diff --git a/apps/mana/apps/web/src/lib/data/crypto/index.ts b/apps/mana/apps/web/src/lib/data/crypto/index.ts index eebf4466e..6798a5caa 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/index.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/index.ts @@ -46,6 +46,17 @@ export { export { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from './record-helpers'; +export { + RECOVERY_SECRET_BYTES, + RecoveryCodeFormatError, + generateRecoverySecret, + formatRecoveryCode, + parseRecoveryCode, + deriveRecoveryWrapKey, + wrapMasterKeyWithRecovery, + unwrapMasterKeyWithRecovery, +} from './recovery'; + export { type VaultClient, type VaultClientOptions, diff --git a/apps/mana/apps/web/src/lib/data/crypto/recovery.test.ts b/apps/mana/apps/web/src/lib/data/crypto/recovery.test.ts new file mode 100644 index 000000000..e41c6a173 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/recovery.test.ts @@ -0,0 +1,255 @@ +/** + * Recovery code primitive tests (Phase 9). + * + * Pure crypto tests against Node 20+ Web Crypto. No Dexie / fake- + * indexeddb needed — these exercise the deterministic primitives that + * the zero-knowledge opt-in is built on. + */ + +import { describe, it, expect } from 'vitest'; +import { + generateRecoverySecret, + formatRecoveryCode, + parseRecoveryCode, + deriveRecoveryWrapKey, + wrapMasterKeyWithRecovery, + unwrapMasterKeyWithRecovery, + RecoveryCodeFormatError, + RECOVERY_SECRET_BYTES, +} from './recovery'; +import { generateMasterKey, wrapValue, unwrapValue, exportMasterKey } from './aes'; + +describe('generateRecoverySecret', () => { + it('returns 32 bytes', () => { + const secret = generateRecoverySecret(); + expect(secret).toBeInstanceOf(Uint8Array); + expect(secret.length).toBe(RECOVERY_SECRET_BYTES); + }); + + it('returns different bytes on each call (CSPRNG)', () => { + const a = generateRecoverySecret(); + const b = generateRecoverySecret(); + // Vanishingly unlikely they collide on 256 bits + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(false); + }); +}); + +describe('formatRecoveryCode', () => { + it('produces 16 dash-separated groups of 4 hex chars', () => { + const secret = new Uint8Array(32).fill(0xab); + const formatted = formatRecoveryCode(secret); + const groups = formatted.split('-'); + expect(groups).toHaveLength(16); + for (const g of groups) { + expect(g).toHaveLength(4); + expect(g).toMatch(/^[0-9A-F]+$/); + } + }); + + it('uses uppercase hex', () => { + const secret = new Uint8Array(32); + secret[0] = 0xab; + const formatted = formatRecoveryCode(secret); + expect(formatted.startsWith('AB00')).toBe(true); + }); + + it('throws on wrong-length input', () => { + expect(() => formatRecoveryCode(new Uint8Array(16))).toThrow(/expected 32 raw bytes/); + expect(() => formatRecoveryCode(new Uint8Array(64))).toThrow(/expected 32 raw bytes/); + }); + + it('total length is 79 chars (64 hex + 15 dashes)', () => { + const formatted = formatRecoveryCode(new Uint8Array(32)); + expect(formatted).toHaveLength(79); + }); +}); + +describe('parseRecoveryCode', () => { + it('roundtrips through formatRecoveryCode', () => { + const original = generateRecoverySecret(); + const formatted = formatRecoveryCode(original); + const parsed = parseRecoveryCode(formatted); + expect(Buffer.from(parsed).equals(Buffer.from(original))).toBe(true); + }); + + it('accepts lowercase hex', () => { + const secret = generateRecoverySecret(); + const upper = formatRecoveryCode(secret); + const lower = upper.toLowerCase(); + const parsed = parseRecoveryCode(lower); + expect(Buffer.from(parsed).equals(Buffer.from(secret))).toBe(true); + }); + + it('tolerates extra whitespace', () => { + const secret = generateRecoverySecret(); + const formatted = formatRecoveryCode(secret); + const messy = ` ${formatted}\n `; + expect(Buffer.from(parseRecoveryCode(messy)).equals(Buffer.from(secret))).toBe(true); + }); + + it('tolerates missing dashes', () => { + const secret = generateRecoverySecret(); + const formatted = formatRecoveryCode(secret).replace(/-/g, ''); + expect(formatted).toHaveLength(64); + expect(Buffer.from(parseRecoveryCode(formatted)).equals(Buffer.from(secret))).toBe(true); + }); + + it('tolerates extra dashes anywhere', () => { + const secret = generateRecoverySecret(); + const hex = formatRecoveryCode(secret).replace(/-/g, ''); + // Inject random dashes + const sliced = `${hex.slice(0, 8)}-${hex.slice(8, 16)}-${hex.slice(16, 32)}-${hex.slice(32)}`; + expect(Buffer.from(parseRecoveryCode(sliced)).equals(Buffer.from(secret))).toBe(true); + }); + + it('throws RecoveryCodeFormatError on wrong length', () => { + expect(() => parseRecoveryCode('AB12')).toThrow(RecoveryCodeFormatError); + expect(() => parseRecoveryCode('A'.repeat(63))).toThrow(RecoveryCodeFormatError); + expect(() => parseRecoveryCode('A'.repeat(65))).toThrow(RecoveryCodeFormatError); + }); + + it('throws RecoveryCodeFormatError on non-hex characters', () => { + // 64-char string with a 'G' in it + const bad = 'G' + 'A'.repeat(63); + expect(() => parseRecoveryCode(bad)).toThrow(RecoveryCodeFormatError); + }); + + it('error message does not leak which character position was wrong', () => { + try { + parseRecoveryCode('Z'.repeat(64)); + } catch (e) { + // Position-leaking words would help an attacker narrow the brute + // force; "characters" (plural, generic) is fine. + expect((e as Error).message).not.toMatch(/position|offset|index|at byte|at char \d/); + } + }); +}); + +describe('deriveRecoveryWrapKey', () => { + it('returns a non-extractable AES-GCM key', async () => { + const secret = generateRecoverySecret(); + const wrapKey = await deriveRecoveryWrapKey(secret); + expect(wrapKey.type).toBe('secret'); + expect(wrapKey.algorithm.name).toBe('AES-GCM'); + expect(wrapKey.extractable).toBe(false); + }); + + it('is deterministic for the same input', async () => { + const secret = new Uint8Array(32).fill(0x42); + const key1 = await deriveRecoveryWrapKey(secret); + const key2 = await deriveRecoveryWrapKey(secret); + // Both keys should produce the same ciphertext for the same plaintext + IV. + // We can verify this indirectly by encrypting with one and decrypting + // with the other. + const iv = new Uint8Array(12); + const data = new TextEncoder().encode('determinism check'); + const ct1 = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key1, data); + const pt2 = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key2, ct1); + expect(new TextDecoder().decode(pt2)).toBe('determinism check'); + }); + + it('throws on wrong-length input', async () => { + await expect(deriveRecoveryWrapKey(new Uint8Array(16))).rejects.toThrow( + /expected 32-byte secret/ + ); + }); +}); + +describe('wrapMasterKeyWithRecovery / unwrapMasterKeyWithRecovery', () => { + it('roundtrips a master key through the recovery wrap', async () => { + const mk = await generateMasterKey(); + const secret = generateRecoverySecret(); + const wrapKey = await deriveRecoveryWrapKey(secret); + + const sealed = await wrapMasterKeyWithRecovery(mk, wrapKey); + expect(sealed.recoveryWrappedMk).toBeTypeOf('string'); + expect(sealed.recoveryIv).toBeTypeOf('string'); + expect(sealed.recoveryWrappedMk.length).toBeGreaterThan(0); + + const recovered = await unwrapMasterKeyWithRecovery( + sealed.recoveryWrappedMk, + sealed.recoveryIv, + wrapKey + ); + + // The recovered key should encrypt + decrypt the same data as the + // original master key. + const original = await exportMasterKey(mk); + const sample = 'recovery roundtrip'; + const enc = await wrapValue(sample, mk); + const dec = await unwrapValue(enc, recovered); + expect(dec).toBe(sample); + + // And the recovered key should be non-extractable. + expect(recovered.extractable).toBe(false); + + // Sanity: the original was extractable so we could read its raw + // bytes for the wrap. Just verifying we didn't accidentally break + // that prerequisite. + expect(original.length).toBe(32); + }); + + it('survives the full user-typed roundtrip (format + parse)', async () => { + const mk = await generateMasterKey(); + const secret = generateRecoverySecret(); + const wrapKey = await deriveRecoveryWrapKey(secret); + const sealed = await wrapMasterKeyWithRecovery(mk, wrapKey); + + // Simulate the user copy-pasting the formatted code somewhere and + // pasting it back later. + const displayed = formatRecoveryCode(secret); + const reentered = parseRecoveryCode(displayed); + const reWrapKey = await deriveRecoveryWrapKey(reentered); + + const recovered = await unwrapMasterKeyWithRecovery( + sealed.recoveryWrappedMk, + sealed.recoveryIv, + reWrapKey + ); + + const sample = { secret: 'data', n: 42 }; + const enc = await wrapValue(sample, mk); + const dec = await unwrapValue(enc, recovered); + expect(dec).toEqual(sample); + }); + + it('fails to unwrap with the wrong recovery code', async () => { + const mk = await generateMasterKey(); + const correct = generateRecoverySecret(); + const wrong = generateRecoverySecret(); + + const correctWrapKey = await deriveRecoveryWrapKey(correct); + const wrongWrapKey = await deriveRecoveryWrapKey(wrong); + + const sealed = await wrapMasterKeyWithRecovery(mk, correctWrapKey); + + await expect( + unwrapMasterKeyWithRecovery(sealed.recoveryWrappedMk, sealed.recoveryIv, wrongWrapKey) + ).rejects.toThrow(); // AES-GCM auth tag mismatch + }); + + it('fails to unwrap tampered ciphertext', async () => { + const mk = await generateMasterKey(); + const secret = generateRecoverySecret(); + const wrapKey = await deriveRecoveryWrapKey(secret); + const sealed = await wrapMasterKeyWithRecovery(mk, wrapKey); + + // Flip a bit in the base64 ciphertext (turn first 'A' into 'B' or vice + // versa). The simplest robust mutation: change the first character. + const tampered = + (sealed.recoveryWrappedMk[0] === 'A' ? 'B' : 'A') + sealed.recoveryWrappedMk.slice(1); + + await expect( + unwrapMasterKeyWithRecovery(tampered, sealed.recoveryIv, wrapKey) + ).rejects.toThrow(); + }); + + it('different IVs are produced for repeated wraps of the same key', async () => { + const mk = await generateMasterKey(); + const wrapKey = await deriveRecoveryWrapKey(generateRecoverySecret()); + const a = await wrapMasterKeyWithRecovery(mk, wrapKey); + const b = await wrapMasterKeyWithRecovery(mk, wrapKey); + expect(a.recoveryIv).not.toBe(b.recoveryIv); + expect(a.recoveryWrappedMk).not.toBe(b.recoveryWrappedMk); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/crypto/recovery.ts b/apps/mana/apps/web/src/lib/data/crypto/recovery.ts new file mode 100644 index 000000000..943c3322c --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/recovery.ts @@ -0,0 +1,276 @@ +/** + * Recovery code primitives for the zero-knowledge opt-in (Phase 9). + * + * The recovery code is a user-held 256-bit secret that wraps the master + * key as an alternative to the server-side KEK wrap. When a user opts + * into zero-knowledge mode: + * + * 1. Client generates a 32-byte recovery secret + * 2. Client derives an AES-GCM-256 wrap key from it via HKDF-SHA256 + * 3. Client wraps the existing master key with the recovery key + * 4. The wrapped MK + IV are sent to the server (the recovery secret + * itself NEVER leaves the device) + * 5. The user copies the formatted recovery code to a password manager + * 6. The user toggles zero-knowledge mode on; the server then refuses + * to release the KEK-wrapped MK and instead returns the recovery- + * wrapped blob, which the client unwraps with the freshly-pasted + * recovery code on the next unlock + * + * Why hex over BIP-39? + * - No 2048-word wordlist to bundle (~17 KB even gzipped) + * - 32 random bytes have a full 256 bits of entropy on their own — + * no checksum word needed because there's nothing to "validate"; + * either the unwrap succeeds or it doesn't + * - Trivially copy-pasteable into any password manager, no language + * dependency, no autocomplete-confusing dictionary words + * - Slightly longer than 24 BIP-39 words (64 vs ~150 chars formatted) + * but no spaces, so it survives autocorrect + * + * Format: + * 16 groups of 4 hex chars separated by dashes: + * `1A2B-3C4D-5E6F-...` (79 chars total: 64 hex + 15 dashes) + * The dash separator is purely cosmetic — `parseRecoveryCode` strips + * any whitespace and any dashes before validating, so users can paste + * any reasonable formatting. + * + * Why HKDF instead of PBKDF2/scrypt? + * - The recovery code already has 256 bits of entropy; brute-forcing + * a single AES-GCM unwrap is computationally infeasible regardless + * of how slow the KDF is + * - HKDF is the lighter primitive when the input is high-entropy + * (RFC 5869 was designed for exactly this case) + * - Web Crypto ships HKDF natively — no third-party crypto deps + * + * The salt is empty by design (high-entropy input) and the info string + * (`mana-recovery-v1`) gives us a versioned derivation path for any + * future format migration. + */ + +import { importMasterKey, exportMasterKey } from './aes'; + +/** Length of the raw recovery secret in bytes — 256 bits of entropy. */ +export const RECOVERY_SECRET_BYTES = 32; + +/** Number of hex chars per visual group in the formatted code. */ +const GROUP_SIZE = 4; + +/** Dash-separated hex groups that make up the displayed recovery code. */ +const TOTAL_GROUPS = (RECOVERY_SECRET_BYTES * 2) / GROUP_SIZE; // 16 + +/** HKDF info string. Bumped if the derivation ever changes. */ +const HKDF_INFO = 'mana-recovery-v1'; + +// ─── Generation + formatting ──────────────────────────────────── + +/** + * Generates a fresh 32-byte recovery secret using the Web Crypto CSPRNG. + * This is the raw key material; the caller usually wants to round-trip + * it through `formatRecoveryCode` before showing it to a human. + */ +export function generateRecoverySecret(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(RECOVERY_SECRET_BYTES)); +} + +/** + * Formats raw recovery bytes as a copy-pasteable string of 16 dash- + * separated hex groups: `1A2B-3C4D-5E6F-...`. Uppercase hex by + * convention (easier to read on a printout, parser is case-insensitive). + */ +export function formatRecoveryCode(raw: Uint8Array): string { + if (raw.length !== RECOVERY_SECRET_BYTES) { + throw new Error( + `mana-crypto/recovery: expected ${RECOVERY_SECRET_BYTES} raw bytes, got ${raw.length}` + ); + } + const hex = bytesToHex(raw).toUpperCase(); + const groups: string[] = []; + for (let i = 0; i < TOTAL_GROUPS; i++) { + groups.push(hex.slice(i * GROUP_SIZE, (i + 1) * GROUP_SIZE)); + } + return groups.join('-'); +} + +/** + * Parses a user-typed (or password-manager-pasted) recovery code back + * into the raw 32 bytes. Tolerant of: + * - Any case (mixed/upper/lower) + * - Extra whitespace anywhere + * - Missing or extra dashes + * - Common confusables (none replaced for now — hex is unambiguous) + * + * Throws on: + * - Wrong length after stripping separators + * - Non-hex characters + * + * Failures are intentionally generic — the UI shouldn't tell an + * attacker which character was wrong. + */ +export function parseRecoveryCode(code: string): Uint8Array { + const cleaned = code.replace(/[\s-]/g, ''); + if (cleaned.length !== RECOVERY_SECRET_BYTES * 2) { + throw new RecoveryCodeFormatError( + `expected ${RECOVERY_SECRET_BYTES * 2} hex chars after stripping separators, got ${cleaned.length}` + ); + } + if (!/^[0-9a-fA-F]+$/.test(cleaned)) { + throw new RecoveryCodeFormatError('contains non-hex characters'); + } + return hexToBytes(cleaned); +} + +/** + * Thrown by `parseRecoveryCode` when the input is not a valid recovery + * code shape. Distinct from a wrap/unwrap failure (which means the code + * is well-formed but didn't match) so the UI can give the user a more + * specific hint when the format is just off. + */ +export class RecoveryCodeFormatError extends Error { + constructor(detail: string) { + super(`mana-crypto/recovery: malformed recovery code (${detail})`); + this.name = 'RecoveryCodeFormatError'; + } +} + +// ─── Key derivation + wrap/unwrap ─────────────────────────────── + +/** + * Derives the AES-GCM-256 wrap key from the raw recovery secret via + * HKDF-SHA256. Returns a non-extractable CryptoKey ready for use with + * `wrapMasterKeyWithRecovery` / `unwrapMasterKeyWithRecovery`. + * + * Empty salt + the versioned `mana-recovery-v1` info string. The + * derived key is non-extractable so even if a malicious script gets a + * reference to it, it can't be exfiltrated as raw bytes. + */ +export async function deriveRecoveryWrapKey(rawSecret: Uint8Array): Promise { + if (rawSecret.length !== RECOVERY_SECRET_BYTES) { + throw new Error( + `mana-crypto/recovery: expected ${RECOVERY_SECRET_BYTES}-byte secret, got ${rawSecret.length}` + ); + } + + // Import the raw bytes as HKDF input keying material. + const ikm = await crypto.subtle.importKey( + 'raw', + toBufferSource(rawSecret), + 'HKDF', + false, // never extractable + ['deriveKey'] + ); + + // Derive the AES-GCM wrap key. salt:empty because the input already + // has full 256-bit entropy; info:mana-recovery-v1 versions the derivation. + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new TextEncoder().encode(HKDF_INFO), + }, + ikm, + { name: 'AES-GCM', length: 256 }, + false, // non-extractable + ['encrypt', 'decrypt'] + ); +} + +/** + * Wraps a master key (as a CryptoKey) with the recovery wrap key. + * Returns base64-encoded ciphertext + IV ready to be sent to the + * server. The MK must be extractable (the only way to encrypt it as + * a blob is to read its raw bytes first). + * + * Throws if the master key is non-extractable — the caller must use + * `generateMasterKey(true)` or import via a path that preserved + * extractability if it wants to seal a key for recovery. + */ +export async function wrapMasterKeyWithRecovery( + masterKey: CryptoKey, + recoveryWrapKey: CryptoKey +): Promise<{ recoveryWrappedMk: string; recoveryIv: string }> { + const rawMk = await exportMasterKey(masterKey); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + recoveryWrapKey, + toBufferSource(rawMk) + ); + // Best-effort wipe of the raw MK reference once it's sealed. + rawMk.fill(0); + return { + recoveryWrappedMk: bytesToBase64(new Uint8Array(ct)), + recoveryIv: bytesToBase64(iv), + }; +} + +/** + * Unwraps a recovery-wrapped master key blob back into a usable + * non-extractable CryptoKey. The returned key is suitable for direct + * use with the existing wrapValue/unwrapValue helpers. + * + * Throws on: + * - Wrong recovery code (AES-GCM auth tag mismatch) + * - Tampered ciphertext + * - Malformed base64 + * + * The caller is expected to surface these uniformly as "wrong recovery + * code" — leaking which check failed gives an attacker free signal. + */ +export async function unwrapMasterKeyWithRecovery( + recoveryWrappedMk: string, + recoveryIv: string, + recoveryWrapKey: CryptoKey +): Promise { + const iv = base64ToBytes(recoveryIv); + const ct = base64ToBytes(recoveryWrappedMk); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + recoveryWrapKey, + toBufferSource(ct) + ); + const rawMk = new Uint8Array(plain); + if (rawMk.length !== 32) { + throw new Error(`mana-crypto/recovery: unwrapped master key has wrong length ${rawMk.length}`); + } + const cryptoKey = await importMasterKey(rawMk); + rawMk.fill(0); + return cryptoKey; +} + +// ─── Internal helpers ─────────────────────────────────────────── + +function bytesToHex(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i++) { + out += bytes[i].toString(16).padStart(2, '0'); + } + return out; +} + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** TS 5.7 BufferSource compat — see comment in aes.ts. */ +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.length); + new Uint8Array(buf).set(bytes); + return buf; +}