142 lines
5.4 KiB
TypeScript
142 lines
5.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<KeyManager> {
|
||
|
|
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<string>();
|
||
|
|
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();
|
||
|
|
});
|
||
|
|
});
|