A la lliçó anterior vam xifrar exactament 16 bytes amb AES i ens vam topar amb la paret: un historial real fa kilobytes, i una màquina que només processa blocs de 16 bytes necessita instruccions sobre com encadenar-los. Aquestes instruccions són el mode d'operació, i són la diferència entre un xifratge segur i una filtració de dades amb AES perfectament intacte per sota. En aquesta lliçó veuràs — amb codi, sobre un historial de MediNube — per què el mode ECB és un desastre estructural (el famós "pingüí"), com ho arreglen CBC (amb el seu IV i el seu farciment) i CTR (convertint AES en un xifratge en flux), i una catàstrofe que reapareixerà en tot el curs: reutilitzar un nonce. Al final tindràs una regla que no es negocia i una peça que encara falta: cap d'aquests modes detecta manipulacions. Això ho resoldrà la lliçó 02-03.
Contingut
- Per què un xifratge en bloc necessita un mode
- ECB: el mode que no has d'usar (i el pingüí)
- CBC: encadenament, IV i farciment PKCS7
- CTR: el comptador que converteix AES en xifratge en flux
- La catàstrofe del nonce reutilitzat
- Taula comparativa i la regla d'or de l'IV/nonce
Per què un xifratge en bloc necessita un mode
AES és una funció que transforma 16 bytes en 16 bytes amb una clau. Res més. Per xifrar un missatge de qualsevol altra mida cal respondre a dues preguntes:
- Com trossejo el missatge i encadeno els blocs? Si el missatge fa 1600 bytes, són 100 blocs: els xifrem independents? Els enllacem? Aquesta decisió és el mode d'operació.
- Què faig amb l'últim tros si no arriba a 16 bytes? Un missatge de 22 bytes té un bloc complet i 6 bytes solts. Omplir aquest buit és el farciment (padding), i no tots els modes el necessiten.
flowchart LR
M[Missatge de N bytes] --> S[Trossejar en blocs de 16]
S --> B1[Bloc 1]
S --> B2[Bloc 2]
S --> B3[... Bloc k]
B1 & B2 & B3 --> MODE{Mode d'operació:\ncom es combinen}
MODE --> C[Text xifrat]
L'elecció del mode no canvia AES: canvia com s'orquestren les crides a AES. I aquí és on es guanya o es perd la seguretat. Anem a veure-ho.
ECB: el mode que no has d'usar (i el pingüí)
ECB (Electronic Codebook) és el mode més ingenu: xifra cada bloc de 16 bytes de forma independent, amb la mateixa clau i sense cap relació entre ells.
flowchart LR
subgraph ECB
P1[Bloc 1] --> E1[AES] --> Cc1[Xifrat 1]
P2[Bloc 2] --> E2[AES] --> Cc2[Xifrat 2]
P3[Bloc 3] --> E3[AES] --> Cc3[Xifrat 3]
end
La fallada és estructural i aclaparadora: com que cada bloc es xifra igual, blocs de text en clar idèntics produeixen blocs de xifrat idèntics. El xifratge es converteix en una taula de correspondències que preserva els patrons de l'original. La il·lustració clàssica és el "pingüí de Linux" (Tux): si xifres la imatge del pingüí amb ECB, continues veient el pingüí, perquè les grans zones del mateix color produeixen els mateixos blocs xifrats. AES està intacte; el mode el delata.
Reproduïm l'efecte sobre dades de MediNube. Els historials tenen estructura repetitiva (camps que es repeteixen entre pacients), i ECB la filtra:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clau = os.urandom(32)
# Un "historial" amb estructura repetitiva: el mateix bloc de 16 bytes
# apareix tres vegades (imagina un camp de plantilla repetit al registre).
bloc_repetit = b"CLINICA: SOL----" # exactament 16 bytes
dades = bloc_repetit * 3 + b"PACIENT:ANA-----" # 4 blocs, l'1-2-3 iguals
xifrador = Cipher(algorithms.AES(clau), modes.ECB()).encryptor()
xifrat = xifrador.update(dades) + xifrador.finalize()
# Trossegem el xifrat en blocs de 16 i busquem repeticions.
blocs = [xifrat[i:i+16] for i in range(0, len(xifrat), 16)]
for i, b in enumerate(blocs):
print(i, b.hex())
repetits = len(blocs) - len(set(blocs))
print(f"\nBlocs xifrats repetits: {repetits}") # -> 2Sortida: els tres primers blocs xifrats són idèntics byte a byte, i el comptador detecta 2 repeticions. Sense conèixer la clau ni desxifrar res, un atacant ja sap que "hi ha tres coses iguals al principi" — l'estructura de l'historial s'ha filtrat. En dades reals això revela plantilles, valors repetits (mateix diagnòstic, mateix grup sanguini entre pacients), longituds... informació valuosíssima. La regla és taxativa:
Mai usis ECB per xifrar dades. És el mode que només serveix com a peça interna de construccions més complexes, mai directament.
Com s'arregla? Fent que blocs iguals es xifrin diferent. Hi ha dues estratègies, i donen lloc a CBC i CTR.
CBC: encadenament, IV i farciment PKCS7
CBC (Cipher Block Chaining) trenca la repetició encadenant els blocs: abans de xifrar cada bloc, se li aplica XOR amb el xifrat del bloc anterior. Així, el resultat de cada bloc depèn de tots els anteriors; dos blocs iguals al text en clar produeixen xifrats diferents perquè arriben amb "context" diferent.
flowchart LR
IV[IV aleatori] -->|XOR| X1
P1[Bloc 1] --> X1((XOR)) --> E1[AES] --> C1[Xifrat 1]
C1 -->|XOR| X2
P2[Bloc 2] --> X2((XOR)) --> E2[AES] --> C2[Xifrat 2]
C2 -->|XOR| X3
P3[Bloc 3] --> X3((XOR)) --> E3[AES] --> C3[Xifrat 3]
Però el primer bloc no té "anterior". Aquí entra l'IV (Initialization Vector, vector d'inicialització): un bloc de 16 bytes que fa de "xifrat del bloc zero". Els seus requisits:
- Aleatori i impredictible per a cada missatge: surt del CSPRNG (
os.urandom(16)). Si l'IV fos fix o predictible, dos missatges que comencen igual tornarien a delatar-se. - Públic: es desa/transmet junt amb el xifrat, en clar. No és un secret; el seu valor rau a ser irrepetible i impredictible.
I com que CBC xifra bloc a bloc, el missatge ha de ser múltiple exacte de 16 bytes. Gairebé mai ho és, així que se n'omple l'últim bloc. L'estàndard és PKCS7: omple amb bytes el valor dels quals és el nombre de bytes de farciment afegits. Si falten 5 bytes per completar el bloc, afegeix 05 05 05 05 05; si el missatge ja era múltiple de 16, afegeix un bloc sencer de 10 10 ... 10 (16 en hexadecimal és 0x10), perquè en desxifrar sempre se sàpiga quant treure sense ambigüitat.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
clau = os.urandom(32)
iv = os.urandom(16) # IV aleatori de 16 bytes, un per missatge
missatge = "HISTORIAL D'ANA PEREZ".encode("utf-8") # 21 bytes, NO múltiple de 16
# 1. Omplir fins a múltiple de la mida de bloc (128 bits = 16 bytes).
farcidor = padding.PKCS7(128).padder()
farcit = farcidor.update(missatge) + farcidor.finalize()
print(len(farcit)) # 32: es va omplir de 21 a 32 (2 blocs)
# 2. Xifrar amb CBC, passant l'IV.
xifrador = Cipher(algorithms.AES(clau), modes.CBC(iv)).encryptor()
xifrat = xifrador.update(farcit) + xifrador.finalize()
# 3. Per desxifrar cal conservar l'IV (es desa junt amb el xifrat).
desxifrador = Cipher(algorithms.AES(clau), modes.CBC(iv)).decryptor()
farcit2 = desxifrador.update(xifrat) + desxifrador.finalize()
# 4. Treure el farciment.
desfarcidor = padding.PKCS7(128).unpadder()
recuperat = desfarcidor.update(farcit2) + desfarcidor.finalize()
print(recuperat.decode("utf-8")) # HISTORIAL D'ANA PEREZPunts clau del codi:
- L'IV es genera amb
os.urandom(16)a cada xifratge i es desa junt amb el resultat. Sense l'IV correcte no es desxifra. padding.PKCS7(128)— el128són els bits del bloc d'AES (16 bytes). Elpadderafegeix el farciment; l'unpadderel retira i valida que sigui coherent.- CBC resol el problema d'ECB: blocs iguals donen xifrats diferents, i el mateix missatge amb un IV diferent dona un xifrat totalment diferent.
Ara bé, CBC té una fragilitat històrica que és la raó per la qual avui preferim AEAD (lliçó 02-03): el procés de "treure i validar el farciment" es pot convertir en un oracle per a l'atacant. Si el sistema reacciona de forma distingible davant un farciment vàlid enfront d'un d'invàlid (un error, un temps diferent), un atacant pot, enviant xifrats manipulats i observant aquestes reaccions, desxifrar les dades sense la clau. És l'atac d'oracle de farciment (padding oracle attack). No desenvoluparem l'atac aquí — queda't amb la moralitat: CBC "a pèl" és perillós de manejar correctament, i la solució moderna no és pedaçar-lo, sinó usar xifratge autenticat.
CTR: el comptador que converteix AES en xifratge en flux
CTR (Counter) usa una altra idea completament diferent: en lloc de xifrar el missatge, xifra un comptador per generar un keystream, i després fa XOR d'aquest keystream amb el missatge. És a dir, converteix AES (xifratge en bloc) en un xifratge en flux, com els que vam veure a 02-01.
flowchart LR
N0[nonce+comptador 0] --> AE0[AES] --> K0[keystream 0]
N1[nonce+comptador 1] --> AE1[AES] --> K1[keystream 1]
K0 -->|XOR| P0[Bloc 0] --> C0[Xifrat 0]
K1 -->|XOR| P1[Bloc 1] --> C1[Xifrat 1]
Com funciona: es construeix un bloc d'entrada ajuntant un nonce (valor únic per missatge) amb un comptador que comença en 0 i es va incrementant (0, 1, 2, ...). Cadascun d'aquests blocs nonce||comptador es xifra amb AES, i el resultat és el keystream que es combina per XOR amb el missatge. Avantatges notables:
- No necessita farciment: com que és XOR byte a byte, el xifrat fa exactament el mateix que el missatge. Adéu a l'oracle de farciment de CBC.
- Paral·lelitzable: cada bloc del keystream es calcula independentment (n'hi ha prou de conèixer-ne la posició), així que es pot xifrar/desxifrar en paral·lel o accedir a l'atzar a mitja part d'un fitxer.
- Xifrar i desxifrar són la mateixa operació: XOR amb el mateix keystream (desxifrar = tornar a fer XOR).
A pyca/cryptography, CTR rep el bloc inicial complet (nonce + comptador) de 16 bytes:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clau = os.urandom(32)
nonce = os.urandom(16) # bloc inicial (nonce||comptador), únic per missatge
missatge = "HISTORIAL D'ANA PEREZ".encode("utf-8")
xifrador = Cipher(algorithms.AES(clau), modes.CTR(nonce)).encryptor()
xifrat = xifrador.update(missatge) + xifrador.finalize()
print(len(xifrat)) # 21: MATEIXA longitud que el missatge, sense farciment
desxifrador = Cipher(algorithms.AES(clau), modes.CTR(nonce)).decryptor()
recuperat = desxifrador.update(xifrat) + desxifrador.finalize()
print(recuperat.decode("utf-8")) # HISTORIAL D'ANA PEREZCTR és elegant i ràpid, però arrossega un perill mortal que comparteix amb tots els xifratges en flux (ChaCha20 inclòs). Anem a veure-ho amb codi, perquè és una de les lliçons que més cares surten quan s'ignora.
La catàstrofe del nonce reutilitzat
En qualsevol xifratge en flux, el xifrat és xifrat = missatge XOR keystream, i el keystream depèn només de la clau i del nonce. Per tant, si xifrem dos missatges amb la mateixa clau i el mateix nonce, tots dos usen el mateix keystream. I aquí un atacant fa màgia sense conèixer la clau:
El keystream es cancel·la i l'atacant obté el XOR dels dos missatges en clar, sense la clau per a res. Això filtra moltíssim, i si coneix (o endevina) part d'un, recupera la part corresponent de l'altre. Demostració sobre dos historials de MediNube xifrats amb el mateix nonce per error:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clau = os.urandom(32)
nonce = os.urandom(16) # ⚠️ EL MATEIX nonce per a dos missatges: l'error fatal
m1 = b"PACIENT: Ana Perez GRUP: 0+"
m2 = b"PACIENT: Juan Prats GRUP: AB"
def xifra(m):
c = Cipher(algorithms.AES(clau), modes.CTR(nonce)).encryptor()
return c.update(m) + c.finalize()
c1, c2 = xifra(m1), xifra(m2)
# L'atacant NOMÉS veu c1 i c2. Calcula el seu XOR:
xor_xifrats = bytes(a ^ b for a, b in zip(c1, c2))
xor_clars = bytes(a ^ b for a, b in zip(m1, m2))
print(xor_xifrats == xor_clars) # True: C1 XOR C2 == M1 XOR M2, el keystream ha desaparegut
# I si l'atacant coneix/endevina m1 (p. ex. perquè coneix la plantilla),
# aïlla m2 per complet, sense la clau:
m2_recuperat = bytes(a ^ b for a, b in zip(xor_xifrats, m1))
print(m2_recuperat) # b'PACIENT: Juan Prats GRUP: AB'Amb només els dos xifrats i una suposició raonable sobre un dels missatges (la plantilla de l'historial és coneguda: regla de Kerckhoffs, l'enemic coneix el sistema), l'atacant reconstrueix l'altre sencer. La clau AES-256 no ha servit de res. Aquesta és una de les formes més freqüents i catastròfiques de trencar criptografia per mal ús, i no és exclusiva de CTR: li passa exactament igual a ChaCha20 i a AES-GCM (02-03). D'aquí la regla que tanca la lliçó.
Taula comparativa i la regla d'or de l'IV/nonce
| ECB | CBC | CTR | |
|---|---|---|---|
| Idea | Cada bloc independent | Encadena amb el xifrat anterior | Xifra un comptador, XOR amb el missatge |
| IV / nonce? | Cap | IV aleatori i impredictible (16 B) | nonce+comptador únic (16 B) |
| Farciment? | Sí | Sí (PKCS7) | No |
| Longitud del xifrat | Múltiple de 16 | Múltiple de 16 | Igual que el missatge |
| Paral·lelitzable | Sí | Xifrar no, desxifrar sí | Sí (xifrar i desxifrar) |
| Fallada característica | Filtra patrons (pingüí) | Oracle de farciment | Nonce reutilitzat |
| Protegeix la integritat | No | No | No |
| Recomanació | Mai | Només amb integritat afegida; avui, evitar | Només amb integritat afegida (és la base de GCM) |
Dues conclusions que governen tot el que segueix:
Regla d'or: mai reutilitzis la parella clau + IV/nonce. Amb la mateixa clau, cada missatge necessita el seu propi IV/nonce únic (i, en CBC, impredictible). Repetir-lo trenca la confidencialitat de cop, sense necessitat de trencar l'algorisme.
I el gran cap solt, visible a l'última fila de la taula: cap d'aquests modes protegeix la integritat. Res impedeix que un atacant modifiqui el xifrat i que en desxifrar-lo surti una dada alterada sense que ningú se n'adoni. Amb CTR, a més, és esgarrifosament fàcil (canviar un bit del xifrat canvia exactament aquest bit del missatge). Resoldre això — xifrar i detectar manipulacions en una sola operació — és el salt a AEAD que fem a la propera lliçó, i és la construcció amb la qual per fi xifrem els historials de MediNube com cal.
Errors Comuns i Consells
- Usar ECB "perquè és el més simple". És el parany número u. Si el teu codi crida
modes.ECB()per xifrar dades reals, és un bug de seguretat. En aquest curs ECB només ha aparegut com a peça didàctica d'un bloc solt. - Reutilitzar l'IV/nonce. L'error més car del mòdul. Un comptador global mal gestionat, un IV "fix per simplificar", un nonce derivat de l'hora amb poca resolució... tots acaben en reutilització. Genera l'IV/nonce amb el CSPRNG a cada xifratge i desa'l junt amb la dada.
- Creure que l'IV és secret. No ho és: és públic. La seva virtut és ser irrepetible (i, en CBC, impredictible), no ocult.
- Confiar en CBC "a pèl". L'oracle de farciment és real i subtil. La resposta professional no és programar la validació del farciment amb més cura, sinó usar AEAD (02-03).
- Pensar que xifrar = protegir. Xifrar dona confidencialitat, no integritat. Un xifrat sense autenticació és manipulable. Aquest és el pont a la propera lliçó.
- Oblidar desar l'IV/nonce. Sense ell no es pot desxifrar. Forma part de la dada xifrada i s'ha d'emmagatzemar amb ella (ho formalitzarem al format
v1de 02-03).
Exercicis
-
Detector d'ECB. Escriu una funció
sembla_ecb(xifrat: bytes) -> boolque, trossejant el xifrat en blocs de 16 bytes, retorniTruesi hi ha algun bloc repetit. Prova-la amb el xifrat de l'exemple d'ECB de la lliçó i amb un de fet en CTR sobre les mateixes dades. Què observes? -
L'IV importa. Xifra el mateix missatge dues vegades en CBC, primer amb el mateix IV i després amb dos IV diferents. Compara els xifrats en tots dos casos i explica el resultat.
-
Bit-flipping en CTR (aperitiu de 02-03). Xifra en CTR el missatge
b"SALDO: 000010 EUR". Sense conèixer la clau, manipula el byte del xifrat que correspon a un dígit de l'import (fent-li XOR amb un valor) perquè, en desxifrar, l'import canviï. Què demostra això sobre la integritat?
Solucions
def sembla_ecb(xifrat: bytes) -> bool:
blocs = [xifrat[i:i+16] for i in range(0, len(xifrat), 16)]
return len(blocs) != len(set(blocs))Amb el xifrat ECB de dades repetitives retorna True (hi ha blocs repetits → filtració detectable). Amb CTR sobre les mateixes dades retorna False: encara que els blocs del missatge es repeteixin, cadascun es xifra amb una posició diferent del keystream, així que els blocs xifrats són tots diferents. És, en miniatura, la prova que fan servir els analistes per detectar xifrats ECB.
-
Amb el mateix IV, els dos xifrats surten idèntics (mateix missatge + mateix IV + mateixa clau = mateix resultat; això ja filtra que els missatges són iguals). Amb IV diferents, els dos xifrats són completament diferents tot i xifrar el mateix. Aquesta és exactament la funció de l'IV: garantir que xifrar la mateixa dada dues vegades no produeixi la mateixa sortida.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
clau, nonce = os.urandom(32), os.urandom(16)
m = b"SALDO: 000010 EUR"
c = Cipher(algorithms.AES(clau), modes.CTR(nonce)).encryptor()
xifrat = bytearray(c.update(m) + c.finalize())
# Posició de l'"1" de l'import (índex 11). En CTR: xifrat = m XOR keystream,
# així que xifrat[i] XOR X desxifra a m[i] XOR X. Canviem '1'(0x31) per '9'(0x39):
xifrat[11] ^= (ord('1') ^ ord('9'))
d = Cipher(algorithms.AES(clau), modes.CTR(nonce)).decryptor()
print((d.update(bytes(xifrat)) + d.finalize())) # b'SALDO: 000090 EUR'Sense la clau, hem convertit un saldo de 10 en 90 amb precisió quirúrgica. Demostra que CTR (i tot xifratge en flux) és maleable: la confidencialitat no implica integritat. Detectar i rebutjar aquesta manipulació és exactament el que aporta el xifratge autenticat de la propera lliçó.
Conclusió
Ja saps per què AES necessita un mode d'operació i què fa cadascun: ECB filtra patrons i no s'usa mai (ho has vist amb els teus propis ulls sobre un historial repetitiu); CBC encadena blocs amb un IV aleatori i impredictible i omple amb PKCS7, però el seu oracle de farciment el fa perillós de manejar; CTR converteix AES en xifratge en flux, sense farciment i paral·lelitzable, però comparteix amb ChaCha20 la catàstrofe del nonce reutilitzat, que hem demostrat que filtra els missatges per complet. T'enduus la regla que no es negocia — mai repeteixis la parella clau + IV/nonce — i un forat enorme que cap d'aquests modes cobreix: la integritat. El bit-flipping de l'últim exercici ho deixa claríssim — es pot alterar un xifrat sense la clau i sense que ningú ho detecti. La solució no és apilar pedaços sobre CBC o CTR, sinó fer el salt al xifratge autenticat. A la propera lliçó, 02-03: Xifratge Autenticat (AEAD), unim confidencialitat i integritat en una sola operació amb AES-GCM i ChaCha20-Poly1305 — i, per fi, saldem el deute del curs xifrant els historials de MediNube en disc amb el format versionat v1. 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
