Arribem a la lliçó que salda el deute del curs. A 02-02 vam deixar un forat incòmode: CBC i CTR xifrem, sí, però un atacant pot modificar el xifrat sense la clau i sense que ningú se n'adoni — el bit-flipping de l'últim exercici va convertir un saldo de 10 en 90 amb precisió quirúrgica. Xifrar dona confidencialitat, no integritat. La resposta professional a aquest problema té nom: AEAD, Authenticated Encryption with Associated Data, xifratge autenticat amb dades associades. Combina confidencialitat, integritat i autenticitat en una sola operació impossible d'usar a mitges. En aquesta lliçó coneixeràs AES-GCM i ChaCha20-Poly1305, entendràs què són el tag i les dades associades (AAD), veuràs Fernet com a recepta d'alt nivell i — el moment que portem esperant des del mòdul 1 — construiràs el mòdul medinube.cripto que xifra per fi els historials de MediNube en disc, amb el format versionat v1 promès a 01-04.
Contingut
- El problema: xifrar no impedeix manipular
- Què afegeix AEAD i com ho fa (el tag)
- AES-GCM amb
AESGCM - ChaCha20-Poly1305 amb
ChaCha20Poly1305 - Dades associades (AAD): autenticar sense xifrar
- Fernet: la recepta d'alt nivell
- Cas central: xifrar els historials de MediNube (
medinube.cripto, formatv1)
El problema: xifrar no impedeix manipular
Un xifratge s'anomena maleable quan un atacant pot transformar el text xifrat per produir un canvi predictible en el text en clar, sense conèixer la clau. A 02-02 ho vam veure a CTR: com que xifrat = missatge XOR keystream, girar un bit del xifrat gira exactament aquest bit del missatge. CBC és maleable d'una altra forma (manipular un bloc xifrat corromp aquest bloc però altera de forma controlada el següent). El patró conceptual de l'atac, sobre un camp d'un historial de MediNube:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clau, nonce = os.urandom(32), os.urandom(16)
historial = b"ALERGIA: iode TRACTAMENT: actiu"
c = Cipher(algorithms.AES(clau), modes.CTR(nonce)).encryptor()
xifrat = bytearray(c.update(historial) + c.finalize())
# L'atacant NO coneix la clau, però sí la plantilla (Kerckhoffs).
# Sap on comença el camp TRACTAMENT i que acaba en "actiu".
# Canvia "actiu" per "parat" girant els bytes exactes:
pos = historial.index(b"actiu")
for i, (a, b) in enumerate(zip(b"actiu", b"parat")):
xifrat[pos + i] ^= a ^ b
d = Cipher(algorithms.AES(clau), modes.CTR(nonce)).decryptor()
print(d.update(bytes(xifrat)) + d.finalize())
# b'ALERGIA: iode TRACTAMENT: parat'L'atacant ha alterat un historial clínic sense desxifrar-lo. En un sistema mèdic, poder canviar "TRACTAMENT: actiu" per "parat" — o pitjor, esborrar una al·lèrgia — és tan greu com poder llegir-lo. Xifrar sense autenticar és mitja protecció, i la meitat que falta pot costar vides. Necessitem que desxifrar falli sorollosament si un sol bit ha canviat.
Què afegeix AEAD i com ho fa (el tag)
Un esquema AEAD garanteix tres coses de cop:
- Confidencialitat: ningú sense la clau llegeix el missatge (com CTR/CBC).
- Integritat: qualsevol modificació del xifrat es detecta.
- Autenticitat: només qui té la clau ha pogut produir aquest xifrat.
Com? En xifrar, l'algorisme calcula a més un tag d'autenticació (authentication tag): un valor curt — típicament 16 bytes — que és una mena de "segell" criptogràfic sobre el xifrat i la clau. En desxifrar, l'algorisme recalcula el tag i el compara: si no coincideix (perquè algú ha tocat un bit), l'operació llança una excepció i no retorna res. No hi ha "desxifrar a mitges": o les dades estan intactes i autèntiques, o error.
flowchart LR
subgraph Xifrar
M[missatge] --> ENC[AEAD encrypt]
K1[clau] --> ENC
N1[nonce] --> ENC
ENC --> CT[xifrat + tag]
end
subgraph Desxifrar
CT --> DEC[AEAD decrypt]
K2[clau] --> DEC
N2[nonce] --> DEC
DEC -->|tag OK| OUT[missatge]
DEC -->|tag malament| ERR[⚠ excepció: dades manipulades]
end
La comparació del tag es fa, per descomptat, en temps constant (recorda compare_digest, 01-04): la llibreria ho gestiona per tu. Aquest és el gran valor d'AEAD i la raó per la qual avui és l'estàndard de facto: és molt difícil d'usar malament. No cal recordar-se d'afegir integritat a part ni de validar el farciment amb cura — ve tot integrat. Els dos AEAD que faràs servir són les versions "amb superpoders" dels algorismes del mòdul:
- AES-GCM = AES en mode CTR (confidencialitat) + un autenticador anomenat GHASH (integritat).
- ChaCha20-Poly1305 = ChaCha20 (confidencialitat) + l'autenticador Poly1305 (integritat).
AES-GCM amb AESGCM
GCM (Galois/Counter Mode) és el mode AEAD més usat del món: és el que domina a TLS. A pyca/cryptography viu a la capa hazmat però amb una API d'alt nivell molt còmoda, la classe AESGCM.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
clau = AESGCM.generate_key(bit_length=256) # 32 bytes del CSPRNG; el nostre estàndard
aesgcm = AESGCM(clau)
nonce = os.urandom(12) # GCM usa nonce de 12 bytes (96 bits)
missatge = "AL·LÈRGIA: penicil·lina".encode("utf-8")
# xifrar(nonce, dades, aad). El tag va INCLÒS al final del xifrat retornat.
xifrat = aesgcm.encrypt(nonce, missatge, None) # None = sense dades associades (de moment)
# desxifrar valida el tag; si alguna cosa ha canviat, llança InvalidTag.
recuperat = aesgcm.decrypt(nonce, xifrat, None)
print(recuperat.decode("utf-8")) # AL·LÈRGIA: penicil·lina
# Prova d'integritat: manipulem un byte del xifrat.
manipulat = bytearray(xifrat)
manipulat[0] ^= 1
try:
aesgcm.decrypt(nonce, bytes(manipulat), None)
except Exception as e:
print(type(e).__name__) # InvalidTag ✅ detectatDetalls crucials:
- Nonce de 12 bytes. És la mida canònica i recomanada per a GCM (no els 16 de CTR/CBC). I continua vigent la regla d'or de 02-02: mai repeteixis la parella clau+nonce. A GCM la reutilització és encara més greu que a CTR, perquè a més de filtrar els missatges, permet recuperar la clau interna d'autenticació i falsificar tags. Genera el nonce amb
os.urandom(12)a cada xifratge. - El tag va inclòs.
encryptretornaxifrat || tagen un solbytes;decryptespera aquest mateix format. No has de gestionar el tag per separat. - Límit d'ús del mateix nonce/clau. Amb nonces aleatoris de 12 bytes, GCM té un límit pràctic de missatges per clau (de l'ordre de 2^32 abans que la probabilitat de col·lisió de nonce deixi de ser menyspreable). Molt folgat per a MediNube, però per això el mòdul 6 parlarà de rotació de claus. Retén-ho com un límit existent, no infinit.
AESGCM.generate_key(bit_length=256)és una drecera còmoda equivalent aos.urandom(32).
ChaCha20-Poly1305 amb ChaCha20Poly1305
L'alternativa de flux té una API idèntica, cosa que fa trivial canviar de l'una a l'altra (criptoagilitat, 01-04):
import os
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
clau = ChaCha20Poly1305.generate_key() # 32 bytes (ChaCha20 sempre són 256 bits)
chacha = ChaCha20Poly1305(clau)
nonce = os.urandom(12) # també 12 bytes
missatge = "AL·LÈRGIA: penicil·lina".encode("utf-8")
xifrat = chacha.encrypt(nonce, missatge, None)
recuperat = chacha.decrypt(nonce, xifrat, None)
print(recuperat.decode("utf-8")) # AL·LÈRGIA: penicil·linaMateixa signatura (nonce, dades, aad), mateix nonce de 12 bytes, mateix tag inclòs, mateixa regla de no reutilitzar nonce. L'elecció entre AESGCM i ChaCha20Poly1305 és la de 02-01: AES-GCM en servidors amb AES-NI (el cas de MediNube), ChaCha20-Poly1305 en mòbil/embegut. Per això medinube.cripto centralitzarà l'elecció: canviar d'un a l'altre serà tocar un sol lloc.
Dades associades (AAD): autenticar sense xifrar
L'"AD" d'AEAD són les Associated Data (dades associades): informació que vols autenticar però NO xifrar. Van al tercer paràmetre d'encrypt/decrypt. El tag es calcula també sobre elles, així que si canvien, decrypt falla — però viatgen en clar.
Quan serveix això? Quan una dada ha d'anar visible (perquè el sistema la necessita per encaminar, versionar o indexar abans de desxifrar) però no ha de poder-se falsificar. Exemples típics a MediNube:
- L'identificador de pacient i la versió del format: s'han de poder llegir sense desxifrar (per saber a qui pertany el registre i amb quin esquema desxifrar-lo), però no s'han de poder alterar. Si un atacant intenta "reetiquetar" l'historial xifrat d'Ana Pérez com si fos d'un altre pacient, canviar l'AAD farà que
decryptfalli.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(AESGCM.generate_key(bit_length=256))
nonce = os.urandom(12)
historial = b"ALERGIA: iode"
aad = b"paciente=ana.perez;formato=v1" # autenticat, viatja EN CLAR
xifrat = aesgcm.encrypt(nonce, historial, aad)
# Desxifrar amb el MATEIX aad funciona:
print(aesgcm.decrypt(nonce, xifrat, aad)) # b'ALERGIA: iode'
# Desxifrar amb un aad diferent (l'atacant reetiqueta el pacient) FALLA:
try:
aesgcm.decrypt(nonce, xifrat, b"paciente=otro;formato=v1")
except Exception as e:
print(type(e).__name__) # InvalidTag ✅El camp aad — el format literal paciente=...;formato=v1 — és una cadena de protocol interna de MediNube (com la resta del curs, no es tradueix: viatja tal qual entre el codi que xifra i el que desxifra). L'AAD lliga el xifrat al seu context: el registre només és vàlid per a aquell pacient i aquella versió. És una eina potentíssima i la farem servir al format v1.
Fernet: la recepta d'alt nivell
Tot l'anterior és la capa hazmat. Per a molts casos no cal baixar tant: pyca/cryptography ofereix Fernet, una recepta d'alt nivell que pren totes les decisions per tu.
from cryptography.fernet import Fernet clau = Fernet.generate_key() # clau llesta per usar (format Base64 urlsafe) f = Fernet(clau) token = f.encrypt(b"ALERGIA: iode") # retorna un token Base64 autocontingut print(f.decrypt(token)) # b'ALERGIA: iode'
Què fa Fernet per dins, perquè sàpigues què estàs acceptant:
- Xifra amb AES-128 en mode CBC i autentica amb HMAC-SHA256 (l'HMAC el veurem al mòdul 3): és AEAD "compost a mà per experts".
- Genera l'IV automàticament, inclou un timestamp (permet caducar tokens amb
decrypt(token, ttl=...)), i empaqueta tot — versió, timestamp, IV, xifrat, tag — en un únic token Base64 que pots desar com a text. - Gestiona el format i la rotació de claus (
MultiFernet).
AESGCM / ChaCha20Poly1305 (hazmat) |
Fernet (alt nivell) | |
|---|---|---|
| Nivell | Primitiva AEAD | Recepta completa |
| Control | Total (nonce, AAD, algorisme, format) | Cap: decideix la llibreria |
| Algorisme | AES-256-GCM / ChaCha20-Poly1305 | AES-128-CBC + HMAC-SHA256 |
| Format de sortida | bytes crus (tu dissenyes l'embolcall) |
Token Base64 autocontingut |
| AAD | Sí | No |
| Quan usar-lo | Necessites AAD, format propi, AES-256, o rendiment | Cas general "xifra això i prou", tokens |
Quan n'hi ha prou amb Fernet: quan vols "xifrar això de forma segura" sense dissenyar un format, no necessites AAD i AES-128 et val. Per a MediNube, en canvi, volem AES-256 (el nostre estàndard), AAD (lligar l'historial al seu pacient) i un format versionat propi (v1, criptoagilitat): per això construirem sobre AESGCM. Regla professional: usa Fernet tret que tinguis una raó concreta per baixar a hazmat — i aquí la tenim.
Cas central: xifrar els historials de MediNube (medinube.cripto, format v1)
Ha arribat el moment. Des del mòdul 1 arrosseguem el deute que els historials de pacients com Ana Pérez estan sense xifrar en disc. El saldarem al mòdul centralitzat medinube.cripto que vam prometre a 01-04, amb el format v1 = AES-256-GCM.
Disseny del format del registre xifrat (el que es desa en disc), un bytes autocontingut:
┌─────────┬───────────────┬──────────────────────────┐ │ versió │ nonce │ xifrat + tag │ │ 1 byte │ 12 bytes │ (resta) │ └─────────┴───────────────┴──────────────────────────┘ 0x01 os.urandom(12) AESGCM.encrypt(...)
- El byte de versió al principi fa el format criptoàgil: demà
v2podrà ser ChaCha20-Poly1305 o AES amb una altra gestió de clau, i el codi sabrà pel primer byte com desxifrar cada registre heretat (01-04). - El nonce viatja junt amb el xifrat (és públic; la seva única obligació és no repetir-se) — així resolem el "cal desar l'IV/nonce" de 02-02.
- Com a AAD hi posem l'id de pacient i la versió: lliguen el registre al seu propietari i al seu esquema, autenticats encara que no xifrats.
# medinube/cripto.py — mòdul criptogràfic centralitzat de MediNube.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
VERSIO_V1 = 0x01 # 1 byte: identifica el format AES-256-GCM
class HistorialManipulat(Exception):
"""El registre va ser alterat, corromput o desxifrat amb la clau/pacient equivocats."""
def xifrar_historial_v1(clau: bytes, id_pacient: str, text_clar: bytes) -> bytes:
"""Xifra un historial amb AES-256-GCM. Retorna: versio || nonce || (xifrat+tag)."""
aesgcm = AESGCM(clau) # clau = 32 bytes (d'on surt: lliçó 02-04)
nonce = os.urandom(12) # nonce ÚNIC per registre (regla d'or 02-02)
aad = f"paciente={id_pacient};formato=v1".encode("utf-8")
xifrat = aesgcm.encrypt(nonce, text_clar, aad)
return bytes([VERSIO_V1]) + nonce + xifrat
def desxifrar_historial(clau: bytes, id_pacient: str, registre: bytes) -> bytes:
"""Desxifra un registre. Llança HistorialManipulat si el tag no valida."""
versio = registre[0]
if versio != VERSIO_V1:
raise ValueError(f"Versió de format desconeguda: {versio}")
nonce = registre[1:13] # 12 bytes després del byte de versió
xifrat = registre[13:] # xifrat + tag
aad = f"paciente={id_pacient};formato=v1".encode("utf-8")
aesgcm = AESGCM(clau)
try:
return aesgcm.decrypt(nonce, xifrat, aad)
except Exception: # cryptography llança InvalidTag
raise HistorialManipulat(
"L'historial va ser manipulat o la clau/pacient no coincideixen."
)Ús complet, tancant el cercle amb Ana Pérez:
import os
from medinube.cripto import xifrar_historial_v1, desxifrar_historial, HistorialManipulat
clau = os.urandom(32) # clau mestra provisional; el seu origen real és la lliçó 02-04
historial = "Pacient Ana Perez. AL·LÈRGIA: penicil·lina. Grup 0+.".encode("utf-8")
registre = xifrar_historial_v1(clau, "ana.perez", historial)
# 'registre' és el que es desa en disc: il·legible i a prova de manipulacions.
print(registre[:1].hex(), "->", "versió") # 01 -> versió
# Lectura legítima:
print(desxifrar_historial(clau, "ana.perez", registre).decode("utf-8"))
# Pacient Ana Perez. AL·LÈRGIA: penicil·lina. Grup 0+.
# Atac 1: manipular el xifrat (el bit-flipping de 02-02, ara bloquejat).
falsificat = bytearray(registre)
falsificat[20] ^= 1
try:
desxifrar_historial(clau, "ana.perez", bytes(falsificat))
except HistorialManipulat as e:
print("BLOQUEJAT:", e)
# Atac 2: reetiquetar el registre com d'un altre pacient (l'AAD no coincideix).
try:
desxifrar_historial(clau, "un.altre.pacient", registre)
except HistorialManipulat as e:
print("BLOQUEJAT:", e)Tots dos atacs que a 02-02 tenien èxit ara fallen sorollosament. Amb això, la necessitat central del curs queda per fi coberta: els historials de MediNube es desen xifrats amb AES-256-GCM, amb integritat, autenticitat i lligats al seu pacient, en un format versionat llest per evolucionar.
Queden dos caps, i són deliberats:
- D'on surt la
claude 32 bytes? En aquests exemples la generem ambos.urandom(32), però en un sistema real la clau ha de provenir d'algun lloc estable: una passphrase de l'administrador, una clau mestra de la qual es deriven subclaus... Aquest és exactament el tema de la propera lliçó, 02-04 (KDF). - Com es desa, protegeix i rota aquesta clau en producció? (gestors de secrets, HSM, rotació). Això és matèria del mòdul 6; aquí només ho assenyalem.
Com sempre: dades fictícies. Un desplegament real amb dades sanitàries exigeix, a més d'aquesta base criptogràfica, revisió per part de professionals de seguretat i compliment normatiu (RGPD).
Errors Comuns i Consells
- Xifrar sense autenticar el 2026. Si uses CBC o CTR "a pèl" per a dades que altres poden tocar, tens un bug. Per defecte: AEAD.
- Reutilitzar el nonce a GCM. Encara més greu que a CTR: compromet la clau d'autenticació i permet falsificar. Nonce aleatori de 12 bytes per operació, sense excepcions.
- Ignorar l'excepció de
decrypt. L'InvalidTag(o el teuHistorialManipulat) no és una nosa, és la protecció funcionant. Mai la capturis per "continuar amb les dades igualment": si salta, les dades no són vàlides i punt. - Ficar secrets a l'AAD. L'AAD no es xifra, només s'autentica. Va en clar. Per a l'id de pacient i la versió és perfecte; per a dades sensibles, mai.
- Oblidar desar el nonce. S'ha d'emmagatzemar amb el xifrat. Per això l'incloem al propi registre
v1; no ho deixis "per després". - Baixar a
hazmatsense necessitat. Si Fernet et val (sense AAD, AES-128 acceptable), usa'l. Baixem aAESGCMperquè MediNube necessita AES-256, AAD i format versionat — raons concretes, no per gust.
Exercicis
-
La xarxa de seguretat. Fent servir
medinube.cripto, xifra un historial i després, sobre elregistre, prova tres manipulacions: (a) girar un byte del xifrat, (b) canviar l'id_pacienten desxifrar, (c) canviar el byte de versió a0x02. Classifica quina excepció llança cadascuna i per què. -
AAD que viatja en clar. Demostra que l'AAD no es xifra: xifra un historial amb
aad=b"paciente=ana.perez;formato=v1"i comprova que la subcadenab"ana.perez"no apareix als bytes del xifrat, però que tot i així el registre queda lligat a aquest pacient (desxifrar amb un altre id falla). Quina propietat d'AEAD il·lustra això? -
Fernet vs
v1. Xifra el mateix historial amb Fernet i ambxifrar_historial_v1. Enumera tres diferències concretes entre tots dos resultats i justifica per què MediNube va triar construirv1sobreAESGCMen lloc d'usar Fernet.
Solucions
-
(a) Girar un byte del xifrat →
HistorialManipulat(internamentInvalidTag): el tag recalculat no coincideix amb el desat. (b) Canviar l'id_pacient→HistorialManipulattambé: l'AAD forma part del càlcul del tag, així que un AAD diferent invalida la verificació. (c) Canviar la versió a0x02→ValueError("Versió de format desconeguda: 2"): el nostredesxifrar_historialrebutja el registre abans d'intentar desxifrar, perquè no sap interpretar aquest format. Les tres protegeixen, però per mecanismes diferents: (a) i (b) pel tag AEAD, (c) per la comprovació de versió del format criptoàgil.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(os.urandom(32)); nonce = os.urandom(12)
aad = b"paciente=ana.perez;formato=v1"
xifrat = aesgcm.encrypt(nonce, "AL·LÈRGIA: penicil·lina".encode("utf-8"), aad)
print(b"ana.perez" in xifrat) # False: l'AAD no està DINS del xifratL'id no apareix al xifrat perquè l'AAD no es xifra ni s'annexa: només participa en el càlcul del tag. Tot i així, desxifrar amb un altre id falla (ho has vist a l'exemple). Il·lustra l'essència d'AEAD: es pot autenticar una dada sense xifrar-la — integritat i confidencialitat són propietats independents que AEAD combina a voluntat.
- Diferències: (i) algorisme — Fernet usa AES-128-CBC+HMAC;
v1usa AES-256-GCM (el nostre estàndard de 256 bits). (ii) format — Fernet retorna un token Base64 autocontingut que no controlem;v1és unbytesbinari amb un byte de versió propi que governem per a la criptoagilitat. (iii) AAD — Fernet no admet dades associades;v1lliga el registre al pacient via AAD. MediNube va triarv1perquè necessita aquestes tres coses concretes: AES-256, control del format versionat (per a migracions futures) i el vincle autenticat amb el pacient. Per a un cas sense aquests requisits, Fernet hauria estat l'opció correcta i més simple.
Conclusió
Has tancat el forat que arrossegàvem des de 02-02 i has saldat el deute central del curs. El xifratge autenticat (AEAD) uneix confidencialitat, integritat i autenticitat en una operació difícil d'usar malament: un tag que es recalcula en desxifrar i fa fallar l'operació davant qualsevol manipulació d'un sol bit. Domines les dues construccions estàndard — AES-GCM (AESGCM) i ChaCha20-Poly1305 (ChaCha20Poly1305), d'API idèntica i nonce de 12 bytes irrepetible —, saps usar AAD per autenticar dades en clar com l'id de pacient, i coneixes Fernet com a recepta d'alt nivell per quan no necessites més. I, sobretot, has construït medinube.cripto: els historials d'Ana Pérez ja es desen xifrats en disc amb AES-256-GCM en el format versionat v1, resistint tant la lectura com el bit-flipping i el reetiquetatge. Només falta respondre una pregunta que hem ajornat a cada exemple amb un os.urandom(32) provisional: d'on surt aquesta clau mestra? Una contrasenya d'administrador no són 32 bytes d'entropia pura, i no es pot ficar una passphrase directament com a clau. La resposta — convertir contrasenyes en claus i derivar subclaus d'una clau mestra — és la derivació de claus, i tanca el mòdul a la propera lliçó, 02-04: Derivació de Claus (KDF). Ens veiem allà.
Curs de Criptografia Aplicada
Mòdul 1: Fonaments de la Criptografia
- Què és la criptografia i per a què serveix
- Codificació, ofuscació i xifratge
- Aleatorietat i entropia
- El principi de Kerckhoffs i les regles d'or
Mòdul 2: Criptografia Simètrica
- Xifratge simètric: AES i ChaCha20
- Modes d'operació
- Xifratge autenticat (AEAD)
- Derivació de claus (KDF)
Mòdul 3: Hashes, MAC i Contrasenyes
- Funcions hash criptogràfiques
- Autenticació de missatges amb HMAC
- Emmagatzematge segur de contrasenyes
Mòdul 4: Criptografia Asimètrica
- Fonaments de clau pública i RSA
- Criptografia de corba el·líptica
- Signatures digitals
- Intercanvi de claus: Diffie-Hellman
- Xifratge híbrid
Mòdul 5: PKI, Certificats i TLS
- Certificats X.509 i autoritats de certificació
- TLS a la pràctica
- Gestió del cicle de vida dels certificats
