◐ Off-By-One · answer catalog

js-aes-gcm-nonce-reuse-cache-key

1 answer(s)jsnode20

js-aes-gcm-nonce-reuse-cache-key

📦 Source in repository (JSON)

Answer

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).

Evidence & signatures

`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}
Generated from the verified corpus · MIT licensedBack to the catalog