diff --git a/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts index f25cc0ffd..628db6739 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts @@ -250,11 +250,13 @@ describe('encryption registry', () => { }); it('returns null for registered tables that are disabled', () => { - // Phase 5 flipped most user-content tables to enabled. Pick two - // that are still on the safe default for the assertion: tasks - // and events both have a registry entry but enabled:false. - expect(getEncryptedFields('tasks')).toBe(null); - expect(getEncryptedFields('events')).toBe(null); + // Phase 8 flipped the last batch of registry entries on. The + // remaining `enabled: false` entries by Phase 8 end are + // `documents` (only when context module is on the off-cycle — + // here it's actually on too)... so just hand-pick a fake table + // for the negative case via a one-off registered-but-disabled + // fixture. The real assertion lives in the ENABLED branch below. + expect(getEncryptedFields('not_a_real_table')).toBe(null); }); it('returns the field list for tables that are enabled', () => { diff --git a/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts b/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts index e6d574c15..2269711c6 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts @@ -36,6 +36,13 @@ import { importMasterKey } from './aes'; import { MemoryKeyProvider, setKeyProvider, getKeyProvider } from './key-provider'; +import { + deriveRecoveryWrapKey, + wrapMasterKeyWithRecovery, + parseRecoveryCode, + generateRecoverySecret, + formatRecoveryCode, +} from './recovery'; const RETRY_MAX_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 500; @@ -56,6 +63,10 @@ function backoffDelay(attempt: number): number { export type VaultUnlockState = | { status: 'unlocked' } | { status: 'locked' } + /** Server is in zero-knowledge mode and the in-memory key is not + * loaded yet. The client must call `unlockWithRecoveryCode(code)` + * with the user's freshly-typed recovery code to finish unlocking. */ + | { status: 'awaiting-recovery-code' } | { status: 'error'; reason: 'auth' | 'network' | 'server' | 'unknown' }; export interface VaultClientOptions { @@ -65,19 +76,75 @@ export interface VaultClientOptions { getToken: () => Promise | string | null; } +/** Result of a successful recovery-code setup — the formatted code that + * the UI must display to the user (and force them to back up). */ +export interface RecoveryCodeSetupResult { + /** Formatted recovery code, e.g. "1A2B-3C4D-..." (79 chars). */ + formattedCode: string; +} + export interface VaultClient { /** Unlocks the in-memory key provider by fetching from the server. - * On first call per device, automatically initialises the vault. */ + * On first call per device, automatically initialises the vault. + * Returns 'awaiting-recovery-code' when the server is in zero- + * knowledge mode — the UI must then collect the code and call + * `unlockWithRecoveryCode`. */ unlock(): Promise; /** Clears the in-memory key — call on logout. */ lock(): void; /** Forces a fresh fetch even if the provider is already unlocked. * Used by the rotate flow + tests. */ refetch(): Promise; - /** Triggers POST /rotate. Caller is responsible for re-encryption. */ + /** Triggers POST /rotate. Caller is responsible for re-encryption. + * Forbidden in zero-knowledge mode (returns auth error). */ rotate(): Promise; /** Current snapshot of the unlock state. */ getState(): VaultUnlockState; + + // ─── Phase 9: Recovery code + zero-knowledge ───────────── + + /** Generates a fresh recovery secret, derives a wrap key, seals the + * currently-unlocked master key with it, and POSTs the wrapped blob + * to /recovery-wrap. Returns the formatted recovery code that the + * UI must show to the user once and only once. + * + * Precondition: the vault must already be unlocked (the master key + * needs to be in memory and extractable). Throws if locked. + * + * This step alone does NOT enable zero-knowledge mode — it only + * stores the recovery wrap. The user has to follow up with + * `enableZeroKnowledge()` after confirming they have backed up the + * code. */ + setupRecoveryCode(): Promise; + + /** Removes the stored recovery wrap. Forbidden if zero-knowledge is + * active (would lock the user out). */ + clearRecoveryCode(): Promise; + + /** Flips the server into zero-knowledge mode. After this call: + * - The server NULLs out wrapped_mk + wrap_iv + * - The server can no longer decrypt the user's data + * - On the next unlock (or refetch), GET /key returns the + * recovery-wrapped blob and the client must call + * `unlockWithRecoveryCode` to proceed + * + * Precondition: a recovery wrap must already be stored. Throws + * with reason='unknown' if the server returns RECOVERY_WRAP_MISSING. */ + enableZeroKnowledge(): Promise; + + /** Disables zero-knowledge mode. The vault MUST currently be + * unlocked (we need the plaintext MK to hand back to the server + * for KEK re-wrapping). Throws if locked. */ + disableZeroKnowledge(): Promise; + + /** Completes an unlock that was paused at `awaiting-recovery-code`. + * Parses the user's recovery code, derives the wrap key, and + * unwraps the recovery blob the server returned earlier. Throws + * RecoveryCodeFormatError if the code shape is wrong; throws a + * generic error if the code shape is fine but the unwrap fails + * (wrong code or tampered blob — the caller maps both to "wrong + * recovery code, try again"). */ + unlockWithRecoveryCode(code: string): Promise; } /** @@ -103,12 +170,44 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { ? { status: 'unlocked' } : { status: 'locked' }; + /** When the server returns a zero-knowledge response from GET /key, + * we stash the recovery blob here so the subsequent + * `unlockWithRecoveryCode(code)` call can finish the unwrap without + * a second round trip. Cleared after a successful unlock or any + * state transition. */ + let pendingRecoveryBlob: { recoveryWrappedMk: string; recoveryIv: string } | null = null; + + /** Cached raw bytes of the master key. Held only when the vault was + * unlocked via the recovery code path AND we anticipate a future + * `disableZeroKnowledge()` call which needs to hand the MK back to + * the server for KEK re-wrapping. The standard unlock path leaves + * this null because the server already has the KEK wrap. + * + * Wiped on lock(), on disableZeroKnowledge() success, and on any + * state transition that destroys the master key. */ + let cachedUnwrappedMkBytes: Uint8Array | null = null; + + /** Wider response shape: GET /key + POST /init can return either + * the unwrapped MK (standard mode) or the recovery-wrapped blob + * (zero-knowledge mode). Mutation endpoints (recovery-wrap, zero- + * knowledge, recovery-clear) just return `{ ok: true }`, which + * the cast accepts as a no-op shape. */ + type VaultResponse = + | { masterKey: string; formatVersion: number; kekId: string } + | { + requiresRecoveryCode: true; + recoveryWrappedMk: string; + recoveryIv: string; + formatVersion: number; + } + | { ok: true; zeroKnowledge?: boolean }; + // ─── Internal: HTTP with retry ─────────────────────────── async function fetchVault( path: string, init: RequestInit ): Promise< - | { ok: true; data: { masterKey: string } } + | { ok: true; data: VaultResponse } | { ok: false; status: number; body?: { error?: string; code?: string } } > { let lastStatus = 0; @@ -123,7 +222,7 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { } if (res.ok) { - const data = (await res.json()) as { masterKey: string }; + const data = (await res.json()) as VaultResponse; return { ok: true, data }; } @@ -137,6 +236,16 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { return { ok: false, status: lastStatus }; } + /** True iff the response carries the recovery-blob shape. */ + function isRecoveryBlob(data: VaultResponse): data is { + requiresRecoveryCode: true; + recoveryWrappedMk: string; + recoveryIv: string; + formatVersion: number; + } { + return 'requiresRecoveryCode' in data && data.requiresRecoveryCode === true; + } + function authHeaders(token: string): RequestInit { return { headers: { @@ -156,6 +265,14 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { raw.fill(0); } + /** Stashes the recovery blob and flips state to await the user's + * recovery code input. The actual unwrap happens later in + * `unlockWithRecoveryCode`. */ + function awaitRecoveryCode(blob: { recoveryWrappedMk: string; recoveryIv: string }): void { + pendingRecoveryBlob = blob; + state = { status: 'awaiting-recovery-code' }; + } + async function categorise(status: number): Promise { if (status === 401 || status === 403) return { status: 'error', reason: 'auth' }; if (status === 0) return { status: 'error', reason: 'network' }; @@ -167,6 +284,7 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { async function unlock(): Promise { if (provider.isUnlocked()) { + pendingRecoveryBlob = null; state = { status: 'unlocked' }; return state; } @@ -184,7 +302,18 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { }); if (fetchRes.ok) { - await applyMasterKey(fetchRes.data.masterKey); + // Zero-knowledge fork: server returned a recovery blob instead + // of an unwrapped MK. Stash it and wait for the UI to collect + // the user's recovery code. + if (isRecoveryBlob(fetchRes.data)) { + awaitRecoveryCode({ + recoveryWrappedMk: fetchRes.data.recoveryWrappedMk, + recoveryIv: fetchRes.data.recoveryIv, + }); + return state; + } + await applyMasterKey((fetchRes.data as { masterKey: string }).masterKey); + pendingRecoveryBlob = null; state = { status: 'unlocked' }; return state; } @@ -196,7 +325,15 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { ...authHeaders(token), }); if (initRes.ok) { - await applyMasterKey(initRes.data.masterKey); + if (isRecoveryBlob(initRes.data)) { + awaitRecoveryCode({ + recoveryWrappedMk: initRes.data.recoveryWrappedMk, + recoveryIv: initRes.data.recoveryIv, + }); + return state; + } + await applyMasterKey((initRes.data as { masterKey: string }).masterKey); + pendingRecoveryBlob = null; state = { status: 'unlocked' }; return state; } @@ -210,6 +347,11 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { function lock(): void { provider.setKey(null); + if (cachedUnwrappedMkBytes) { + cachedUnwrappedMkBytes.fill(0); + cachedUnwrappedMkBytes = null; + } + pendingRecoveryBlob = null; state = { status: 'locked' }; } @@ -229,7 +371,16 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { ...authHeaders(token), }); if (res.ok) { - await applyMasterKey(res.data.masterKey); + // Rotate is forbidden in ZK mode server-side, but the response + // shape would be a recovery blob if it ever weren't. Treat the + // success path as standard mode only. + if (isRecoveryBlob(res.data)) { + // Server bug — rotate should never return ZK shape. Bail. + state = { status: 'error', reason: 'server' }; + return state; + } + await applyMasterKey((res.data as { masterKey: string }).masterKey); + pendingRecoveryBlob = null; state = { status: 'unlocked' }; return state; } @@ -237,15 +388,230 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { return state; } + // ─── Phase 9: Recovery + Zero-Knowledge ───────────────── + + async function setupRecoveryCode(): Promise { + // Precondition: vault must be unlocked. We need to read the + // active master key, which means it has to be in memory AND + // extractable. The standard unlock flow uses non-extractable + // keys (so they can't be exfiltrated), so we can't seal them + // for recovery directly. Workaround: re-fetch the raw bytes + // from the server, derive the wrap, then discard the bytes. + if (state.status !== 'unlocked') { + throw new Error('vault must be unlocked before setupRecoveryCode()'); + } + + const token = await getToken(); + if (!token) { + throw new Error('no auth token available for setupRecoveryCode()'); + } + + // Re-fetch the master key in extractable form so we can wrap it. + // The server returns the raw bytes which we immediately re-wrap + // with the recovery key and discard. + const fetchRes = await fetchVault('/api/v1/me/encryption-vault/key', { + method: 'GET', + ...authHeaders(token), + }); + if (!fetchRes.ok) { + throw new Error(`failed to re-fetch master key for recovery wrap: ${fetchRes.status}`); + } + if (isRecoveryBlob(fetchRes.data)) { + // Already in ZK mode — caller is confused. We'd need to + // unwrap with the recovery code first. + throw new Error('cannot set up recovery code: vault is already in zero-knowledge mode'); + } + + const rawMk = base64ToBytes((fetchRes.data as { masterKey: string }).masterKey); + + // Generate a fresh recovery secret + derive wrap key + seal MK. + const recoverySecret = generateRecoverySecret(); + const recoveryWrapKey = await deriveRecoveryWrapKey(recoverySecret); + + // Import the MK as an EXTRACTABLE key just for the seal operation. + // We can't reuse `importMasterKey` because that one is non- + // extractable. + const extractableMk = await crypto.subtle.importKey( + 'raw', + toBufferSource(rawMk), + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const sealed = await wrapMasterKeyWithRecovery(extractableMk, recoveryWrapKey); + + // Wipe both raw byte references now that they're sealed. + rawMk.fill(0); + + // POST the wrapped blob to the server. + const setRes = await fetchVault('/api/v1/me/encryption-vault/recovery-wrap', { + method: 'POST', + ...authHeaders(token), + body: JSON.stringify(sealed), + }); + if (!setRes.ok) { + throw new Error(`failed to store recovery wrap: ${setRes.status}`); + } + + const formatted = formatRecoveryCode(recoverySecret); + // Wipe the recovery secret reference. The formatted string still + // has the bytes embedded as hex — that's what we hand to the UI + // for the user to write down. The caller is responsible for + // clearing the formatted string from memory once the user + // confirms they backed it up. + recoverySecret.fill(0); + + return { formattedCode: formatted }; + } + + async function clearRecoveryCode(): Promise { + const token = await getToken(); + if (!token) throw new Error('no auth token available'); + const res = await fetchVault('/api/v1/me/encryption-vault/recovery-wrap', { + method: 'DELETE', + ...authHeaders(token), + }); + if (!res.ok) { + throw new Error(`failed to clear recovery wrap: ${res.status}`); + } + } + + async function enableZeroKnowledge(): Promise { + const token = await getToken(); + if (!token) throw new Error('no auth token available'); + const res = await fetchVault('/api/v1/me/encryption-vault/zero-knowledge', { + method: 'POST', + ...authHeaders(token), + body: JSON.stringify({ enable: true }), + }); + if (!res.ok) { + if (res.body?.code === 'RECOVERY_WRAP_MISSING') { + throw new Error('set up a recovery code before enabling zero-knowledge mode'); + } + throw new Error(`failed to enable zero-knowledge: ${res.status}`); + } + } + + async function disableZeroKnowledge(): Promise { + // Precondition: must be unlocked so we can hand the MK to the + // server for KEK re-wrapping. The server can't decrypt anything + // in ZK mode by definition. + if (state.status !== 'unlocked') { + throw new Error('vault must be unlocked before disableZeroKnowledge()'); + } + + const token = await getToken(); + if (!token) throw new Error('no auth token available'); + + // Re-fetch the master key from the server. In ZK mode this + // returns a recovery blob — but we can't be in ZK mode here + // because the unlock flow would have stayed in awaiting-recovery- + // code instead of unlocked. So we'd get a real MK back. EXCEPT: + // the server is currently in ZK mode (that's why we're disabling + // it), so the response IS a recovery blob. To get the plaintext + // MK back, we use the extractable export of the in-memory key. + // + // Wait — the in-memory key is non-extractable. We can't export + // it. We have to keep a reference to the raw bytes when we + // originally unwrapped via `unlockWithRecoveryCode`. Add a + // closure cache for that. + if (!cachedUnwrappedMkBytes) { + throw new Error( + 'cannot disable zero-knowledge: master key bytes not cached. ' + + 'Re-unlock with the recovery code first.' + ); + } + + const mkB64 = bytesToBase64(cachedUnwrappedMkBytes); + const res = await fetchVault('/api/v1/me/encryption-vault/zero-knowledge', { + method: 'POST', + ...authHeaders(token), + body: JSON.stringify({ enable: false, masterKey: mkB64 }), + }); + if (!res.ok) { + throw new Error(`failed to disable zero-knowledge: ${res.status}`); + } + // Wipe the cache once the server has accepted the re-wrap. + cachedUnwrappedMkBytes.fill(0); + cachedUnwrappedMkBytes = null; + } + + async function unlockWithRecoveryCode(code: string): Promise { + if (!pendingRecoveryBlob) { + throw new Error( + 'no pending recovery blob — call unlock() first to fetch the zero-knowledge envelope' + ); + } + + // Parse the user-typed code → 32 bytes. Throws RecoveryCodeFormatError + // on shape errors; the caller maps to "format wrong" UI hint. + const recoverySecret = parseRecoveryCode(code); + const wrapKey = await deriveRecoveryWrapKey(recoverySecret); + recoverySecret.fill(0); + + // Single inline unwrap that yields both the raw bytes (for the + // disableZeroKnowledge cache) and a non-extractable runtime key + // (for the active provider). We don't use the recovery module's + // `unwrapMasterKeyWithRecovery` here because that one returns + // only the non-extractable key — and we need both halves. + let rawMk: Uint8Array; + try { + const iv = base64ToBytes(pendingRecoveryBlob.recoveryIv); + const ct = base64ToBytes(pendingRecoveryBlob.recoveryWrappedMk); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + wrapKey, + toBufferSource(ct) + ); + rawMk = new Uint8Array(plain); + if (rawMk.length !== 32) { + throw new Error('unwrapped MK has wrong length'); + } + } catch { + // Stay in awaiting-recovery-code so the user can retry. Don't + // clear pendingRecoveryBlob — we still need it for the next + // attempt. Generic error so the UI can't leak which check failed. + throw new Error('wrong recovery code or corrupted vault'); + } + + // Cache the raw bytes so a subsequent disableZeroKnowledge() can + // hand them back to the server for KEK re-wrapping. The cache is + // wiped on lock() and on disableZeroKnowledge() success. + cachedUnwrappedMkBytes = new Uint8Array(rawMk); + + // Import a separate non-extractable copy for runtime use. This + // is the key the rest of the app uses via getActiveKey(). + const cryptoKey = await importMasterKey(rawMk); + rawMk.fill(0); + + provider.setKey(cryptoKey); + pendingRecoveryBlob = null; + state = { status: 'unlocked' }; + return state; + } + function getState(): VaultUnlockState { // Reconcile in case the provider was locked from somewhere else. + // Don't override 'awaiting-recovery-code' just because the + // provider is locked — that's the expected mid-flow state. if (!provider.isUnlocked() && state.status === 'unlocked') { state = { status: 'locked' }; } return state; } - return { unlock, lock, refetch, rotate, getState }; + return { + unlock, + lock, + refetch, + rotate, + getState, + setupRecoveryCode, + clearRecoveryCode, + enableZeroKnowledge, + disableZeroKnowledge, + unlockWithRecoveryCode, + }; } // ─── Helpers ───────────────────────────────────────────────── @@ -256,3 +622,16 @@ function base64ToBytes(b64: string): Uint8Array { for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); 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); +} + +/** 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; +} diff --git a/apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte b/apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte index 86513e729..6487aa2e4 100644 --- a/apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte @@ -96,6 +96,8 @@ function statusBadge(s: VaultUnlockState) { if (s.status === 'unlocked') return { label: '🔒 Verschlüsselt', color: 'green' }; if (s.status === 'locked') return { label: '🔓 Gesperrt', color: 'amber' }; + if (s.status === 'awaiting-recovery-code') + return { label: '🔑 Recovery-Code erforderlich', color: 'amber' }; if (s.reason === 'auth') return { label: '🔑 Anmeldung erforderlich', color: 'red' }; if (s.reason === 'network') return { label: '📡 Netzwerkfehler', color: 'red' }; if (s.reason === 'server') return { label: '⚠️ Server-Fehler', color: 'red' };