mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
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:
parent
25aabc3f49
commit
2f48f867f1
3 changed files with 542 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
255
apps/mana/apps/web/src/lib/data/crypto/recovery.test.ts
Normal file
255
apps/mana/apps/web/src/lib/data/crypto/recovery.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
276
apps/mana/apps/web/src/lib/data/crypto/recovery.ts
Normal file
276
apps/mana/apps/web/src/lib/data/crypto/recovery.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue