js-aes-gcm-nonce-reuse-cache-key
Root cause. service.vulnerable.js built the 12-byte GCM nonce from a counter cached in a Map keyed by sha256(plaintext). The cached counter was read, written into the last 4 bytes of the nonce, and re-stored without incrementing. So encrypting the same payload twice resurrected the identical nonce under the same 256-bit key → identical CTR keystream → ciphertext₁ ⊕ ciphertext₂ = plaintext₁ ⊕ plaintext₂, and GCM's authenticity is void (verified below).
Fix (service.js): drop the plaintext-derived cache entirely and draw a fresh 12-byte nonce from the OS CSPRNG on every call — nothing keyed on the plaintext can resurrect an old nonce.
'use strict';
const crypto = require('node:crypto');
const ALGORITHM = 'aes-256-gcm';
const NONCE_LENGTH = 12; // 96-bit nonce (NIST-recommended for GCM)
const KEY = crypto.randomBytes(32); // production: KMS / secret store
function encrypt(plaintext) {
// Fresh, cryptographically random nonce — unique per encryption call.
const nonce = crypto.randomBytes(NONCE_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, nonce);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return {
nonce: nonce.toString('base64'),
ciphertext: ciphertext.toString('base64'),
tag: tag.toString('base64'),
};
}
function decrypt(payload) {
const nonce = Buffer.from(payload.nonce, 'base64');
const ciphertext = Buffer.from(payload.ciphertext, 'base64');
const tag = Buffer.from(payload.tag, 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
module.exports = { encrypt, decrypt, KEY, NONCE_LENGTH };
Collision risk is negligible: with random 96-bit nonces the birthday bound is ~2⁴⁸ encryptions per key (rotate the key well before that; standard GCM bounds). test.js adds the required regression test — identical plaintext encrypted twice must yield distinct nonces — plus 9 edge-case tests. Run with npm test (node --test).
`node --test`: **10/10 pass, 0 fail** (`# tests 10, # pass 10`). Files: `service.js` (fixed), `service.vulnerable.js` (buggy baseline, kept for demonstration), `test.js`, `package.json`. **Required test** — identical plaintext twice → distinct nonces (and distinct ciphertexts): ``` VULNERABLE same plaintext twice : nonce1=AAAAAAAAAAAAAAAA nonce2=AAAAAAAAAAAAAAAA (equal: true) FIXED same plaintext twice : nonce1=adBd7mWYwgyKfIKW nonce2=bu8Lel4IlvGLDVmS (equal: false) ``` **XOR-leak demonstration** — the vulnerable version leaks `p₁⊕p₂`; the fixed version does not: ``` VULNERABLE c1^c2 === p1^p2 : true -> plaintext XOR leaked FIXED c1^c2 === p1^p2 : false -> no keystream reuse ``` **Edge cases covered:** - Nonce is exactly 12 bytes (96 bits). - 1000 encryptions of the *same* plaintext → 1000 unique nonces. - 200 concurrent `encrypt()` calls (`Promise.all`) → 200 unique nonces (no shared mutable state). - Empty payload and 1-byte payloads → fresh nonces each time; round-trip decrypt works. - Round-trip decrypt restores the original plaintext. - Bit-flipped ciphertext fails GCM auth tag verification (`throws`). - Distinct plaintexts: `c₁⊕c₂ ≠ p₁⊕p₂` (no keystream reuse across different plaintexts). - Sanity guards: the vulnerable baseline still reproduces nonce reuse and the XOR leak (so the regression test is meaningful, not vacuously passing).
{"model": "deepseek-v4-flash", "problem_class": "js-aes-gcm-nonce-reuse-cache-key", "result": "passed", "tests": 10}