feat(crypto): phase 9 milestone 1 — recovery code primitives

Foundation for the zero-knowledge opt-in. New crypto/recovery.ts
provides the user-held secret half of the Phase 9 design:

  - generateRecoverySecret() — 32 random bytes (256 bits) from Web
    Crypto CSPRNG
  - formatRecoveryCode() — renders raw bytes as 16 dash-separated
    groups of 4 uppercase hex chars: "1A2B-3C4D-5E6F-..." (79 chars
    total). Copy-pasteable, password-manager-friendly, no language
    dependency.
  - parseRecoveryCode() — tolerant inverse: strips whitespace + any
    dash placement, accepts mixed case, throws RecoveryCodeFormatError
    on wrong length / non-hex (no position-leaking errors)
  - deriveRecoveryWrapKey() — HKDF-SHA256 with empty salt + versioned
    info "mana-recovery-v1" → non-extractable AES-GCM-256 wrap key.
    HKDF (not PBKDF2/scrypt) because the input already has full 256
    bits of entropy — no slow KDF needed.
  - wrapMasterKeyWithRecovery() — exports the master key bytes,
    AES-GCM-encrypts with the recovery wrap key, returns base64
    ciphertext + IV ready for the server. Wipes the raw MK reference
    immediately after sealing.
  - unwrapMasterKeyWithRecovery() — inverse, returns a non-extractable
    CryptoKey. Throws uniformly on wrong code / tampered ciphertext —
    the UI maps both to "wrong recovery code" so an attacker gets no
    side-channel signal about which check failed.

Why hex over BIP-39?
  - No 2048-word wordlist to bundle (~17 KB even gzipped)
  - 32 random bytes have full 256 bits of entropy on their own — no
    checksum word needed because there's nothing to "validate"
  - Trivially copy-pasteable into any password manager, no language
    dependency, no autocomplete-confusing dictionary words
  - Survives autocorrect (no spaces)

22 tests in recovery.test.ts cover:
  - generation (length, randomness)
  - format (16 groups, uppercase, total 79 chars, wrong-length input)
  - parse (roundtrip, lowercase, whitespace, missing dashes, extra
    dashes, error cases, no position leakage)
  - key derivation (non-extractable, deterministic, wrong-length input)
  - wrap/unwrap roundtrip (with and without format/parse trip)
  - failure modes (wrong code, tampered ciphertext)
  - IV uniqueness (no reuse on repeated wraps)

This is the self-contained foundation. Server-side schema, vault
service extensions, vault-client wire-up and the settings UI all
build on these primitives in subsequent commits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 22:00:43 +02:00
parent 25aabc3f49
commit 2f48f867f1
3 changed files with 542 additions and 0 deletions

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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<CryptoKey> {
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<CryptoKey> {
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;
}