Skip to content
Terug naar blog
Beveiliging

AES-decryptie mislukt: sleutel, IV, modus en padding

AES-decryptie mislukt? Een verkeerde sleutel geeft een padding-fout, een verkeerde IV beschadigt alleen blok één. Debug het gratis met onze online tool.

16 min leestijd

AES-decryptie mislukt: sleutel, IV, modus en padding

Staat er in je logs dat AES-ontsleuteling is mislukt, dan beschrijft die melding waarschijnlijk het verkeerde probleem. Vier volstrekt losstaande bugs geven bijna identieke symptomen, en de meest voorkomende daarvan, een verkeerde sleutel, verschijnt als een padding-fout.

Dit is de volgorde die je de meeste tijd bespaart, uitgaande van een CBC-ontsleuteling die BadPaddingException gooit:

  1. De sleutelbytes verschillen tussen beide kanten. Met afstand de waarschijnlijkste oorzaak.
  2. De sleutelafleiding verschilt: dezelfde passphrase, een andere KDF of een ander aantal iteraties, dus andere sleutelbytes.
  3. De ciphertext is onderweg beschadigd: afgekapt, base64 verhaspeld, of heen en weer door een tekstcodering gehaald.
  4. De IV klopt niet. Dat komt voor, maar geeft geen padding-fout; het beschadigt zestien bytes en zwijgt verder.

Die volgorde volgt uit de manier waarop CBC werkt. CBC controleert de padding pas als laatste stap van het ontsleutelen, nadat de sleutel is toegepast en de keten is afgewikkeld. Padding werkt zo als een checksum over alles wat eraan voorafgaat, en die faalt luidruchtig ongeacht wat er stroomopwaarts stukging. Wil je meteen aan de slag, plak je ciphertext dan in de AES-ontsleuteltool en werk de bisectie uit sectie 9 af.

Alles hieronder is gemeten op java 1.8.0_162, node v25.8.2 en openssl 3.6.2. Standaardwaarden verschuiven tussen versies, dus beschouw de versienummers als onderdeel van het resultaat.

1. Begin bij wat je foutmelding uitsluit

Een AES-foutmelding zegt bijna niets over de oorzaak en veel over wat de oorzaak niet kan zijn. Gebruik hem om takken te schrappen, niet om er een te kiezen. Let trouwens op de woorden terwijl je zoekt: dezelfde handeling heet in het Nederlands ontsleutelen of decryptie, en het opvulschema heet net zo vaak opvulling als padding. De melding in je log komt er hoe dan ook in het Engels uit, dus zoek op die Engelse tekst en niet op de vertaling.

Wat je zietWat het uitsluitWat nog overeind staat
BadPaddingException, bad decrypt, wrong final block lengthGCM; een pure IV-fout; een decodeerfoutverkeerde sleutel, verkeerde KDF, afgekapte ciphertext, IV-bytes die als ciphertext zijn opgegeten, verkeerde modus, verkeerd paddingschema
GCM Authentication failed, Unsupported state or unable to authenticate datapadding; elke theorie met gedeeltelijke outputverkeerde sleutel, verkeerde nonce, losse of verkeerd geplaatste tag, verkeerde taglengte, niet-overeenkomende AAD
Geen exception, output is onzinelke authenticated modusECB, CTR, CBC die geluk had, verkeerde modus, verkeerde IV

BadPaddingException, bad decrypt, wrong final block length

Java, OpenSSL en .NET melden hier dezelfde gebeurtenis. De fout treedt op aan het eind van CBC- of ECB-ontsleuteling, wanneer het laatste plaintextblok niet eindigt op een geldig PKCS#7-patroon.

Wat je eraan hebt, zit in de ontkenning: dat je zover komt, betekent dat je base64 of hex is gedecodeerd en dat het aantal bytes een veelvoud van 16 was en niet nul. Het transport heeft je data dus niet vernield en je zit niet in GCM. wrong final block length is de uitzondering: daar was het aantal geen veelvoud van 16, wat naar afkapping wijst in plaats van naar de sleutel. Spring dan meteen door naar sectie 8.

GCM Authentication failed en verwanten

GCM vergelijkt de tag voordat er ook maar één byte plaintext naar buiten komt, zoals NIST SP 800-38D voorschrijft. Deze fout is daardoor eerlijker dan de padding-fout: iets in de tuple (sleutel, nonce, ciphertext, additional authenticated data, tag) komt niet overeen met wat de versleutelende kant gebruikte. Welk element dat is, kan GCM je niet vertellen, en dat zal ook nooit veranderen, want dat uitsplitsen valt bewust buiten wat het algoritme doet. Sectie 6 behandelt het element dat tussen talen het vaakst stukgaat: de plek waar de tag staat, terwijl de waarde ervan gewoon klopt.

Geen fout, maar de output is onzin

Dit is de gevaarlijke uitkomst, want een dashboard registreert hem als geslaagd. CTR gooit nooit een fout en ECB evenmin. CBC gooit er alleen een als het laatste bytepatroon door de padding-controle zakt, en met een verkeerde sleutel is die byte in feite willekeurig: ongeveer één poging op 256 landt op 0x01 en wordt goedgekeurd. Net geen 0,4% van de CBC-ontsleutelingen met een verkeerde sleutel “slaagt”. Maar onzin heeft een vorm, en die vorm noemt de bug bij naam. Sectie 4 en 5 beschrijven de twee vingerafdrukken die je daarvoor nodig hebt.

2. De meest misleidende fout in AES

Deze meting gooit de gebruikelijke debugvolgorde om. Sleutel 0123456789abcdef, IV volledig nul, AES/CBC/PKCS5Padding, plaintext hello world, op java 1.8.0_162 met de ingebouwde SunJCE-provider van de JDK:

ScenarioWijzigingGemeten resultaat
ASleutel 1 byte fout (laatste teken fX)gooit javax.crypto.BadPaddingException: Given final block not properly padded. De padding zelf was nooit misvormd; de melding is volslagen misleidend
BSleutel correct, IV 1 byte foutgeen exception, plaintext hello world kwam terug als iello world. Alleen de overeenkomstige byte van het eerste blok was beschadigd
CSleutel correct, de CBC-ciphertext ontsleuteld met AES/ECBslaagde stilletjes, geen exception. Een verkeerde modus hoeft niets op te werpen

Scenario A kost je hele middagen in de verkeerde richting. Scenario C zet foute data in productie.

Waarom een verkeerde sleutel een padding-fout oplevert

Er was niets mis met de padding. De versleutelende kant plakte vijf 0x05-bytes achter hello world om op zestien uit te komen, versleutelde dat blok, en het staat ongeschonden in je ciphertext.

De fout ontstaat op de terugweg. CBC-ontsleuteling draait het blokcijfer achterstevoren, past op elk resultaat een XOR met het vorige ciphertextblok toe, en leest pas daarna de staart van het laatste blok om te bepalen hoeveel bytes eraf moeten. Met de verkeerde sleutel produceert het cijfer zestien bytes ruis, en ruis eindigt vrijwel nooit op een geldig PKCS#7-patroon. De bibliotheek meldt wat ze zag, namelijk foute padding, en dat is waar én nutteloos.

Lees BadPaddingException als “de plaintext die ik heb gereconstrueerd eindigt niet zoals gepadde plaintext eindigt”. De waarschijnlijkste reden dat je reconstructie fout is, is de sleutel. Daarom belanden een zoekopdracht naar aes decrypt wrong key en een zoekopdracht naar een bad padding exception in dezelfde draadjes: die twee symptomen zijn één symptoom. Daar hangt ook een ontwerpregel aan vast: leg dat onderscheid nooit bloot aan een aanroeper, want “padding ongeldig” kunnen onderscheiden van “padding geldig, inhoud fout” is precies waar een aanval met een padding-oracle van leeft (Vaudenay, EUROCRYPT 2002).

Wat GCM anders doet

GCM keert de volgorde om: eerst de tag verifiëren, dan pas plaintext produceren. Er bestaat dus geen moment waarop er half-correcte bytes rondslingeren, en bij een GCM-fout hoef je je nooit af te vragen of de output echt is, want er is geen output. GCM kent ook helemaal geen padding (onderhuids is het een counter-modus), dus de ciphertext is even lang als de plaintext. Een padding-fout in een systeem waarvan je dacht dat het GCM was, bewijst dus dat het systeem geen GCM is, meestal een configuratie die is teruggevallen op CBC.

3. Gebruiken beide kanten dezelfde sleutelbytes?

AES ziet je sleutelstring nooit, alleen 16, 24 of 32 bytes. Twee systemen kunnen identiek sleutelmateriaal in een configuratiebestand hebben staan en het toch oneens zijn, want “identiek” is een eigenschap van de tekst en niet van de bytes.

De drie manieren waarop een sleutelstring bytes wordt

Geef de letterlijke string 0123456789abcdef aan drie verschillende bibliotheken:

as hex          -> 8 bytes    (invalid AES key length)
as base64       -> 12 bytes   (invalid AES key length)
as raw UTF-8    -> 16 bytes   (valid AES-128)

Zestien tekens, drie byte-aantallen. Dit geval is juist zo vervelend omdat het onder alle drie de lezingen geldig is: elk teken zit in zowel het hex- als het base64-alfabet, en zestien tekens is voor beide decoders een toegestane lengte, dus er gaat bij het inlezen niets mis.

De gids over JWT invalid signature bevat de volledige matrix van hoe elk ecosysteem een secret-string interpreteert; de korte versie voor AES is dat je opschrijft in welke codering je sleutelmateriaal staat en beide kanten expliciet laat decoderen. De HMAC-variant van dezelfde bug treft webhook-ontvangers; die staat in de gids over mislukte webhook signature-verificatie.

AES is streng: precies 16, 24 of 32 bytes

Hier wijkt AES af van de primitieve die de meeste ontwikkelaars als eerste tegenkomen. HMAC accepteert elke sleutellengte: RFC 2104 berekent een hash van alles wat langer is dan de blokgrootte en vult alles wat korter is aan met nulbytes, dus een HMAC-generator slikt een secret van 7 bytes of van 700 bytes zonder morren. AES heeft precies drie toegestane sleutellengtes en wijst al het andere af voordat er ook maar één blok is verwerkt.

Daar heb je wat aan, want een lengtefout is de enige AES-fout die haar eigen oorzaak noemt in plaats van zich achter padding te verstoppen. Onze tool verwoordt het als Key must be 16, 24, or 32 bytes (AES-128/192/256). De valkuilen die een verkeerde lengte opleveren:

  • Een newline aan het eind uit KEY=$(cat key.txt) of echo "$KEY". Gebruik printf en echo -n. Een spatie aan het eind, geplakt uit de UI van een secrets manager, doet hetzelfde.
  • Een 0x-prefix die uit een debugger is gekopieerd: vierendertig tekens die geen geldige hex meer zijn.
  • Niet-ASCII-tekens. contraseña is 10 tekens en 11 bytes in UTF-8, dus een passphrase van “32 tekens” met één letter met accent is 33 bytes.

SecretKeySpec en de standaardcharset van het platform

Java heeft hiervan een variant die zich pas na deployment laat zien. "my secret".getBytes() zonder argument gebruikt de standaardcharset van het platform, die vóór JDK 18 uit de property file.encoding kwam en daarmee uit het besturingssysteem en de locale van de machine. Een laptop op UTF-8 en een container op ANSI_X3.4-1968 leveren voor elk niet-ASCII-teken andere bytes op. JEP 400 maakte UTF-8 de standaard in JDK 18, dus alleen daar is het probleem weg.

// wrong: bytes depend on the machine
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");

// right: bytes depend on nothing
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");

Werkt je code lokaal, faalt hij op de server met een padding-fout en zit er iets buiten ASCII in de passphrase, controleer dit dan als eerste.

4. De IV: waar hij hoort en hoe een verkeerde eruitziet

Een aes iv mismatch is de fout die mensen als eerste vermoeden en als laatste vaststellen, want hij gedraagt zich anders dan de rest: stil, en beperkt tot één blok.

Een verkeerde IV beschadigt precies één blok

Kijk nog eens naar scenario B. Sleutel correct, IV één byte fout:

hello world   ->   iello world

Geen exception, één teken. Schrijf de CBC-stap voor het eerste blok uit en het is meteen duidelijk: P1 = D(C1) XOR IV. CBC XORt de IV rechtstreeks op het eerste plaintextblok en raakt verder niets aan, dus één bit omklappen in de IV klapt dezelfde bit op dezelfde positie in de plaintext om. Hier werd h (0x68) een i (0x69), dus de eerste byte van de IV verschoof met precies 0x01.

De vingerafdruk: in CBC betekent “eerste 16 bytes onzin en alles daarna schoon” dat de IV fout is en de sleutel goed. Is elk blok onzin, dan is de sleutel fout. Die ene waarneming scheidt de twee meest voorkomende oorzaken zonder dat je een regel code aanpast, en de AES-ontsleuteltool toont de gedecodeerde bytes, zodat je het direct kunt aflezen.

Waarom er niets werd gegooid: hello world is 11 bytes en past dus in één blok, en de PKCS#7-padding zit daarin in de bytes 11 tot en met 15. De IV-byte die veranderde was byte 0, dus het paddinggebied bleef ongemoeid en werd goedgekeurd. Beschadig een IV-byte op positie 11 of later en je krijgt wél een padding-fout, nóg een route waarlangs die melding tegen je liegt.

Drie manieren om de IV mee te sturen

Er is geen standaard voor waar de IV thuishoort, alleen drie gewoontes die slecht met elkaar samenwerken.

Ervoor geplakt, als iv || ciphertext, is de meest gebruikte afspraak en de standaard in onze tools. Beide kanten moeten het eens zijn over hoeveel er af moet: 16 bytes voor CBC en CTR, 12 voor GCM. De spiegelbeeldige bug is een verzender die de IV ervoor plakt en een ontvanger die dat niet weet. De eerste 16 bytes “ciphertext” zijn dan de IV, elk blok schuift op, en je krijgt een padding-fout.

In een apart veld: {"iv": "...", "ciphertext": "..."} is in principe netter, maar verdubbelt het aantal plekken waar een codering kan afwijken, want de IV heeft nu zijn eigen vraag base64-of-hex.

De derde is een vaste constante, meestal allemaal nullen, hardcoded omdat iemand determinisme nodig had. Deze werkt overal perfect samen, en juist dat maakt hem gevaarlijk: in CBC lekt een vaste IV welke records gelijk zijn, en in GCM onthult hergebruik van een nonce onder dezelfde sleutel de XOR van de twee plaintexts en kan het de GHASH-subsleutel blootleggen die de tag authenticeert. SP 800-38D is expliciet over uniciteit.

Met de schakelaar voor kale ciphertext in de tool plus een expliciete IV test je alle drie de afspraken in een minuut op dezelfde bytes.

De IV van GCM is 12 bytes, geen 16

Teams die GCM invoeren door een bestaand CBC-pad aan te passen, nemen de IV van 16 bytes gewoon mee, en het resultaat faalt zonder enige aanwijzing.

SP 800-38D standaardiseert een IV van 96 bits. Andere lengtes mogen, maar dat is niet simpelweg “een langere IV”: is de IV geen 96 bits, dan leidt GCM zijn eerste counterblok af door de IV door GHASH te halen in plaats van hem direct te gebruiken. Dezelfde 16 bytes als nonce leveren dus een compleet andere keystream en tag op dan de eerste 12 zouden doen, en je krijgt een generieke authenticatiefout. Komt de ciphertext ergens anders vandaan en gok je naar de indeling, tel dan van achteren: de tag is de laatste 16 bytes, de nonce vrijwel altijd de eerste 12.

5. Verkeerde modus, ook de stille variant

Cipher.getInstance("AES") is ECB

Java laat je een cijfer opgeven zonder een modus of een paddingschema te noemen, en accepteert dat zonder waarschuwing. Onder de ingebouwde SunJCE-provider van de JDK vult het de gaten met ECB en PKCS5Padding.

Het experiment daarvoor: versleutel 32 identieke bytes (twee blokken A) met sleutel 0123456789abcdef en kijk daarna of de twee ciphertextblokken gelijk zijn. Op java 1.8.0_162:

getInstance("AES")           ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
  block1 = 3bfd04cc0d7ed55358e2cbe19de21383
  block2 = 3bfd04cc0d7ed55358e2cbe19de21383   <- identical blocks = the ECB fingerprint (plaintext structure leaks)
getInstance("AES/CBC/PKCS5Padding")  blocks differ = chaining is active

Byte voor byte identiek. Dat is de handtekening van ECB, dezelfde eigenschap waardoor de beroemde versleutelde pinguïn er nog steeds als een pinguïn uitziet. Het experiment werkt alleen met identieke plaintextblokken: zestien A-bytes gevolgd door zestien B-bytes leveren ook onder ECB twee verschillende ciphertextblokken op, en dan zou je ten onrechte concluderen dat de standaard CBC was.

Baken het resultaat goed af: het beschrijft de ingebouwde SunJCE-provider van de JDK op de versie hierboven. Welke transformatie de standaard is, beslist de provider, dus een externe provider als BouncyCastle kan dezelfde afkorting anders oplossen. Java betekent dus niet automatisch ECB. Een transformatiestring zonder nadere aanduiding levert je uit aan je provider, en daarom schrijf je er nooit een.

De verkeerde modus hoeft geen fout op te leveren

In scenario C werd CBC-ciphertext met AES/ECB ontsleuteld en kwam de juiste plaintext terug, zonder exception. Dat lijkt onmogelijk tot je het rekenwerk uitschrijft. CBC-versleuteling van het eerste blok is C1 = E(P1 XOR IV), en ECB-ontsleuteling van dat blok is D(C1) = P1 XOR IV. De IV bestond hier volledig uit nullen, dus P1 XOR 0 = P1 en het eerste blok komt er perfect uit. hello world is één blok lang, dus “het eerste blok” was het hele bericht.

Die uitkomst is het onthouden waard: bij een IV van louter nullen zijn ECB en CBC het eens over het eerste blok en oneens over elk blok daarna. Ontsleutel een lang CBC-bericht als ECB en je krijgt zestien schone bytes gevolgd door ruis, precies het omgekeerde van de vingerafdruk van een verkeerde IV. Twee tegengestelde vormen, twee verschillende bugs, en in beide gevallen geen foutmelding. Hardcoded IV’s van nullen komen vaak genoeg voor om dit geen laboratoriumcuriositeit te maken.

Wat een minimale aanroep je per taal oplevert

EcosysteemMinimale aanroepModus die je echt krijgt
Java (SunJCE)Cipher.getInstance("AES")ECB met PKCS5Padding, zonder een woord te zeggen
Node cryptocreateDecipheriv('aes-256-cbc', key, iv)wat de algoritmestring zegt; er bestaat geen standaard
Web Cryptocrypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...)expliciet benoemd; ECB is er domweg niet
Python cryptographyCipher(algorithms.AES(key), modes.CBC(iv))het modusobject is verplicht
PyCryptodomeAES.new(key, AES.MODE_ECB)verplicht argument, maar ECB staat gewoon in de autocomplete
Go crypto/aesaes.NewCipher(key) geeft een kaal cipher.Block terugDecrypt aanroepen op dat blok is ECB; verpak het in cipher.NewCBCDecrypter of cipher.NewGCM
CryptoJSCryptoJS.AES.decrypt(ct, "passphrase")CBC, PKCS#7, EVP_BytesToKey met MD5 (zie sectie 7)

Ecosystemen waarin de modus in een string of een object zit, verrassen je nooit. De twee die een aanroep van “gewoon AES” aanbieden, Java en Go, zijn de bron van de meldingen over ECB bij ongeluk. Twijfel je over de modus, dan ontsleutel je dezelfde bytes eerst met elke modus apart voordat je je code aanpast.

6. GCM: dezelfde bytes, andere API’s

De meeste aes gcm auth tag-fouten tussen talen zijn niet cryptografisch. Beide kanten berekenden dezelfde 16 bytes en zijn het oneens over waar die bytes staan.

De meting

Sleutel = 32 bytes 0123456789abcdef0123456789abcdef, IV = 12 nulbytes, plaintext hello world, op node v25.8.2 en java 1.8.0_162:

Node   ciphertext = a616cd6d7d2328379d41e5                    (11 B)   <- update+final
       authTag    = c87af9f8ad7148e873fa797292c0af3f          (16 B)   <- fetched separately via getAuthTag()
Java   doFinal()  = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f   (27 B)   <- ciphertext and tag already concatenated

Node ciphertext || authTag is exact Java doFinal(), alle 27 bytes ervan. Er zit geen verschil in codering tussen die twee: Node geeft je de stukken los en Java geeft ze aan elkaar geplakt. En 11 bytes plaintext leverden 11 bytes ciphertext op, want GCM voegt geen padding toe. Daarom kan een padding-fout nooit uit een echt GCM-pad komen.

Aaneengeplakt of los, per runtime

RuntimeVersleutel-APIWaar de tag terechtkomt
Node cryptoupdate() + final(), daarna getAuthTag()los
Java (SunJCE, AES/GCM/NoPadding)doFinal()eraan geplakt
Go cipher.AEADSeal()eraan geplakt
Python cryptography, AESGCMencrypt()eraan geplakt
Python cryptography, Cipher + modes.GCMfinalize(), daarna encryptor.taglos
Web Cryptocrypto.subtle.encrypteraan geplakt

Node is de vreemde eend onder de high-level API’s, en daarom is Node-naar-wat-dan-ook de vaakst gemelde faalrichting. Zoek je eigen runtime op in die tabel en controleer daarna waar de tag in jouw bytes staat voordat je aan de sleutel gaat twijfelen. Zo verpak je Node-output voor een consument in Java, Go, Python of de 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()]);   // now matches doFinal()

En zo pak je een aaneengeplakte blob uit voor Node:

const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16));  // must come before final()
const pt = Buffer.concat([
  decipher.update(packed.subarray(0, packed.length - 16)),
  decipher.final(),
]);

De volgorde-eis is echt: roep je setAuthTag()final() aan, dan gooit Node Unsupported state or unable to authenticate data, ook als elke byte klopt.

De taglengte is variabel, de eenheid niet

GCM staat tags toe van 128, 120, 112, 104 of 96 bits, met 64 en 32 gereserveerd voor beperkte toepassingen (SP 800-38D, bijlage C). Bijna iedereen gebruikt 128, en het probleem zit in hoe elke API erom vraagt:

  • Java: new GCMParameterSpec(128, iv), waarbij het eerste argument in bits staat.
  • Web Crypto: { name: 'AES-GCM', iv, tagLength: 128 }, ook in bits, standaard 128.
  • Node: createCipheriv(algo, key, iv, { authTagLength: 16 }), en dat getal staat in bytes.

new GCMParameterSpec(16, iv) is een Java-regel die er legitiem uitziet maar om een tag van 16 bits vraagt; sommige JDK’s weigeren hem, en waar hij wel wordt geaccepteerd heb je je integriteitsgarantie ingeruild voor een muntworp met kans één op 65.536. Zijn beide kanten het oneens over de taglengte, dan verschillen ook de verpakte lengtes, dus de ontvanger knipt op de verkeerde grens en krijgt een authenticatiefout die niets met de sleutel te maken heeft.

7. Je hebt een passphrase, geen sleutel

Neemt een van beide kanten een door mensen getypte string aan, dan zit er tussen die string en AES een functie die de sleutel afleidt, en een verschil in KDF is onzichtbaar. Er komt nooit een fout uit: je krijgt 32 keurige bytes terug die toevallig de verkeerde 32 bytes zijn, en de mislukking komt een laag lager boven water als, alweer, een padding-fout.

Bij PBKDF2 moeten vier dingen kloppen

  • Salt. In het OpenSSL-formaat Salted__ zijn dat 8 bytes binnen de ciphertext; in het passphrase-formaat van onze tools is het een prefix van 16 bytes; in zelfgebouwde schema’s is het vaak een hardcoded constante.
  • Iteraties. openssl enc -pbkdf2 gebruikt standaard 10.000. OWASP beveelt 600.000 aan voor PBKDF2-HMAC-SHA256, en dat is wat onze passphrase-modus gebruikt. Frameworks kiezen hun eigen getallen.
  • Hash. SHA-1 tegenover SHA-256 tegenover SHA-512. Oudere code en sommige mobiele SDK’s kiezen nog steeds standaard SHA-1.
  • Uitvoerlengte. Tweeëndertig bytes voor AES-256, zestien voor AES-128. Sommige schema’s leiden sleutel en IV samen af uit één langere aanroep, en dat komt nooit overeen met een gewone afleiding van 32 bytes.

EVP_BytesToKey, en waarom CryptoJS het maar niet doet

cryptojs aes decrypt not working komt meestal neer op één specifiek verschil. CryptoJS.AES.encrypt(text, "passphrase") gebruikt geen PBKDF2, maar EVP_BytesToKey, de afleiding van OpenSSL van vóór 1.1, met MD5 en één enkele iteratie.

EVP_BytesToKey doet daarbij iets wat PBKDF2 niet doet: het leidt in één keer zowel de sleutel als de IV af uit de passphrase en de salt. Daarom bevat een OpenSSL-bestand met Salted__ geen apart IV-veld, en daarom is het dubbel fout om CryptoJS-output na te bouwen met PBKDF2 plus een willekeurige IV.

Het formaat herken je meteen: de 8 ASCII-bytes Salted__ gevolgd door een salt van 8 bytes beginnen, base64-geëncodeerd, altijd met U2FsdGVkX1. Begint je ciphertext zo, dan is hij van een passphrase afgeleid en moet je weten wélke afleiding; de AES-ontsleuteltool herkent de prefix en schakelt tussen de drie zonder dat je code aanpast.

Waarom hetzelfde wachtwoord verschillende sleutels oplevert

Zoiets als “het AES-wachtwoord” bestaat niet. Elke bibliotheek verzon zijn eigen route van string naar sleutel:

ProducentAfleidingResultaat voor één passphrase
CryptoJS AES.encrypt(text, pass)EVP_BytesToKey, MD5, 1 iteratiesleutel A
openssl enc 1.0.2 en ouderEVP_BytesToKey, MD5, 1 iteratiesleutel A
openssl enc 1.1+ zonder -pbkdf2EVP_BytesToKey, SHA-256, 1 iteratiesleutel B
openssl enc -pbkdf2PBKDF2-HMAC-SHA256, 10.000 iteratiessleutel C
Onze passphrase-modusPBKDF2-HMAC-SHA256, 600.000 iteratiessleutel D
Java, Python, Gohelemaal geen standaard; jij schrijft de afleidingwat je zelf hebt geschreven

Vier sleutels uit één wachtwoord, en dan heeft nog niemand een fout gemaakt. De sprong van 1.0.2 naar 1.1 veranderde de standaard-digest van MD5 in SHA-256, en daarom liet ciphertext uit oude scripts zich op een nieuwere machine niet meer ontsleutelen met hetzelfde commando. Heb je data geërfd en herinnert niemand zich de toolchain, probeer de afleidingen dan in die volgorde. Dat zijn drie opties om af te lopen.

8. Wat het transport met je bytes heeft gedaan

Ciphertext is uniform willekeurige binaire data, en daarmee maximaal vijandig tegenover alles wat bytes als tekst behandelt. Een groot deel van de AES-mislukkingen raakt het cijfer nooit.

Base64-varianten en ontbrekende padding

Standaard-base64 (RFC 4648 §4) gebruikt + en /; de URL-veilige variant (§5) gebruikt - en _. Geef je een URL-veilige string aan een standaarddecoder, dan gooit die een fout. Tolerante decoders laten de storende tekens stilletjes verdwijnen, en dan rollen er te weinig en verschoven bytes uit. Daarom levert Java Base64.getUrlDecoder() en Base64.getDecoder() als aparte objecten. Sommige encoders laten de afsluitende = weg, sommige decoders staan erop, en codepaden rond JWT strippen hem standaard.

Voordat je de sleutel verdenkt: decodeer de ciphertext en leg de lengte naast de modus:

  • CBC en ECB: een veelvoud van 16 dat niet nul is. Alles daarbuiten is afkapping of een decodeerprobleem, geen sleutelprobleem.
  • GCM: de lengte van de ciphertext is gelijk aan die van de plaintext, plus 16 voor de tag, plus 12 vooraan als de nonce ervoor is geplakt.
  • CTR: elke lengte, dus deze controle zegt je niets.

De Base64-decoder geeft je het aantal bytes met één keer plakken, vaak de snelste meting in het hele onderzoek.

Newlines, slimme aanhalingstekens en de rondgang door UTF-8

openssl base64 breekt de uitvoer af op 64 kolommen tenzij je -A meegeeft, en sommige decoders slaan ingebedde newlines over terwijl andere ze afkeuren, waardoor hetzelfde bestand op de ene machine decodeert en op de andere faalt. Kopiëren via een chatprogramma of een tekstverwerker maakt van rechte aanhalingstekens gekrulde en van koppeltekens en-streepjes, en dat verschil is in een terminal bijna onzichtbaar.

Onherstelbaar is de rondgang door UTF-8. Houdt je code kale AES-uitvoer ooit als string vast zonder die eerst te encoderen (new String(cipherBytes) in Java, bytes.decode('utf-8', errors='replace') in Python, ergens een TextDecoder), dan klapt elke bytereeks die geen geldige UTF-8 is samen tot U+FFFD, en encodeer je dat terug, dan staat er EF BF BD waar je data stond. Omdat ruwweg de helft van willekeurige bytes niet-ASCII is, sneuvelt het grootste deel van de ciphertext en haalt geen enkele sleutel het terug; de gids over UTF-8 en UTF-16 legt uit waarom dat verlies eenrichtingsverkeer is. Binaire ciphertext reist als base64 of hex, of als binaire data, maar nooit als string.

Databasekolommen

Opslag richt dezelfde schade stiller aan. Ciphertext die één blok te lang is voor een VARCHAR(255) wordt afgekapt, en MySQL doet dat buiten strict mode zonder foutmelding. Precies in de staart zitten het paddingblok en de GCM-tag, dus een rij die maanden geleden “met succes” is weggeschreven, faalt nu, en viel de knip op een grens van 16 bytes, dan pikt ook de lengtecontrole hierboven het niet op. De charsetconversie doet de rest: een latin1-kolom die UTF-8-bytes ontvangt, herschrijft je data onderweg naar binnen.

Sla ciphertext op in VARBINARY, BLOB of bytea, of sla base64 op in een tekstkolom met ruimte over.

9. Een bisectie-workflow die het in vijf minuten vindt

Elke sectie hierboven versmalt één variabele. Loop je ze op volgorde af tegen een referentie-implementatie die je zelf beheert, dan convergeer je snel, en de tools op deze site werken goed als die referentie: ze draaien volledig in je browser, dus je sleutel en ciphertext gaan nooit naar een server, en je kunt er één instelling tegelijk wijzigen en de bytes zien.

  1. Stap 0: meet de vorm. Decodeer de ciphertext en noteer het aantal bytes, de eerste paar bytes, en of hij met U2FsdGVkX1 begint. Leg het aantal naast sectie 8. Is het geen veelvoud van 16 terwijl je denkt in CBC te zitten, stop dan: dit is een transportbug.
  2. Stap 1: versleutel een bekende plaintext. Versleutel in de AES-versleuteltool een korte, bekende string met de parameters waarvan je denkt dat productie ze gebruikt, en vergelijk daarna de vorm van beide uitvoeren in plaats van de waarden: totale lengte, eerste bytes, aanwezigheid van een salt-header. Wijkt dat af, dan klopt je aanname over het formaat of de KDF niet, en dan lost geen enkel gepruts aan de sleutel het op.
  3. Stap 2: loop de afleidingen af. Draai voor data die van een passphrase is afgeleid eerst PBKDF2 met het exacte aantal iteraties, dan EVP-SHA256, dan EVP-MD5 in de AES-ontsleuteltool. Precies één kan goed zijn. Werkt geen van drieën, dan zit de bug boven de KDF.
  4. Stap 3: haal elke afspraak weg. Schakel over op een kale sleutel, zet kale ciphertext aan, geef de IV expliciet op. Je zegt nu precies welke bytes sleutel, IV en ciphertext zijn, zonder dat de tool nog iets afleidt. Ontsleutelt het hier wel en in je eigen code niet, dan is je bug een verpakkingsbug (een IV-prefix die niet is afgehaald, een tag op de verkeerde plek) en geen cryptografische.
  5. Stap 4: wissel van modus. Probeer CBC, dan CTR, dan GCM op dezelfde bytes. Levert CTR leesbare tekst op waar CBC faalde, dan heb je je antwoord: verkeerde modus.
  6. Stap 5: lees de onzin. Eerste blok fout en de rest schoon betekent de IV. Eerste blok schoon en de rest fout betekent dat je CBC als ECB hebt ontsleuteld met een IV van nullen. Alles fout betekent de sleutel of de afleiding.

10. Veelgestelde vragen

Waarom werkt mijn AES-code lokaal wel en in productie niet?

Lokaal werkt het en in productie niet omdat de omgeving iets heeft veranderd wat niet in versiebeheer staat. De gebruikelijke verdachten, op volgorde: de sleutel kwam uit een omgevingsvariabele of secrets manager met een newline aan het eind; de standaardcharset van Java verschilt tussen laptop en container, waardoor getBytes() andere bytes opleverde (sectie 3); in productie draait OpenSSL 1.1+ terwijl je lokale scripts op 1.0.2 mikten, wat de digest van EVP_BytesToKey van MD5 in SHA-256 verandert; of een databasekolom kapt de ciphertext in één omgeving af. Druk eerst aan beide kanten de sleutellengte en de ciphertextlengte in bytes af, want die twee getallen beslissen het meestal.

Ik heb in Node versleuteld en kan het in Java niet ontsleutelen. Waar begin ik?

Begin bij de GCM-tag: van Node naar Java is dat de meest voorkomende en minst voor de hand liggende oorzaak. Node geeft ciphertext en tag apart terug; doFinal() in Java verwacht ze aaneengeplakt als ciphertext || tag, en sectie 6 laat zien dat de bytes verder identiek zijn. Zit je in CBC, begin dan bij de afspraak over de IV: heeft Node hem ervoor geplakt, en haalt de Java-kant er 16 bytes af voor het ontsleutelen? Op de derde plaats komt de sleutel zelf, waar Buffer.from(k, 'hex') en k.getBytes(StandardCharsets.UTF_8) uit dezelfde string verschillende lengtes maken.

Is PKCS5Padding in Java hetzelfde als PKCS#7?

Voor AES komt PKCS5Padding op precies hetzelfde neer als PKCS#7. PKCS#5 (RFC 8018) is alleen gedefinieerd voor blokken van 8 bytes; PKCS#7 (RFC 5652) veralgemeent het schema naar blokgroottes van 1 tot 255 bytes. PKCS5Padding in Java, toegepast op een blokcijfer van 16 bytes, implementeert het gedrag van PKCS#7, en de naam is een historisch overblijfsel. Dit is dus nooit je bug. NoPadding wel: dat eist plaintext die al een veelvoud van 16 is, en bij het ontsleutelen geeft het de padding als data terug, dus je ziet plausibele tekst met bytes als \x05\x05\x05\x05\x05 aan het eind.

Mijn sleutel is 32 tekens lang, maar AES zegt dat de sleutellengte ongeldig is. Hoe kan dat?

Een lengtefout betekent dat de bibliotheek een aantal bytes kreeg dat niet 16, 24 of 32 is. Bij een string van 32 tekens is dat meestal een newline aan het eind (33 bytes), een 0x-prefix waardoor de string geen geldige hex meer is, of een niet-ASCII-teken dat twee of drie bytes in UTF-8 inneemt. De gevaarlijkere variant is dat je juist géén fout krijgt: 32 hex-tekens decoderen naar 16 geldige bytes en 32 base64-tekens naar 24 geldige bytes, allebei toegestane AES-lengtes. De bibliotheek accepteert ze, gebruikt de verkeerde sleutel, en geeft je in plaats daarvan een padding-fout. Controleer het aantal bytes, niet het aantal tekens.

Het ontsleutelen “slaagde”, maar de output is onzin. Wat ging er mis?

Het ontsleutelen “slaagde” en de output is onzin, dus je zit in een modus die niets verifieert. CTR en ECB gooien nooit een fout, en CBC alleen als het laatste bytepatroon door de padding-controle zakt, waar een verkeerde sleutel net geen 0,4% van de keren doorheen komt. Lees de vorm: eerste 16 bytes kapot en de rest schoon betekent de IV; eerste 16 schoon en de rest kapot betekent dat je CBC-ciphertext als ECB hebt ontsleuteld met een IV van nullen; overal kapot betekent de sleutel of de afleiding. Leesbare tekst met een paar rare bytes aan het eind betekent NoPadding op gepadde data. De structurele oplossing is GCM, zodat “geslaagd” ook iets betekent.

Kan ik nog ontsleutelen als ik de IV kwijt ben?

Zonder de IV ontsleutel je in CBC nog steeds alles behalve de eerste 16 bytes. Blok 2 en verder haal je terug met D(C_i) XOR C_{i-1}, en elke invoer daarvoor zit al in de ciphertext, dus alleen het eerste blok heeft de IV nodig. Weet je bovendien hoe de plaintext begint, bijvoorbeeld een JSON-blob die met {"userId": opent, dan haal je de IV zelfs helemaal terug als D(C1) XOR P1. In CTR zaait de IV de hele keystream, dus kwijt is kwijt. In GCM voedt de nonce zowel de teller als de tag, dus daar is geen gedeeltelijk herstel mogelijk.

Kan ik de plaintext terugkrijgen als de GCM-tag is afgekapt of weggevallen?

Met een afgekapte of weggevallen GCM-tag kan het wiskundig wel, praktisch met moeite. GCM is onderhuids CTR-modus, dus de sleutel en de nonce alleen al reproduceren de keystream. Geen enkele gangbare bibliotheek doet het voor je: Java, Go, Python en Web Crypto weigeren allemaal plaintext vrij te geven zonder geldige tag, en dat is opzet. De omweg is dezelfde bytes als AES-CTR ontsleutelen met het eerste counterblok gezet op de nonce van 12 bytes gevolgd door 00000002, want daar begint het eerste datablok van GCM. Je krijgt de data terug en levert elke integriteitsgarantie in, dus behandel het resultaat als onbetrouwbaar. Heb je alle 16 tagbytes nog en mislukt de authenticatie tóch, dan ontbreekt de tag niet en zit je bug ergens anders op deze pagina. Neem hem mee naar de AES-ontsleuteltool en begin bij stap 0.

Tags: aes encryption debugging cryptography interoperability

Gerelateerde artikelen

Alle artikelen bekijken