crypto-aesgcm-timestamp-nonce-reuse
The bug: the service built a 12-byte GCM nonce from zero-padded Unix timestamp (8B) + per-process counter (4B). Because GCM's keystream is a function of (key, nonce), any reuse — two messages in the same second, or counter reset on restart — makes the keystream repeat. An attacker with two ciphertexts C1, C2 can compute P1 ⊕ P2 = C1 ⊕ C2, and forge valid tags under the reused nonce.
The fix (~/secure-encrypt.js): one nonce per encryption, sampled fresh from crypto.randomBytes(12), never from wall-clock time or resettable counters. The nonce is prepended to the ciphertext so decryption can recover it:
const crypto = require('crypto');
const NONCE_LEN = 12, KEY_LEN = 32, TAG_LEN = 16;
function encrypt(plaintext, key) {
if (!Buffer.isBuffer(plaintext)) plaintext = Buffer.from(plaintext);
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN)
throw new TypeError(`key must be a ${KEY_LEN}-byte Buffer`);
// FIX: fresh 96-bit nonce from the OS CSPRNG per message.
// No timestamps, no counters — nothing that can reset or repeat.
const nonce = crypto.randomBytes(NONCE_LEN);
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]); // nonce||ct||tag
}
function decrypt(payload, key) {
if (payload.length < NONCE_LEN + TAG_LEN) throw new Error('payload too short');
const nonce = payload.subarray(0, NONCE_LEN);
const tag = payload.subarray(payload.length - TAG_LEN);
const ct = payload.subarray(NONCE_LEN, payload.length - TAG_LEN);
const d = crypto.createDecipheriv('aes-256-gcm', key, nonce);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]); // throws on tamper
}
Key points:
- crypto.randomBytes is backed by the OS CSPRNG (getrandom/RtlGenRandom), seeded independently per process — restart-resistance is inherent.
- 12 bytes = 96 bits is the recommended GCM nonce size; at ~2⁶⁴ messages the collision probability stays negligible.
- The nonce must be stored/transmitted with the ciphertext (prepended here) — it is not secret, only unique.
- decrypt throws on any authentication failure (tampered ciphertext, wrong key, bit-flipped tag), so forgery is rejected by GCM's 128-bit tag.
Test suite at `~/test.js` — run with `node test.js`: ``` PASS round-trip empty message PASS round-trip 1-byte message PASS round-trip 1 MiB message PASS nonce length is 12 bytes PASS nonce is 96-bit random, not time-derived PASS no nonce reuse across 10k rapid encryptions PASS old scheme: same-second nonce reuse (XOR leak) is real PASS fixed scheme: same-second encryptions use distinct nonces PASS tampered ciphertext rejected (auth tag) PASS tampered tag rejected PASS wrong key rejected PASS short key rejected at encrypt PASS truncated payload rejected 13 passed, 0 failed ``` Verification highlights and edge cases: - **Attack reproduced on old scheme, then killed:** I kept the old implementation in `vulnerable-encrypt.js` and forced the counter back to 0 before each call (same second / after restart). The old code produced identical nonces and `C1 ⊕ C2 === P1 ⊕ P2` — the plaintext XOR leak is real. The fixed scheme produces distinct nonces for back-to-back encryptions, so the XOR reveals nothing. - **Same-second / high-volume reuse:** 10,000 rapid encryptions in the test suite, plus a separate 500,000-encryption stress run — all 500,000 nonces unique (Set size 500000). - **Round-trips:** empty buffer, 1-byte buffer, 1 MiB blob all decrypt intact. - **Integrity:** flipping one ciphertext bit or one tag bit, using the wrong key, and truncating the payload all throw (GCM auth failure) — no silent decryption, no forgery path. - **Validation:** non-32-byte keys rejected at encrypt time; too-short payloads rejected at decrypt time. - **Nonce shape check:** the first 8 bytes are never the zero-padded epoch-seconds pattern (high 4 bytes are non-zero, low 4 bytes don't equal the current seconds), confirming it's not time-derived.
{"model": "deepseek-v4-flash", "problem_class": "crypto-aesgcm-timestamp-nonce-reuse", "result": "passed", "tests": 13}The bug: the service built a 12-byte GCM nonce from zero-padded Unix timestamp (8B) + per-process counter (4B). Because GCM's keystream is a function of (key, nonce), any reuse — two messages in the same second, or counter reset on restart — makes the keystream repeat. An attacker with two ciphertexts C1, C2 can compute P1 ⊕ P2 = C1 ⊕ C2, and forge valid tags under the reused nonce.
The fix (~/secure-encrypt.js): one nonce per encryption, sampled fresh from crypto.randomBytes(12), never from wall-clock time or resettable counters. The nonce is prepended to the ciphertext so decryption can recover it:
const crypto = require('crypto');
const NONCE_LEN = 12, KEY_LEN = 32, TAG_LEN = 16;
function encrypt(plaintext, key) {
if (!Buffer.isBuffer(plaintext)) plaintext = Buffer.from(plaintext);
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN)
throw new TypeError(`key must be a ${KEY_LEN}-byte Buffer`);
// FIX: fresh 96-bit nonce from the OS CSPRNG per message.
// No timestamps, no counters — nothing that can reset or repeat.
const nonce = crypto.randomBytes(NONCE_LEN);
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]); // nonce||ct||tag
}
function decrypt(payload, key) {
if (payload.length < NONCE_LEN + TAG_LEN) throw new Error('payload too short');
const nonce = payload.subarray(0, NONCE_LEN);
const tag = payload.subarray(payload.length - TAG_LEN);
const ct = payload.subarray(NONCE_LEN, payload.length - TAG_LEN);
const d = crypto.createDecipheriv('aes-256-gcm', key, nonce);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]); // throws on tamper
}
Key points:
- crypto.randomBytes is backed by the OS CSPRNG (getrandom/RtlGenRandom), seeded independently per process — restart-resistance is inherent.
- 12 bytes = 96 bits is the recommended GCM nonce size; at ~2⁶⁴ messages the collision probability stays negligible.
- The nonce must be stored/transmitted with the ciphertext (prepended here) — it is not secret, only unique.
- decrypt throws on any authentication failure (tampered ciphertext, wrong key, bit-flipped tag), so forgery is rejected by GCM's 128-bit tag.
Test suite at `~/test.js` — run with `node test.js`: ``` PASS round-trip empty message PASS round-trip 1-byte message PASS round-trip 1 MiB message PASS nonce length is 12 bytes PASS nonce is 96-bit random, not time-derived PASS no nonce reuse across 10k rapid encryptions PASS old scheme: same-second nonce reuse (XOR leak) is real PASS fixed scheme: same-second encryptions use distinct nonces PASS tampered ciphertext rejected (auth tag) PASS tampered tag rejected PASS wrong key rejected PASS short key rejected at encrypt PASS truncated payload rejected 13 passed, 0 failed ``` Verification highlights and edge cases: - **Attack reproduced on old scheme, then killed:** I kept the old implementation in `vulnerable-encrypt.js` and forced the counter back to 0 before each call (same second / after restart). The old code produced identical nonces and `C1 ⊕ C2 === P1 ⊕ P2` — the plaintext XOR leak is real. The fixed scheme produces distinct nonces for back-to-back encryptions, so the XOR reveals nothing. - **Same-second / high-volume reuse:** 10,000 rapid encryptions in the test suite, plus a separate 500,000-encryption stress run — all 500,000 nonces unique (Set size 500000). - **Round-trips:** empty buffer, 1-byte buffer, 1 MiB blob all decrypt intact. - **Integrity:** flipping one ciphertext bit or one tag bit, using the wrong key, and truncating the payload all throw (GCM auth failure) — no silent decryption, no forgery path. - **Validation:** non-32-byte keys rejected at encrypt time; too-short payloads rejected at decrypt time. - **Nonce shape check:** the first 8 bytes are never the zero-padded epoch-seconds pattern (high 4 bytes are non-zero, low 4 bytes don't equal the current seconds), confirming it's not time-derived.
{"model": "deepseek-v4-flash", "problem_class": "crypto-aesgcm-timestamp-nonce-reuse", "result": "passed", "tests": 13}