/** * Regression tests for the AES-GCM nonce-reuse fix (G0). * * The codec used to derive its nonce from (fieldKey, table, pk) alone. That * is a pure function of row identity, so every re-seal of a mutable row — * `saveSession` runs on each ratchet step — reused (key, nonce) across * different plaintexts. AES-GCM forbids exactly that: it leaks the XOR of * the plaintexts and the GHASH subkey, which yields tag forgery under that * key. These tests fail if the derivation ever comes back. */ import { describe, test, expect } from 'bun:test'; import { KeyManager } from '../src/crypto/key-manager.js'; import { AEAD_NONCE_LEN, aeadSeal } from '../src/crypto/aead.js'; import { buildAad, deriveNonce } from '../src/crypto/kdf.js'; import { COL, TBL, openBytes, openString, sealBytes, sealString, } from '../src/crypto/row-codec.js'; const TEXT = new TextEncoder(); function km(): Promise { return KeyManager.open({ kind: 'injected', key: new Uint8Array(32).fill(0x42) }); } const nonceOf = (blob: Uint8Array) => blob.subarray(0, AEAD_NONCE_LEN); const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); describe('nonce uniqueness across re-saves', () => { test('two seals of the same row do not share a nonce', async () => { const k = await km(); // The real shape of the bug: same (table, column, pk), different // plaintext, as a session row is re-sealed on every ratchet step. const first = await sealString(k, TBL.sessions, COL.session, 'alice', '{"messageCount":1}'); const second = await sealString(k, TBL.sessions, COL.session, 'alice', '{"messageCount":2}'); expect(hex(nonceOf(first))).not.toBe(hex(nonceOf(second))); k.destroy(); }); test('a long run of re-saves produces all-distinct nonces', async () => { const k = await km(); const seen = new Set(); for (let i = 0; i < 200; i++) { const blob = await sealString(k, TBL.sessions, COL.session, 'alice', `state-${i}`); seen.add(hex(nonceOf(blob))); } expect(seen.size).toBe(200); k.destroy(); }); test('sealBytes is covered by the same rule', async () => { const k = await km(); const a = await sealBytes(k, TBL.config, COL.config, 'cfg', TEXT.encode('one')); const b = await sealBytes(k, TBL.config, COL.config, 'cfg', TEXT.encode('two')); expect(hex(nonceOf(a))).not.toBe(hex(nonceOf(b))); k.destroy(); }); test('the nonce is not the derived one', async () => { const k = await km(); const blob = await sealString(k, TBL.sessions, COL.session, 'alice', 'payload'); const derived = deriveNonce(k.fieldKey(TBL.sessions, COL.session), TBL.sessions, 'alice'); expect(hex(nonceOf(blob))).not.toBe(hex(derived)); k.destroy(); }); }); describe('backward compatibility with deterministically-sealed blobs', () => { // `aeadOpen` has always read the nonce from the blob prefix, so data // written before the fix opens unchanged and needs no migration. This // test reproduces an old blob by sealing with the deprecated derivation. test('a blob sealed with the old derived nonce still opens', async () => { const k = await km(); const key = k.fieldKey(TBL.sessions, COL.session); const legacy = await aeadSeal( key, deriveNonce(key, TBL.sessions, 'alice'), TEXT.encode('written before the fix'), buildAad(TBL.sessions, COL.session, 'alice'), ); const opened = await openString(k, TBL.sessions, COL.session, 'alice', legacy); expect(opened).toBe('written before the fix'); k.destroy(); }); test('openBytes reads an old blob too', async () => { const k = await km(); const key = k.fieldKey(TBL.config, COL.config); const payload = TEXT.encode('legacy bytes'); const legacy = await aeadSeal( key, deriveNonce(key, TBL.config, 'cfg'), payload, buildAad(TBL.config, COL.config, 'cfg'), ); expect(await openBytes(k, TBL.config, COL.config, 'cfg', legacy)).toEqual(payload); k.destroy(); }); test('new and old blobs are both readable under one key', async () => { const k = await km(); const key = k.fieldKey(TBL.sessions, COL.session); const legacy = await aeadSeal( key, deriveNonce(key, TBL.sessions, 'bob'), TEXT.encode('old'), buildAad(TBL.sessions, COL.session, 'bob'), ); const fresh = await sealString(k, TBL.sessions, COL.session, 'bob', 'new'); expect(await openString(k, TBL.sessions, COL.session, 'bob', legacy)).toBe('old'); expect(await openString(k, TBL.sessions, COL.session, 'bob', fresh)).toBe('new'); k.destroy(); }); }); describe('tamper detection survives the switch', () => { // The dropped `expectedNonce` check was the stated reason for the // deterministic nonce. It detected nothing the AEAD tag misses. test('a flipped nonce byte is still rejected', async () => { const k = await km(); const blob = await sealString(k, TBL.sessions, COL.session, 'alice', 'payload'); const tampered = new Uint8Array(blob); tampered[0]! ^= 0x01; await expect(openString(k, TBL.sessions, COL.session, 'alice', tampered)).rejects.toThrow(); k.destroy(); }); test('a blob moved to another row is still rejected (AAD binding)', async () => { const k = await km(); const blob = await sealString(k, TBL.sessions, COL.session, 'alice', 'alice-secret'); await expect(openString(k, TBL.sessions, COL.session, 'bob', blob)).rejects.toThrow(); k.destroy(); }); });