From 56312ff57959199fb509eeeb23ec8d0fc1eef305 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 23:03:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(settings):=20phase=209=20milestone=204=20?= =?UTF-8?q?=E2=80=94=20zero-knowledge=20UI=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-facing setup + management surface for the Phase 9 recovery code + zero-knowledge opt-in. Lives in /settings/security between the Rotate and Honest-disclosure cards. Three-step setup flow --------------------- Step 1 — Generate Single button "Recovery-Code einrichten". Disabled unless the vault is currently unlocked. Clicks call vaultClient.setupRecoveryCode() which mints a fresh 32-byte secret, derives the wrap key, posts the sealed wrap to /recovery-wrap, and returns the formatted code. Step 2 — Display + copy Shows the formatted code (1A2B-3C4D-...) in a monospace, user- selectable block with a 📋 Copy button. Explicit warning: "Wir zeigen ihn dir nur ein einziges Mal." User clicks "Ich habe den Code gesichert" to advance. Step 3 — Confirm User has to type (or paste) the code back into a verification input. Comparison is case-insensitive and ignores dashes/whitespace on both sides so format jitter doesn't punish them. Mismatch shows a clear inline error and stays in the same step. Step 4 — Activate Final danger confirmation: "Wenn du jetzt aktivierst, löscht der Server seine Kopie deines Schlüssels." Click → vaultClient. enableZeroKnowledge() → server NULLs out wrapped_mk + wrap_iv, state flips to 'enabled', generatedCode is wiped from the closure. Active state ------------ After enable, the section shows a green "✅ Zero-Knowledge-Modus aktiv" panel with a "Disable" button. Disabling needs an unlocked vault (the cached MK bytes from the recovery-code unlock get sent back to the server for KEK re-wrapping). Two-click confirmation guards the destructive call. State machine ------------- zkSetupStep: 'idle' → 'generated' → 'confirming' → 'enabling' → 'enabled' plus a `handleResetSetup` escape that clears the in-flight code + input + error and drops back to 'idle' from any step. Known limitation: the page state doesn't survive a reload — there is no GET /encryption-vault/status endpoint yet to query the server's current zero_knowledge flag, so on a fresh page load we always start at 'idle' regardless of whether ZK is actually on. A future commit will add the status endpoint + an onMount call to hydrate zkSetupStep correctly. For now, the existing 'awaiting-recovery-code' badge from milestone 3 covers the lock- screen path, and the dashboard sets the right initial state at unlock time. Status badge fix from milestone 3 (statusBadge() handling the new 'awaiting-recovery-code' variant) is reused here. Styles ------ .zk-error — light red bordered alert for inline errors .zk-actions — flex row of buttons (wraps on mobile) .zk-step — bordered group with the step heading .recovery-code — monospace, user-select:all so click+copy works .recovery-input — monospace input for the confirm step .btn-ghost — transparent border-less variant for "Abbrechen" Dark-mode handling for the new surfaces is in the existing media query block. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(app)/settings/security/+page.svelte | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) 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 6487aa2e4..69555a6e6 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 @@ -34,6 +34,127 @@ let rotating = $state(false); let confirmRotate = $state(false); + // ─── Phase 9: Recovery code + Zero-knowledge ───────────── + // + // The setup flow has three steps: + // 1. Generate: client mints a fresh recovery secret + posts the + // sealed wrap to /recovery-wrap → returns the formatted code + // 2. Confirm: user has to type the code back in to prove they + // backed it up. We don't move to step 3 until this matches. + // 3. Enable: client posts /zero-knowledge { enable: true } and + // the server NULLs out the KEK wrap. Irreversible without the + // recovery code. + // + // The disable flow needs an unlocked vault that came in via the + // recovery code path (so the cached MK bytes are populated). We + // don't expose disable from the lock screen — only from this page + // while already unlocked. + + let zkSetupStep = $state<'idle' | 'generated' | 'confirming' | 'enabling' | 'enabled'>('idle'); + let generatedCode = $state(null); + let confirmCodeInput = $state(''); + let zkError = $state(null); + let zkBusy = $state(false); + let confirmDisableZk = $state(false); + let confirmClearRecovery = $state(false); + + async function handleSetupRecoveryCode() { + zkError = null; + zkBusy = true; + try { + const result = await vaultClient.setupRecoveryCode(); + generatedCode = result.formattedCode; + zkSetupStep = 'generated'; + } catch (e) { + zkError = (e as Error).message; + } finally { + zkBusy = false; + } + } + + function handleStartConfirm() { + zkSetupStep = 'confirming'; + confirmCodeInput = ''; + zkError = null; + } + + function handleConfirmCode() { + zkError = null; + // Strip whitespace + dashes from both sides for the comparison so + // the user doesn't get punished for inconsistent dash placement. + const expected = (generatedCode ?? '').replace(/[\s-]/g, '').toUpperCase(); + const actual = confirmCodeInput.replace(/[\s-]/g, '').toUpperCase(); + if (actual !== expected) { + zkError = 'Der eingegebene Code stimmt nicht mit dem angezeigten überein.'; + return; + } + zkSetupStep = 'enabling'; + } + + async function handleEnableZeroKnowledge() { + zkError = null; + zkBusy = true; + try { + await vaultClient.enableZeroKnowledge(); + zkSetupStep = 'enabled'; + // Wipe the displayed code from memory now that the user has + // confirmed they backed it up. The DOM still has it until the + // next render cycle, but our reference goes away. + generatedCode = null; + confirmCodeInput = ''; + toast.success('Zero-Knowledge-Modus aktiviert'); + } catch (e) { + zkError = (e as Error).message; + } finally { + zkBusy = false; + } + } + + async function handleDisableZeroKnowledge() { + zkError = null; + zkBusy = true; + try { + await vaultClient.disableZeroKnowledge(); + toast.success('Zero-Knowledge-Modus deaktiviert'); + confirmDisableZk = false; + zkSetupStep = 'idle'; + } catch (e) { + zkError = (e as Error).message; + } finally { + zkBusy = false; + } + } + + async function handleClearRecoveryCode() { + zkError = null; + zkBusy = true; + try { + await vaultClient.clearRecoveryCode(); + toast.success('Recovery-Code entfernt'); + confirmClearRecovery = false; + zkSetupStep = 'idle'; + } catch (e) { + zkError = (e as Error).message; + } finally { + zkBusy = false; + } + } + + function handleCopyCode() { + if (!generatedCode) return; + navigator.clipboard.writeText(generatedCode).then( + () => toast.success('Code in die Zwischenablage kopiert'), + () => toast.error('Konnte Code nicht kopieren') + ); + } + + function handleResetSetup() { + zkSetupStep = 'idle'; + generatedCode = null; + confirmCodeInput = ''; + zkError = null; + } + // Poll the vault vaultState every second so the badge reflects external // lock/unlock events (logout, manual lock from another tab) without // the user having to refresh the page. 1s is fine for a settings @@ -211,6 +332,165 @@ {/if} + +
+
+

Zero-Knowledge-Modus

+
+

+ Optional, fortgeschritten. Im Zero-Knowledge-Modus speichert Mana deinen + Schlüssel nur noch in einer Form, die wir selbst nicht entschlüsseln können. Du + brauchst dann beim Login von einem neuen Gerät deinen Recovery-Code, um deine Daten + freizuschalten. +

+

+ Vorteil: selbst ein Mana-Mitarbeiter mit Vollzugriff auf den Server kann + deine Inhalte nicht mehr lesen. Risiko: wenn du den Recovery-Code verlierst, sind + deine Daten unwiderruflich weg — wir haben dann keinen Backup-Schlüssel mehr. +

+ + {#if zkError} +
⚠️ {zkError}
+ {/if} + + {#if zkSetupStep === 'idle'} +
+ +
+ {/if} + + {#if zkSetupStep === 'generated' && generatedCode} +
+

Schritt 1 von 3 — Code sicher aufschreiben

+

+ Speichere diesen Code an einem sicheren Ort (Passwort-Manager, ausgedruckt im Tresor, …). + Wir zeigen ihn dir nur ein einziges Mal. +

+
{generatedCode}
+
+ + + +
+
+ {/if} + + {#if zkSetupStep === 'confirming'} +
+

Schritt 2 von 3 — Code zurück eintippen

+

+ Tippe (oder paste) den Code, den du gerade gespeichert hast. So stellen wir sicher, dass + der Backup wirklich vollständig ist. +

+ +
+ + +
+
+ {/if} + + {#if zkSetupStep === 'enabling'} +
+

Schritt 3 von 3 — Zero-Knowledge-Modus aktivieren

+

+ Wenn du jetzt aktivierst, löscht der Server seine Kopie deines Schlüssels. Ab sofort + kannst du nur noch mit dem Recovery-Code auf deine verschlüsselten Daten zugreifen. +

+

+ ⚠️ Diese Aktion ist nicht rückgängig zu machen ohne den Recovery-Code. Wenn du deinen Code + verlegst, sind deine Inhalte verloren. +

+
+ + +
+
+ {/if} + + {#if zkSetupStep === 'enabled'} +
+

✅ Zero-Knowledge-Modus aktiv

+

+ 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} +
+ +
+ {:else} +

+ Damit wir den Server-Schlüssel wiederherstellen können, brauchen wir deinen aktuell + geladenen Master-Key. Der ist gerade in deinem Browser — wir senden ihn einmal an den + Server, der ihn dann mit dem KEK neu wrappt. +

+
+ + +
+ {/if} +
+ {/if} +
+
@@ -387,6 +667,84 @@ gap: 0.5rem; } + /* ─── Phase 9: Zero-knowledge UI ─────────────────────── */ + + .zk-error { + margin-top: 0.75rem; + padding: 0.75rem 1rem; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 0.5rem; + font-size: 0.875rem; + color: rgb(185, 28, 28); + } + + .zk-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + } + + .zk-step { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border, #e5e7eb); + } + + .zk-step h3 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + } + + .zk-step p { + margin: 0.5rem 0; + font-size: 0.9rem; + } + + .zk-step p.warn { + color: rgb(185, 28, 28); + font-weight: 500; + } + + .recovery-code { + margin: 1rem 0; + padding: 1rem 1.25rem; + background: var(--surface-muted, #f9fafb); + border: 1px solid var(--border, #e5e7eb); + border-radius: 0.5rem; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 1rem; + font-weight: 500; + letter-spacing: 0.05em; + text-align: center; + word-break: break-all; + user-select: all; + } + + .recovery-input { + display: block; + width: 100%; + margin: 0.75rem 0; + padding: 0.75rem 1rem; + border: 1px solid var(--border, #e5e7eb); + border-radius: 0.5rem; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.95rem; + background: var(--surface, #fff); + } + + .recovery-input:focus { + outline: 2px solid var(--primary, #6366f1); + outline-offset: 1px; + } + + .btn-ghost { + background: transparent; + border-color: transparent; + } + @media (prefers-color-scheme: dark) { .card { background: var(--surface, #1f2937); @@ -395,5 +753,10 @@ .table-list li { background: var(--surface-muted, #111827); } + .recovery-code, + .recovery-input { + background: var(--surface-muted, #111827); + border-color: var(--border, #374151); + } }