Decrittazione AES fallita: chiave, IV, modalità e padding
Quando nei log compare una decrittazione AES fallita, il messaggio che hai in mano descrive quasi sempre il problema sbagliato. Quattro bug che non hanno niente in comune producono sintomi indistinguibili, e quello di gran lunga più frequente, una chiave sbagliata, si presenta come un errore di padding.
Per una decrittazione CBC che solleva BadPaddingException, l’ordine in cui conviene guardare è questo:
- I byte della chiave sono diversi tra i due lati. Con ampio margine, la causa più probabile.
- La derivazione della chiave è diversa. Stessa passphrase, KDF o numero di iterazioni diversi, quindi byte di chiave diversi.
- Il testo cifrato si è danneggiato in transito: troncato, base64 rovinato, oppure passato per un giro di andata e ritorno in una codifica testuale.
- L’IV è sbagliato. Succede davvero, ma non produce un errore di padding. Corrompe sedici byte e resta in silenzio.
L’ordine è strutturale. CBC controlla il padding come ultimo passo della decrittazione, dopo che la chiave è stata applicata e la catena srotolata, quindi il padding è un checksum su tutto quello che sta a monte e fallisce rumorosamente qualunque cosa a monte si sia rotta. Le misurazioni che lo dimostrano sono più sotto; se hai fretta, incolla il tuo testo cifrato nello strumento di decifratura AES e lavora la bisezione della sezione 9.
Tutto quello che segue è stato misurato su java 1.8.0_162, node v25.8.2 e openssl 3.6.2. I default cambiano tra una versione e l’altra, quindi considera i numeri di versione parte del risultato.
1. Parti da ciò che il tuo errore esclude davvero
Un messaggio di errore AES dice quasi niente sulla causa e moltissimo su ciò che la causa non può essere. Usalo per cancellare rami, non per sceglierne uno. Una nota di vocabolario, utile quando cerchi in italiano: la stessa operazione gira sotto due nomi, decrittazione e decifratura, e il padding compare anche tradotto come riempimento. Le due forme si alternano senza una regola, quindi vale la pena provarle entrambe.
| Che cosa vedi | Che cosa esclude | Che cosa resta in gioco |
|---|---|---|
BadPaddingException, bad decrypt, wrong final block length | GCM; un errore di solo IV; un fallimento di decodifica | chiave sbagliata, KDF sbagliata, testo cifrato troncato, byte dell’IV mangiati come testo cifrato, modalità non coincidente, schema di padding non coincidente |
GCM Authentication failed, Unsupported state or unable to authenticate data | il padding; qualsiasi teoria che preveda output parziale | chiave sbagliata, nonce sbagliato, tag staccato o messo nel posto sbagliato, lunghezza del tag sbagliata, AAD non coincidenti |
| Nessuna eccezione, l’output è spazzatura | tutte le modalità autenticate | ECB, CTR, un CBC fortunato, modalità non coincidente, IV sbagliato |
BadPaddingException, bad decrypt, wrong final block length
Lo stesso evento in tre ecosistemi: Java, OpenSSL e .NET. Scatta alla fine di una decrittazione CBC o ECB, quando l’ultimo blocco di testo in chiaro non termina con uno schema PKCS#7 valido.
La parte utile è quella negativa: essere arrivato fin qui significa che il tuo base64 o hex si è decodificato e che il numero di byte era un multiplo di 16 diverso da zero, quindi il trasporto non ha fatto a pezzi i dati e non sei in GCM. wrong final block length è l’eccezione. Lì il conteggio non era un multiplo di 16, il che punta a un troncamento più che alla chiave: salta alla sezione 8.
Authentication failed di GCM e affini
GCM confronta il tag prima di rilasciare un solo byte di testo in chiaro, come richiede il NIST SP 800-38D. Questo lo rende onesto in un modo in cui l’errore di padding non lo è: qualcosa nella tupla (chiave, nonce, testo cifrato, dati aggiuntivi autenticati, tag) non corrisponde a quello che ha usato chi ha cifrato. Non può dirti quale elemento, e non lo farà mai, perché restringere il campo sta deliberatamente fuori da quello che l’algoritmo fa. La sezione 6 copre l’elemento che si rompe più spesso tra linguaggi diversi: la posizione del tag, non il suo valore.
Nessun errore, ma l’output è spazzatura
Questo è l’esito pericoloso, perché una dashboard lo registra come un successo. CTR non solleva mai eccezioni ed ECB nemmeno. CBC ne solleva una solo quando lo schema dei byte finali non supera il controllo del padding, e con una chiave sbagliata quel byte è di fatto casuale, quindi circa un tentativo su 256 cade su 0x01 e passa la validazione. Poco meno dello 0.4% delle decrittazioni CBC con chiave sbagliata “riesce”. La spazzatura però ha una forma, e la forma dà il nome al bug: le sezioni 4 e 5 contengono le due impronte che vale la pena memorizzare.
2. L’errore più ingannevole di AES
Un solo esperimento riordina le priorità di debug di quasi tutti. Chiave 0123456789abcdef, IV di soli zeri, AES/CBC/PKCS5Padding, testo in chiaro hello world, su java 1.8.0_162 con il provider SunJCE integrato nel JDK:
| Scenario | Modifica | Risultato misurato |
|---|---|---|
| A | Chiave sbagliata di 1 byte (ultimo carattere f → X) | solleva javax.crypto.BadPaddingException: Given final block not properly padded. Il padding in sé non è mai stato malformato; l’errore è del tutto fuorviante |
| B | Chiave corretta, IV sbagliato di 1 byte | nessuna eccezione, il testo in chiaro hello world è tornato indietro come iello world. Si è danneggiato solo il byte corrispondente del primo blocco |
| C | Chiave corretta, decrittazione del testo cifrato CBC con AES/ECB | riuscita in silenzio, nessuna eccezione. Una modalità non coincidente non deve per forza sollevare qualcosa |
Lo scenario A manda fuori strada interi pomeriggi. Lo scenario C spedisce dati sbagliati in produzione.
Perché una chiave sbagliata produce un errore di padding
Del padding non c’era niente di sbagliato. Chi ha cifrato ha aggiunto cinque byte 0x05 per portare hello world a sedici, ha cifrato quel blocco, e il risultato sta nel tuo testo cifrato intatto.
Il guasto avviene all’uscita. La decrittazione CBC esegue il cifrario a blocchi al contrario, mette in XOR ogni risultato con il blocco di testo cifrato precedente, e solo allora legge la coda del blocco finale per decidere quanti byte togliere. Con la chiave sbagliata il cifrario produce sedici byte di rumore, e il rumore quasi mai termina con uno schema PKCS#7 valido. La libreria riporta quello che ha visto, padding non valido, il che è vero e inutile.
Leggi BadPaddingException come “il testo in chiaro che ho ricostruito non finisce come finisce un testo in chiaro con padding”. Il motivo più probabile per cui la tua ricostruzione è sbagliata è la chiave, motivo per cui una ricerca su aes decrypt wrong key e una ricerca su una bad padding exception ti portano negli stessi thread: i due sintomi sono un sintomo solo. Una nota di design, già che ci sei. Non esporre mai quella distinzione a chi chiama, perché distinguere “padding non valido” da “padding valido, contenuto sbagliato” è ciò di cui si nutre un attacco padding oracle (Vaudenay, EUROCRYPT 2002).
Che cosa fa GCM di diverso
GCM inverte l’ordine e verifica il tag prima di produrre qualsiasi testo in chiaro, quindi non esiste una finestra in cui esistano byte parzialmente corretti. Un fallimento GCM non ti lascia mai a chiederti se l’output sia reale, perché output non ce n’è. GCM inoltre non ha padding, essendo costruita sopra una modalità a contatore, quindi la lunghezza del testo cifrato è uguale a quella del testo in chiaro. Un errore di padding in un sistema che credevi fosse GCM dimostra perciò che il sistema non è GCM: di solito una configurazione che è ricaduta su CBC.
3. I due lati usano gli stessi byte di chiave?
AES non vede la tua stringa di chiave. Vede 16, 24 o 32 byte. Due sistemi possono conservare materiale di chiave identico in un file di configurazione e non essere comunque d’accordo, perché “identico” è una proprietà del testo, non dei byte.
I tre modi in cui una stringa di chiave diventa byte
Passa la stringa letterale 0123456789abcdef a tre librerie diverse:
come hex -> 8 byte (lunghezza di chiave AES non valida)
come base64 -> 12 byte (lunghezza di chiave AES non valida)
come UTF-8 grezzo -> 16 byte (AES-128 valida)
Sedici caratteri, tre conteggi di byte. È un caso cattivo proprio perché è valido sotto tutte e tre le letture: ogni carattere appartiene sia all’alfabeto hex sia a quello base64, e sedici caratteri sono una lunghezza legale per entrambi i decodificatori, quindi al momento del parsing non dà errore niente.
La guida alla firma JWT non valida contiene la matrice completa di come ogni ecosistema interpreta una stringa segreta; la versione breve per AES è: scrivi da qualche parte in quale codifica sta il tuo materiale di chiave e fai in modo che entrambi i lati lo decodifichino in modo esplicito. La stessa insidia in versione HMAC morde i ricevitori di webhook, ed è trattata nella guida alla verifica della firma webhook.
AES è rigido: esattamente 16, 24 o 32 byte
È qui che AES si distingue dalla primitiva che quasi tutti gli sviluppatori incontrano per prima. HMAC accetta qualsiasi lunghezza di chiave: l’RFC 2104 calcola l’hash di qualsiasi cosa più lunga della dimensione del blocco e riempie di zeri qualsiasi cosa più corta, quindi un generatore HMAC accetta senza protestare un segreto da 7 byte o da 700. AES ha soltanto tre lunghezze di chiave legali e rifiuta tutto il resto prima ancora di elaborare un singolo blocco.
Quel rigore torna utile, perché un errore di lunghezza è l’unico guasto AES che dichiara la propria causa invece di nascondersi dietro il padding. Il nostro strumento lo formula così: La chiave deve essere di 16, 24 o 32 byte (AES-128/192/256). Le trappole che producono una lunghezza sbagliata:
- Un a capo finale lasciato da
KEY=$(cat key.txt)oecho "$KEY". Usaprintfedecho -n. Uno spazio finale incollato dall’interfaccia di un gestore di segreti fa la stessa cosa. - Un prefisso
0xcopiato da un debugger: trentaquattro caratteri che non sono più hex valido. - Caratteri non ASCII.
contraseñaè 10 caratteri e 11 byte in UTF-8, quindi una passphrase “da 32 caratteri” con una lettera accentata è di 33 byte.
SecretKeySpec e il charset di default della piattaforma
Java ha una versione di questo problema che si manifesta solo dopo il deploy. "my secret".getBytes() senza argomenti usa il charset di default della piattaforma, che prima del JDK 18 arrivava dalla proprietà file.encoding e quindi dal sistema operativo e dal locale della macchina. Un portatile su UTF-8 e un container su ANSI_X3.4-1968 producono byte diversi per qualsiasi carattere non ASCII. La JEP 400 ha reso UTF-8 il default nel JDK 18, il che sistema il codice nuovo e niente altro.
// sbagliato: i byte dipendono dalla macchina
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");
// giusto: i byte non dipendono da niente
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");
Se il tuo codice funziona in locale, fallisce sul server con un errore di padding e la passphrase contiene qualcosa fuori dall’ASCII, controlla prima questo.
4. L’IV: dove va a finire e che aspetto ha quando è sbagliato
Un aes iv mismatch è il guasto che si sospetta per primo e si diagnostica per ultimo, perché non si comporta come gli altri. È silenzioso ed è locale.
Un IV sbagliato corrompe esattamente un blocco
Guarda di nuovo lo scenario B. Chiave corretta, IV sbagliato di un byte:
hello world -> iello world
Nessuna eccezione, un carattere. Scrivi il passaggio CBC per il primo blocco ed è evidente: P1 = D(C1) XOR IV. L’IV viene messo in XOR direttamente nel primo blocco di testo in chiaro e non tocca nient’altro, quindi invertire un bit dell’IV inverte lo stesso bit del testo in chiaro nella stessa posizione. Qui h (0x68) è diventato i (0x69), quindi il primo byte dell’IV si è spostato esattamente di 0x01.
L’impronta da tenere a mente: in CBC, primi 16 byte spazzatura e tutto il resto pulito significa che l’IV è sbagliato e la chiave è giusta. Tutti i blocchi spazzatura significa che è sbagliata la chiave. Quell’unica osservazione separa le due cause più comuni senza cambiare una riga di codice, e lo strumento di decifratura AES mostra i byte decodificati così puoi leggerlo direttamente.
L’eccezione non è scattata perché hello world è 11 byte, quindi un blocco solo, e il padding PKCS#7 vive nei byte dall’11 al 15. Il byte dell’IV che è cambiato era il byte 0, così la regione del padding è rimasta intatta e ha superato la validazione. Corrompi un byte dell’IV in posizione 11 o successiva e ottieni invece un errore di padding, che è un’altra strada per cui l’errore di padding ti mente.
Tre convenzioni di trasmissione
Non esiste uno standard su dove vada l’IV, solo tre abitudini che interoperano male.
Anteposto: iv || ciphertext è la convenzione più diffusa e il default nei nostri strumenti. I due lati devono essere d’accordo su quanto togliere: 16 byte per CBC e CTR, 12 per GCM. Il bug speculare è un produttore che antepone e un consumatore che non toglie niente. I primi 16 byte del “testo cifrato” sono allora l’IV, ogni blocco slitta e ottieni un errore di padding.
Un campo separato: {"iv": "...", "ciphertext": "..."} è più pulito in linea di principio e raddoppia i punti in cui una codifica può non coincidere, dato che ora anche l’IV ha la sua domanda base64-contro-hex.
Una costante fissa, di solito tutti zeri, scritta a mano nel codice perché a qualcuno serviva il determinismo. Interopera alla perfezione, ed è proprio questo a renderla pericolosa: in CBC un IV fisso rivela l’uguaglianza tra record, e in GCM riusare un nonce con la stessa chiave rivela lo XOR dei due testi in chiaro e può esporre la sottochiave GHASH che autentica il tag. La SP 800-38D è esplicita sull’unicità.
L’opzione per il testo cifrato nudo dello strumento, insieme a un override esplicito dell’IV, mette alla prova tutte e tre le convenzioni sugli stessi byte in un minuto.
L’IV di GCM è di 12 byte, non 16
I team che adottano GCM modificando un percorso CBC già esistente si portano dietro l’IV da 16 byte, e il risultato fallisce senza il minimo indizio.
La SP 800-38D standardizza un IV da 96 bit. Altre lunghezze sono permesse ma non sono semplicemente “un IV più lungo”: quando l’IV non è di 96 bit, GCM deriva il proprio blocco contatore iniziale passando l’IV attraverso GHASH invece di usarlo direttamente. Gli stessi 16 byte usati come nonce producono quindi un keystream e un tag completamente diversi da quelli che darebbero i primi 12, e ottieni un generico fallimento di autenticazione. Se il testo cifrato arriva da altrove e stai tirando a indovinare sul formato, conta all’indietro: il tag è costituito dagli ultimi 16 byte, il nonce quasi sempre dai primi 12.
5. Modalità non coincidente, compresa quella silenziosa
Cipher.getInstance("AES") è ECB
Java ti lascia nominare un cifrario senza nominare una modalità né uno schema di padding. Non rifiuta e non avverte. Con il provider SunJCE integrato nel JDK riempie i vuoti con ECB e PKCS5Padding.
Dimostrarlo richiede l’esperimento giusto: cifra 32 byte identici (due blocchi di A) con la chiave 0123456789abcdef, poi controlla se i due blocchi di testo cifrato coincidono. Su java 1.8.0_162:
getInstance("AES") ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
block1 = 3bfd04cc0d7ed55358e2cbe19de21383
block2 = 3bfd04cc0d7ed55358e2cbe19de21383 <- blocchi identici = l'impronta di ECB (la struttura del testo in chiaro trapela)
getInstance("AES/CBC/PKCS5Padding") i blocchi differiscono = il chaining è attivo
Identici byte per byte. Quella è la firma di ECB, la stessa proprietà per cui la famosa immagine cifrata del pinguino continua a sembrare un pinguino. L’esperimento funziona solo con blocchi di testo in chiaro identici: sedici byte A seguiti da sedici byte B producono due blocchi di testo cifrato diversi anche sotto ECB, e concluderesti a torto che il default fosse CBC.
Delimita bene il risultato: descrive il provider SunJCE integrato nel JDK sulla versione indicata sopra. La trasformazione di default è una decisione del provider, quindi un provider di terze parti come BouncyCastle può risolvere la stessa abbreviazione in modo diverso. La generalizzazione non è “Java significa ECB” ma “una stringa di trasformazione non qualificata significa quello che decide il tuo provider, ed è per questo che non se ne scrive mai una”.
La modalità sbagliata può non sollevare alcun errore
Lo scenario C ha decrittato testo cifrato CBC con AES/ECB e ha restituito il testo in chiaro corretto senza eccezioni. Sembra impossibile finché non scrivi i conti. La cifratura CBC del primo blocco è C1 = E(P1 XOR IV), e la decrittazione ECB di quel blocco è D(C1) = P1 XOR IV. Qui l’IV era di soli zeri, quindi P1 XOR 0 = P1 e il primo blocco si decritta alla perfezione. hello world è lungo un blocco, e “il primo blocco” era l’intero messaggio.
La regola generale conviene portarsela dietro: con un IV di zeri, ECB e CBC concordano sul primo blocco e discordano su tutti quelli successivi. Decritta un lungo messaggio CBC come ECB e ottieni sedici byte puliti seguiti da rumore, l’esatto inverso dell’impronta dell’IV sbagliato. Due forme opposte, due bug diversi, nessun messaggio di errore in nessuno dei due casi. Gli IV a zero scritti a mano nel codice sono comuni abbastanza da far incontrare questo scenario anche fuori dal laboratorio.
Che cosa ti dà una chiamata minima in ogni linguaggio
| Ecosistema | Chiamata minima | Modalità che ottieni davvero |
|---|---|---|
| Java (SunJCE) | Cipher.getInstance("AES") | ECB con PKCS5Padding, in silenzio |
Node crypto | createDecipheriv('aes-256-cbc', key, iv) | quello che dice la stringa dell’algoritmo; nessun default esiste |
| Web Crypto | crypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...) | indicata esplicitamente; ECB non è proprio implementata |
Python cryptography | Cipher(algorithms.AES(key), modes.CBC(iv)) | l’oggetto modalità è obbligatorio |
| PyCryptodome | AES.new(key, AES.MODE_ECB) | argomento obbligatorio, ma ECB è lì nell’autocompletamento |
Go crypto/aes | aes.NewCipher(key) restituisce un cipher.Block grezzo | chiamare Decrypt su quel blocco è ECB; avvolgilo in cipher.NewCBCDecrypter o cipher.NewGCM |
| CryptoJS | CryptoJS.AES.decrypt(ct, "passphrase") | CBC, PKCS#7, EVP_BytesToKey con MD5 (vedi sezione 7) |
Gli ecosistemi in cui la modalità sta in una stringa o in un oggetto non ti sorprendono mai. I due che offrono una chiamata “AES e basta”, Java e Go, sono quelli da cui arrivano le segnalazioni di ECB accidentale. Quando il sospetto cade qui, ripassa gli stessi byte cambiando una modalità alla volta: la forma dell’output dice quale modalità era davvero.
6. GCM: gli stessi byte, API diverse
Quasi tutti i fallimenti aes gcm auth tag tra linguaggi diversi non sono crittografici. I due lati hanno calcolato gli stessi 16 byte e non sono d’accordo su dove quei byte stiano.
La misurazione
Chiave = 32 byte 0123456789abcdef0123456789abcdef, IV = 12 byte a zero, testo in chiaro hello world, su node v25.8.2 e java 1.8.0_162:
Node ciphertext = a616cd6d7d2328379d41e5 (11 B) <- update+final
authTag = c87af9f8ad7148e873fa797292c0af3f (16 B) <- recuperato a parte con getAuthTag()
Java doFinal() = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f (27 B) <- testo cifrato e tag già concatenati
Node ciphertext || authTag è esattamente Java doFinal(), tutti e 27 i byte. Nessuna differenza di codifica, niente da negoziare: Node ti passa i due pezzi separati e Java te li passa incollati insieme. Nota anche che 11 byte di testo in chiaro hanno dato 11 byte di testo cifrato, perché GCM non aggiunge padding. Ecco perché un errore di padding non può mai arrivare da un vero percorso GCM.
Concatenato o separato, per runtime
| Runtime | API di cifratura | Dove finisce il tag |
|---|---|---|
Node crypto | update() + final(), poi getAuthTag() | separato |
Java (SunJCE, AES/GCM/NoPadding) | doFinal() | in coda |
Go cipher.AEAD | Seal() | in coda |
Python cryptography, AESGCM | encrypt() | in coda |
Python cryptography, Cipher + modes.GCM | finalize(), poi encryptor.tag | separato |
| Web Crypto | crypto.subtle.encrypt | in coda |
Se non sai in quale delle due forme sta il tuo blob, controlla la posizione del tag sui tuoi byte prima di sospettare della chiave. Node è l’anomalia tra le API di alto livello, e da Node verso qualunque altra cosa è la direzione di fallimento più segnalata. Per impacchettare l’output di Node destinato a un consumatore Java, Go, Python o browser:
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]); // ora coincide con doFinal()
Per spacchettare un blob concatenato destinato a Node:
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16)); // deve venire prima di final()
const pt = Buffer.concat([
decipher.update(packed.subarray(0, packed.length - 16)),
decipher.final(),
]);
L’ordine delle chiamate conta: chiama setAuthTag() dopo final() e Node solleva Unsupported state or unable to authenticate data anche quando ogni byte è corretto.
La lunghezza del tag è variabile, l’unità di misura no
GCM permette tag da 128, 120, 112, 104 o 96 bit, con 64 e 32 riservati alle applicazioni con risorse limitate (SP 800-38D, Appendice C). Quasi tutti usano 128, e il guaio sta in come ogni API lo chiede:
- Java:
new GCMParameterSpec(128, iv). Il primo argomento è in bit. - Web Crypto:
{ name: 'AES-GCM', iv, tagLength: 128 }. Anche qui bit, default 128. - Node:
createCipheriv(algo, key, iv, { authTagLength: 16 }). Qui sono byte.
new GCMParameterSpec(16, iv) è una riga Java dall’aria legittima che chiede un tag da 16 bit; alcuni JDK la rifiutano, e dove viene accettata hai barattato la tua garanzia di integrità con un lancio di moneta a uno su 65.536. Quando i due lati non sono d’accordo sulla lunghezza del tag, differiscono anche le lunghezze impacchettate, quindi chi riceve taglia nel punto sbagliato e ottiene un fallimento di autenticazione che non ha niente a che vedere con la chiave.
7. Hai una passphrase, non una chiave
Se uno dei due lati prende una stringa digitata da un essere umano, tra quella stringa e AES c’è una funzione di derivazione di chiave, e una KDF non coincidente è invisibile. Non dà mai errore. Restituisce 32 byte perfettamente buoni che sono però i 32 byte sbagliati, e il guasto riemerge uno strato più sotto come (questo lo conosci) un errore di padding.
PBKDF2 ha bisogno che quattro cose coincidano
- Il salt: nel formato OpenSSL
Salted__sono 8 byte dentro il testo cifrato; nel formato passphrase dei nostri strumenti è un prefisso da 16 byte; negli schemi fatti in casa è spesso una costante scritta a mano nel codice. - Le iterazioni:
openssl enc -pbkdf2usa come default 10.000. OWASP oggi raccomanda 600.000 per PBKDF2-HMAC-SHA256, che è quello che usa la nostra modalità passphrase. I framework scelgono numeri propri. - L’hash: SHA-1 contro SHA-256 contro SHA-512. Il codice più vecchio e alcuni SDK mobile usano ancora SHA-1 come default.
- La lunghezza dell’output: trentadue byte per AES-256, sedici per AES-128. Alcuni schemi derivano chiave e IV insieme da un’unica chiamata più lunga, cosa che non coincide mai con una semplice derivazione da 32 byte.
EVP_BytesToKey, e perché CryptoJS continua a non funzionare
cryptojs aes decrypt not working di solito è una discrepanza ben precisa. CryptoJS.AES.encrypt(text, "passphrase") non usa PBKDF2. Usa EVP_BytesToKey, la derivazione di OpenSSL precedente alla 1.1, con MD5 e una sola iterazione.
EVP_BytesToKey fa anche una cosa che PBKDF2 non fa: deriva sia la chiave sia l’IV dalla passphrase e dal salt in un solo passaggio. Per questo un file OpenSSL Salted__ non porta con sé un campo IV separato, ed ecco perché riprodurre l’output di CryptoJS con PBKDF2 più un IV casuale è sbagliato due volte.
Il formato si riconosce a colpo d’occhio: gli 8 byte ASCII Salted__ seguiti da un salt di 8 byte, codificati in base64, iniziano sempre con U2FsdGVkX1. Se il tuo testo cifrato comincia così è derivato da passphrase e devi sapere quale derivazione; lo strumento di decifratura AES rileva il prefisso e passa da una all’altra delle tre senza modifiche al codice.
Perché la stessa password dà chiavi diverse
Non esiste “la password AES”. Ogni libreria si è inventata il proprio percorso dalla stringa alla chiave:
| Produttore | Derivazione | Risultato per una sola passphrase |
|---|---|---|
CryptoJS AES.encrypt(text, pass) | EVP_BytesToKey, MD5, 1 iterazione | chiave A |
openssl enc 1.0.2 e precedenti | EVP_BytesToKey, MD5, 1 iterazione | chiave A |
openssl enc 1.1+ senza -pbkdf2 | EVP_BytesToKey, SHA-256, 1 iterazione | chiave B |
openssl enc -pbkdf2 | PBKDF2-HMAC-SHA256, 10.000 iterazioni | chiave C |
| La nostra modalità passphrase | PBKDF2-HMAC-SHA256, 600.000 iterazioni | chiave D |
| Java, Python, Go | nessun default; la derivazione la scrivi tu | quello che hai scritto |
Quattro chiavi da una sola password prima ancora che qualcuno abbia sbagliato qualcosa. Il passaggio dalla 1.0.2 alla 1.1 ha cambiato il digest di default da MD5 a SHA-256, ed è per questo che il testo cifrato prodotto da vecchi script ha smesso di decrittarsi con lo stesso comando su una macchina più recente. Se hai ereditato dei dati e nessuno si ricorda la toolchain, prova le derivazioni in quell’ordine. Sono tre tentativi in tutto, non una ricerca alla cieca.
8. Che cosa ha fatto il trasporto ai tuoi byte
Il testo cifrato è binario uniformemente casuale, il che lo rende ostile a qualsiasi cosa tratti i byte come testo. Una fetta grossa dei fallimenti AES non coinvolge mai il cifrario.
Varianti di base64 e padding mancante
Il base64 standard (RFC 4648 §4) usa + e /; la variante URL-safe (§5) usa - e _. Una stringa URL-safe passata a un decodificatore standard o solleva un’eccezione oppure, nei decodificatori indulgenti, scarta in silenzio i caratteri incriminati e restituisce byte in numero minore e disallineati. Java tiene infatti Base64.getUrlDecoder() e Base64.getDecoder() come oggetti separati. Alcuni codificatori inoltre eliminano il = finale, alcuni decodificatori lo pretendono, e i percorsi di codice vicini a JWT lo tolgono di default.
Prima di sospettare della chiave, decodifica il testo cifrato e confronta la sua lunghezza con la modalità:
- CBC ed ECB: un multiplo di 16 diverso da zero. Qualsiasi altra cosa è un troncamento o un problema di decodifica, non un problema di chiave.
- GCM: la lunghezza del testo cifrato è uguale a quella del testo in chiaro, più 16 per il tag, più 12 in testa se il nonce è anteposto.
- CTR: qualsiasi lunghezza, quindi questo controllo non ti dice niente.
Il decodificatore Base64 ti restituisce il conteggio dei byte appena incolli, e spesso è il controllo più veloce che puoi fare.
A capo, virgolette tipografiche e il giro attraverso UTF-8
openssl base64 manda a capo l’output a 64 colonne se non passi -A, e alcuni decodificatori saltano gli a capo incorporati mentre altri li rifiutano, quindi lo stesso file si decodifica su una macchina e fallisce su un’altra. Copiare passando per un client di chat o un editor di documenti trasforma le virgolette dritte in virgolette curve e i trattini in lineette, e in un terminale la differenza è quasi invisibile.
Quello irrecuperabile è il giro attraverso UTF-8. Se l’output AES grezzo viene tenuto anche solo una volta come stringa senza prima essere codificato (new String(cipherBytes) in Java, bytes.decode('utf-8', errors='replace') in Python, un TextDecoder da qualsiasi parte), ogni sequenza di byte che non è UTF-8 valido collassa in U+FFFD, e ricodificarla ti restituisce EF BF BD dove prima c’erano i tuoi dati. Dato che circa metà dei byte casuali non sono ASCII, la maggior parte del testo cifrato viene distrutta e nessuna chiave la recupera; la guida alla codifica UTF-8 e UTF-16 spiega perché la perdita è a senso unico. Il testo cifrato binario viaggia come base64, come hex o come binario, ma non deve mai viaggiare come stringa.
Colonne di database
L’archiviazione applica lo stesso danno in modo più silenzioso. Il testo cifrato scritto in un VARCHAR(255) che è più lungo di un blocco viene tagliato, e MySQL fuori dalla modalità strict lo fa senza errori. La coda è dove vivono il blocco di padding e il tag GCM, quindi una riga scritta “con successo” mesi fa adesso fallisce, e se il taglio è caduto su un confine di 16 byte nemmeno il controllo di lunghezza qui sopra se ne accorge. Il resto lo fa la conversione di charset: una colonna latin1 che riceve byte UTF-8 riscrive i tuoi dati mentre entrano.
Archivia il testo cifrato in VARBINARY, BLOB o bytea, oppure archivia base64 in una colonna di testo con spazio in abbondanza.
9. Un flusso a bisezione che lo trova in cinque minuti
Ogni sezione qui sopra restringe una variabile. Se le esegui in ordine contro un’implementazione di riferimento che controlli tu, la convergenza è rapida; gli strumenti nel browser funzionano bene come riferimento, perché puoi cambiare un’impostazione alla volta e vedere i byte, e perché girano in locale: la chiave e il testo cifrato che incolli restano nella pagina e non finiscono su nessun server.
- Passo 0: misura la forma. Decodifica il testo cifrato e annota il conteggio dei byte, i primi byte e se comincia con
U2FsdGVkX1. Confronta il conteggio con la sezione 8. Se non è un multiplo di 16 e credi di essere in CBC, fermati: è un bug di trasporto. - Passo 1: cifra un testo in chiaro noto. Nello strumento di crittografia AES, cifra una breve stringa nota con i parametri che credi siano quelli di produzione, poi confronta la forma dei due output invece dei loro valori: lunghezza totale, byte iniziali, presenza di un’intestazione di salt. Una discrepanza significa che la tua assunzione sul formato o sulla KDF è sbagliata, e non la sistemi armeggiando con la chiave.
- Passo 2: passa in rassegna le derivazioni. Per i dati derivati da passphrase, esegui PBKDF2 con il numero esatto di iterazioni, poi EVP-SHA256, poi EVP-MD5 nello strumento di decifratura AES. Esattamente una può essere quella giusta. Se non funziona nessuna, il bug sta sopra la KDF.
- Passo 3: togli ogni convenzione. Passa a una chiave grezza, attiva il testo cifrato nudo, fornisci l’IV in modo esplicito. Adesso stai dichiarando con esattezza quali byte sono chiave, IV e testo cifrato, senza che il codice deduca niente. Se qui si decritta ma nel tuo codice no, il tuo bug è di impacchettamento (un prefisso IV non rimosso, un tag nel posto sbagliato) e non crittografico.
- Passo 4: cambia modalità. Prova CBC, poi CTR, poi GCM sugli stessi byte. Se CTR restituisce testo leggibile dove CBC ha fallito, è una modalità non coincidente, punto.
- Passo 5: leggi la spazzatura. Primo blocco rovinato e il resto pulito significa l’IV. Primo blocco pulito e il resto rovinato significa che hai decrittato CBC come ECB con un IV di zeri. Tutto rovinato significa la chiave o la derivazione.
10. Domande frequenti
Perché il mio codice AES funziona in locale ma fallisce in produzione?
Tra locale e produzione l’ambiente ha cambiato qualcosa che non sta nel controllo di versione. I soliti sospetti, in ordine: la chiave è arrivata da una variabile d’ambiente o da un gestore di segreti con un a capo finale; il charset di default della piattaforma in Java è diverso tra portatile e container, quindi getBytes() ha prodotto byte diversi (sezione 3); OpenSSL in produzione è 1.1+ mentre i tuoi script locali puntavano alla 1.0.2, il che cambia il digest di EVP_BytesToKey da MD5 a SHA-256; oppure una colonna di database tronca il testo cifrato in un solo ambiente. Stampa per prima cosa la lunghezza della chiave e quella del testo cifrato in byte su entrambi i lati, perché di solito sono quei due numeri a chiudere la questione.
Ho cifrato in Node e non riesco a decrittare in Java. Da dove parto?
Parti dal tag GCM: da Node verso Java è la causa più comune e meno evidente. Node restituisce testo cifrato e tag separati; il doFinal() di Java se li aspetta concatenati come ciphertext || tag, e la sezione 6 mostra che per il resto i byte sono identici. Se invece sei in CBC, parti dalla convenzione sull’IV: Node lo ha anteposto, e il lato Java toglie 16 byte prima di decrittare? Al terzo posto c’è la chiave stessa, dove Buffer.from(k, 'hex') e k.getBytes(StandardCharsets.UTF_8) producono lunghezze diverse dalla stessa stringa.
Il PKCS5Padding di Java è la stessa cosa di PKCS#7?
Per AES, il PKCS5Padding di Java e PKCS#7 in pratica coincidono. PKCS#5 (RFC 8018) è definito solo per blocchi da 8 byte; PKCS#7 (RFC 5652) generalizza lo schema a dimensioni di blocco da 1 a 255 byte. Il PKCS5Padding di Java applicato a un cifrario a blocchi da 16 byte implementa il comportamento di PKCS#7, e il nome è un residuo storico, quindi questo non è mai il tuo bug. NoPadding sì: richiede un testo in chiaro già multiplo di 16, e in decrittazione ti restituisce il padding come se fosse dato, quindi vedi testo plausibile con byte finali del tipo \x05\x05\x05\x05\x05.
La mia chiave è di 32 caratteri ma AES dice che la lunghezza non è valida. Perché?
Un errore di lunghezza significa che la libreria ha ricevuto un conteggio di byte diverso da 16, 24 o 32. Con una stringa di 32 caratteri di solito si tratta di un a capo finale (33 byte), di un prefisso 0x che rende la stringa hex non valida, o di un carattere non ASCII che occupa due o tre byte in UTF-8. La variante più pericolosa è quella in cui non ottieni alcun errore: 32 caratteri hex si decodificano in 16 byte validi e 32 caratteri base64 in 24 byte validi, entrambe lunghezze AES legali. La libreria li accetta, usa la chiave sbagliata e ti consegna un fallimento di padding. Controlla il conteggio dei byte, non quello dei caratteri.
La decrittazione è “riuscita” ma l’output è spazzatura. Che cosa è andato storto?
La decrittazione è “riuscita” perché sei in una modalità che non verifica niente, quindi la spazzatura passa liscia. CTR ed ECB non sollevano mai eccezioni, e CBC ne solleva una solo quando lo schema dei byte finali non supera il controllo del padding, cosa che una chiave sbagliata riesce a superare poco meno dello 0.4% delle volte. Leggi la forma: primi 16 byte corrotti e il resto pulito significa l’IV; primi 16 puliti e il resto corrotto significa che hai decrittato testo cifrato CBC come ECB con un IV di zeri; corruzione uniforme significa la chiave o la derivazione. Testo leggibile con qualche byte strano in coda significa NoPadding su dati con padding. La soluzione a lungo termine è GCM, così che “riuscita” voglia dire qualcosa.
Posso ancora decrittare se ho perso l’IV?
Senza l’IV, in CBC puoi comunque decrittare tutto tranne i primi 16 byte. I blocchi dal secondo in poi si recuperano come D(C_i) XOR C_{i-1}, e ogni input di quel calcolo sta già nel testo cifrato, così solo il primo blocco ha bisogno dell’IV. Se sai anche come comincia il testo in chiaro, per esempio un blob JSON che parte con {"userId":, puoi recuperare l’IV direttamente come D(C1) XOR P1. In CTR l’IV innesca l’intero keystream, quindi perderlo significa perdere tutto. In GCM il nonce alimenta sia il contatore sia il tag, e non esiste recupero parziale.
Posso recuperare il testo in chiaro se il tag GCM è stato troncato o perso?
Con il tag GCM troncato o perso il testo in chiaro è matematicamente recuperabile, in pratica con fatica. GCM è costruita sopra una modalità CTR, quindi chiave e nonce da soli riproducono il keystream. Nessuna libreria mainstream lo farà al posto tuo: Java, Go, Python e Web Crypto si rifiutano tutte, per scelta progettuale, di rilasciare testo in chiaro senza un tag valido. Il rimedio è decrittare gli stessi byte come AES-CTR con il blocco contatore iniziale impostato al nonce di 12 byte seguito da 00000002, che è dove comincia il primo blocco di dati di GCM. Riprendi i dati e rinunci a ogni garanzia di integrità, perciò tratta il risultato come non attendibile. Se hai ancora tutti e 16 i byte del tag e l’autenticazione fallisce comunque, il tag non manca e il tuo bug è qualcos’altro in questa pagina. Portalo nello strumento di decifratura AES e riparti dal passo 0.