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.
+ 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.
+