diff --git a/packages/shade-storage-encrypted/src/crypto.ts b/packages/shade-storage-encrypted/src/crypto.ts index 4d6cf03..6c17c00 100644 --- a/packages/shade-storage-encrypted/src/crypto.ts +++ b/packages/shade-storage-encrypted/src/crypto.ts @@ -34,12 +34,16 @@ export { deriveBlobSlotId, deriveBlobKey, deriveBlobSigningSeed, + deriveVaultId, + deriveVaultContentKey, + deriveVaultSigningSeed, } from './crypto/kdf.js'; export { AEAD_NONCE_LEN, AEAD_TAG_LEN, aeadSeal, aeadOpen, + randomNonce, } from './crypto/aead.js'; export { COL, diff --git a/packages/shade-storage-encrypted/src/crypto/aead.ts b/packages/shade-storage-encrypted/src/crypto/aead.ts index 0a7ad48..aad7999 100644 --- a/packages/shade-storage-encrypted/src/crypto/aead.ts +++ b/packages/shade-storage-encrypted/src/crypto/aead.ts @@ -33,9 +33,32 @@ async function importKey(key: Uint8Array, usages: WebCryptoKeyUsage[]): Promise< } /** - * Encrypt a plaintext blob with the given key and a deterministic nonce. - * Returns `nonce || ct||tag` as a single Uint8Array suitable for direct - * BLOB storage. + * A fresh random 12-byte nonce. + * + * AES-GCM tolerates key reuse but not (key, nonce) reuse: two ciphertexts + * sealed under the same pair leak the XOR of their plaintexts and, worse, + * the GHASH authentication subkey — the "forbidden attack", which yields + * tag forgery for that key. Mutable rows are re-sealed on every change + * (`saveSession` runs on each ratchet step), so a nonce derived from row + * identity alone repeats by construction. + * + * Random beats a counter here because the codec is shared across SQLite + * and Postgres and has no durable per-row write counter to lean on. At + * 96 bits, collision probability stays negligible far past any realistic + * number of re-saves. + */ +export function randomNonce(): Uint8Array { + return globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LEN)); +} + +/** + * Encrypt a plaintext blob with the given key and nonce. Returns + * `nonce || ct||tag` as a single Uint8Array suitable for direct BLOB + * storage. + * + * Callers must pass a nonce that is fresh for this key — see + * {@link randomNonce}. Never derive one deterministically from row + * identity for a row that can be written more than once. */ export async function aeadSeal( key: Uint8Array, @@ -58,9 +81,19 @@ export async function aeadSeal( } /** - * Decrypt a `nonce || ct||tag` blob. The expected nonce is verified against - * the prefix to detect tampering before we even reach the AEAD; if the - * caller passes a `expectedNonce`, mismatch throws before SubtleCrypto runs. + * Decrypt a `nonce || ct||tag` blob. + * + * The nonce always comes from the blob prefix, which is what makes the + * move to random nonces backward compatible: a blob sealed under the old + * deterministic scheme opens unchanged, because its nonce was already + * stored the same way. + * + * @param expectedNonce **Deprecated.** Only meaningful when the nonce is + * a pure function of row identity, which is exactly the pattern that made + * (key, nonce) repeat. It adds no tamper detection that the AEAD tag and + * the (table, column, pk) AAD do not already provide — a flipped bit in + * the nonce fails the tag. Retained so existing callers keep compiling; + * pass nothing. */ export async function aeadOpen( key: Uint8Array, diff --git a/packages/shade-storage-encrypted/src/crypto/kdf.ts b/packages/shade-storage-encrypted/src/crypto/kdf.ts index 14c3ede..3ef4e84 100644 --- a/packages/shade-storage-encrypted/src/crypto/kdf.ts +++ b/packages/shade-storage-encrypted/src/crypto/kdf.ts @@ -106,12 +106,20 @@ export function deriveFieldKey(storageKey: Uint8Array, table: string, column: st } /** - * Derive a deterministic 12-byte AEAD nonce from a row key (typically the - * field key) plus (table, pk) binding. With per-field keys, deterministic - * nonces are safe because each (key, plaintext) pair appears at most once - * — re-saving the same row reuses the (nonce, key) pair only because the - * plaintext also changes (chain ratchet, prekey state, etc.). The AAD - * also binds (table, column, pk) so swapping is rejected on decrypt. + * Derive a deterministic 12-byte AEAD nonce from a row key plus (table, pk). + * + * @deprecated Unsafe for anything that can be written twice, which is every + * mutable row. The original rationale held that re-saving a row was fine + * "because the plaintext also changes" — but that is precisely the case + * AES-GCM forbids: reusing (key, nonce) across *different* plaintexts leaks + * their XOR and the GHASH subkey (the forbidden attack), enabling tag + * forgery. `saveSession` re-seals on every ratchet step, so the pair + * repeated by construction. Use {@link randomNonce} from `./aead.js`. + * + * Kept exported (it is part of the published surface via `index.ts` and + * `crypto.ts`) so consumers keep compiling. No caller inside this package + * uses it any more. Reading old data needs no migration: `aeadOpen` has + * always taken the nonce from the blob prefix. */ export function deriveNonce(rowKey: Uint8Array, table: string, pk: string): Uint8Array { const out = hkdfDerive(rowKey, `shade-row-nonce-v1:${table}:${pk}`, 12); @@ -161,3 +169,42 @@ export function deriveBlobKey(masterKey: Uint8Array, app: string): Uint8Array { export function deriveBlobSigningSeed(masterKey: Uint8Array, app: string): Uint8Array { return hkdfDerive(masterKey, `shade-blob-sig-v1:${app}`, 32); } + +// ─── V4.13 — vault (server-side encrypted file store) ────────── +// +// The blob primitive above holds ONE small blob per slot. A vault holds +// a whole collection — a project workspace, a mailbox, a drive folder — +// as content-addressed objects plus an append-only log of manifests. +// +// Its own HKDF branch rather than a reuse of `shade-blob-*-v1`, for two +// reasons. A vault key is used on far more ciphertext than a profile +// blob ever is, and the two have different blast radii: whoever holds +// the vault key can read every file, while the profile key exposes only +// the host list. Separate labels mean compromising one cannot be turned +// into the other, even though both hang off the same master. +// +// vaultId = HKDF(masterKey, info=`shade-vault-id-v1:${app}`) +// contentKey = HKDF(masterKey, info=`shade-vault-content-v1:${app}`) +// sigSeed = HKDF(masterKey, info=`shade-vault-sig-v1:${app}`) + +/** 32-byte vault identifier. Opaque to the relay, like a slotId. */ +export function deriveVaultId(masterKey: Uint8Array, app: string): Uint8Array { + return hkdfDerive(masterKey, `shade-vault-id-v1:${app}`, 32); +} + +/** + * AEAD key for the vault's objects and manifests. + * + * One key for the whole collection, not one per file: per-file keys would + * have to be stored somewhere, and that somewhere would be a manifest + * encrypted under a collection key anyway. Object AAD binds the content + * hash, so a ciphertext cannot be moved to another object's name. + */ +export function deriveVaultContentKey(masterKey: Uint8Array, app: string): Uint8Array { + return hkdfDerive(masterKey, `shade-vault-content-v1:${app}`, 32); +} + +/** 32-byte Ed25519 signing seed; TOFU-pinned by the relay on first write. */ +export function deriveVaultSigningSeed(masterKey: Uint8Array, app: string): Uint8Array { + return hkdfDerive(masterKey, `shade-vault-sig-v1:${app}`, 32); +} diff --git a/packages/shade-storage-encrypted/src/crypto/row-codec.ts b/packages/shade-storage-encrypted/src/crypto/row-codec.ts index 1979a0d..c31039f 100644 --- a/packages/shade-storage-encrypted/src/crypto/row-codec.ts +++ b/packages/shade-storage-encrypted/src/crypto/row-codec.ts @@ -22,8 +22,8 @@ import { serializeSignedPreKey, deserializeSignedPreKey, toBase64, fromBase64, } from '@shade/core'; -import { aeadOpen, aeadSeal } from './aead.js'; -import { buildAad, deriveNonce } from './kdf.js'; +import { aeadOpen, aeadSeal, randomNonce } from './aead.js'; +import { buildAad } from './kdf.js'; import type { KeyManager } from './key-manager.js'; const TEXT_ENCODER = new TextEncoder(); @@ -64,9 +64,8 @@ export async function sealString( plaintext: string, ): Promise { const key = km.fieldKey(table, column); - const nonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); - return aeadSeal(key, nonce, TEXT_ENCODER.encode(plaintext), aad); + return aeadSeal(key, randomNonce(), TEXT_ENCODER.encode(plaintext), aad); } /** Decrypt a blob into a string, reconstructing AAD from (table, column, pk). */ @@ -78,9 +77,8 @@ export async function openString( blob: Uint8Array, ): Promise { const key = km.fieldKey(table, column); - const expectedNonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); - const pt = await aeadOpen(key, blob, aad, expectedNonce); + const pt = await aeadOpen(key, blob, aad); return TEXT_DECODER.decode(pt); } @@ -93,9 +91,8 @@ export async function sealBytes( plaintext: Uint8Array, ): Promise { const key = km.fieldKey(table, column); - const nonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); - return aeadSeal(key, nonce, plaintext, aad); + return aeadSeal(key, randomNonce(), plaintext, aad); } /** Decrypt arbitrary bytes payload. */ @@ -107,9 +104,8 @@ export async function openBytes( blob: Uint8Array, ): Promise { const key = km.fieldKey(table, column); - const expectedNonce = deriveNonce(key, table, pk); const aad = buildAad(table, column, pk); - return aeadOpen(key, blob, aad, expectedNonce); + return aeadOpen(key, blob, aad); } // ─── Typed encoders for each StorageProvider entity ────────────────────── diff --git a/packages/shade-storage-encrypted/src/index.ts b/packages/shade-storage-encrypted/src/index.ts index 4d6c432..bb0ae44 100644 --- a/packages/shade-storage-encrypted/src/index.ts +++ b/packages/shade-storage-encrypted/src/index.ts @@ -19,12 +19,16 @@ export { deriveBlobSlotId, deriveBlobKey, deriveBlobSigningSeed, + deriveVaultId, + deriveVaultContentKey, + deriveVaultSigningSeed, } from './crypto/kdf.js'; export { AEAD_NONCE_LEN, AEAD_TAG_LEN, aeadSeal, aeadOpen, + randomNonce, } from './crypto/aead.js'; export { EncryptedSQLiteStorage } from './storage/encrypted-sqlite.js'; export { diff --git a/packages/shade-storage-encrypted/tests/nonce-reuse.test.ts b/packages/shade-storage-encrypted/tests/nonce-reuse.test.ts new file mode 100644 index 0000000..b283bfe --- /dev/null +++ b/packages/shade-storage-encrypted/tests/nonce-reuse.test.ts @@ -0,0 +1,141 @@ +/** + * 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(); + }); +});