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 6f8849c27..2760311f3 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 @@ -162,6 +162,23 @@ export interface VaultClient { * right section without triggering a full unwrap. Used by the * settings page on mount to hydrate after a reload. */ getStatus(): Promise; + + /** Generates a fresh recovery code and replaces the existing + * recovery wrap on the server. Works in BOTH standard and + * zero-knowledge modes: + * + * - Standard mode: re-fetches the current MK from the server + * and seals it with the new recovery key (same path as the + * initial setupRecoveryCode call). + * - Zero-knowledge mode: uses the cached MK bytes from the most + * recent recovery-code unlock, since the server can't hand + * out the plaintext MK any more. Throws if the cache is empty + * (caller has to re-unlock with the old recovery code first). + * + * Returns the freshly formatted recovery code for the UI to + * display. The OLD code is now invalid — using it on a future + * unlock will fail with "wrong recovery code". */ + rotateRecoveryCode(): Promise; } /** @@ -617,6 +634,90 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { return state; } + async function rotateRecoveryCode(): Promise { + if (state.status !== 'unlocked') { + throw new Error('vault must be unlocked before rotateRecoveryCode()'); + } + + const token = await getToken(); + if (!token) { + throw new Error('no auth token available for rotateRecoveryCode()'); + } + + // Two paths depending on which mode the vault is in: + // + // Standard: server still has the KEK wrap, so we can re-fetch + // the plaintext MK exactly the way setupRecoveryCode + // does. This branch is identical to the initial + // setup, just labelled differently in the UI. + // + // ZK: server can't hand out the MK. We use the cached + // bytes that unlockWithRecoveryCode stashed when + // the user typed in their old recovery code earlier + // this session. If the cache is empty (page reload + // after unlock + lock cycle, or unlock via init + // rather than recovery), we have to bail and ask + // the user to re-authenticate first. + + let rawMk: Uint8Array; + let needsWipe = true; + + const status = await getStatus(); + if (status.zeroKnowledge) { + if (!cachedUnwrappedMkBytes) { + throw new Error( + 'cannot rotate recovery code: vault is in zero-knowledge mode and the master key bytes are not cached. Sign out and back in with your current recovery code first.' + ); + } + // Clone the cache so we don't wipe the source. + rawMk = new Uint8Array(cachedUnwrappedMkBytes); + } else { + // Standard mode — re-fetch from the server. + const fetchRes = await fetchVault('/api/v1/me/encryption-vault/key', { + method: 'GET', + ...authHeaders(token), + }); + if (!fetchRes.ok || isRecoveryBlob(fetchRes.data)) { + throw new Error('failed to re-fetch master key for recovery wrap rotation'); + } + rawMk = base64ToBytes((fetchRes.data as { masterKey: string }).masterKey); + } + + // Generate fresh recovery secret + derive new wrap key. + const recoverySecret = generateRecoverySecret(); + const recoveryWrapKey = await deriveRecoveryWrapKey(recoverySecret); + + // Import the MK as extractable for the wrap operation. + const extractableMk = await crypto.subtle.importKey( + 'raw', + toBufferSource(rawMk), + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const sealed = await wrapMasterKeyWithRecovery(extractableMk, recoveryWrapKey); + + if (needsWipe) { + rawMk.fill(0); + } + + // POST replaces the existing wrap (setRecoveryWrap is idempotent + // server-side). The OLD recovery code is now permanently invalid. + 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 rotated recovery wrap: ${setRes.status}`); + } + + const formatted = formatRecoveryCode(recoverySecret); + recoverySecret.fill(0); + + return { formattedCode: formatted }; + } + async function getStatus(): Promise { const token = await getToken(); if (!token) throw new Error('no auth token available'); @@ -645,6 +746,7 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { disableZeroKnowledge, unlockWithRecoveryCode, getStatus, + rotateRecoveryCode, }; } 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 0a94c079e..6fbe37fb0 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 @@ -62,6 +62,13 @@ * mount so the UI can show "Recovery-Code entfernen" without * walking through the setup flow again. */ let hasRecoveryWrap = $state(false); + /** Side flow for rotating the recovery code from the active state. + * 'idle' — show "Code rotieren" button + * 'rotated' — show the new code + Copy + Done button + * Independent of zkSetupStep so the user can rotate without + * leaving the active-mode UI. */ + let rotateStep = $state<'idle' | 'rotated'>('idle'); + let rotatedCode = $state(null); async function handleSetupRecoveryCode() { zkError = null; @@ -155,6 +162,36 @@ ); } + async function handleRotateRecoveryCode() { + zkError = null; + zkBusy = true; + try { + const result = await vaultClient.rotateRecoveryCode(); + rotatedCode = result.formattedCode; + rotateStep = 'rotated'; + } catch (e) { + zkError = (e as Error).message; + } finally { + zkBusy = false; + } + } + + function handleCopyRotatedCode() { + if (!rotatedCode) return; + navigator.clipboard.writeText(rotatedCode).then( + () => toast.success('Code in die Zwischenablage kopiert'), + () => toast.error('Konnte Code nicht kopieren') + ); + } + + function handleFinishRotation() { + // User has acknowledged they backed up the new code. Wipe the + // reference (the DOM still shows it until the next render + // cycle, but our state goes away). + rotatedCode = null; + rotateStep = 'idle'; + } + function handleResetSetup() { zkSetupStep = 'idle'; generatedCode = null; @@ -529,8 +566,34 @@ Der Server kann deine Daten ab sofort nicht mehr entschlüsseln. Beim nächsten Login auf einem neuen Gerät wirst du nach deinem Recovery-Code gefragt.

- {#if !confirmDisableZk} + + {#if rotateStep === 'rotated' && rotatedCode} +
+

🔁 Neuer Recovery-Code

+

+ Dein alter Code ist ab sofort ungültig. Speichere den neuen Code an einem + sicheren Ort, bevor du diese Seite verlässt — wir zeigen ihn dir nur ein einziges Mal. +

+
{rotatedCode}
+
+ + +
+
+ {:else if !confirmDisableZk}
+