From a48b2d58415e4d5bbcde650e7fcece63605d9d48 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 23:24:32 +0200 Subject: [PATCH] feat(layout): lock-screen recovery code unlock modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second Phase 9 follow-up. When a user has zero-knowledge mode active and signs in on a new device (or after a session expiry), the layout's vault-unlock effect lands in the new 'awaiting-recovery-code' state. Previously this was a dead end — the layout just logged a warning and the rest of the app sat with a locked vault. This commit adds the missing UI piece: a non-dismissable modal that mounts whenever the unlock effect signals 'awaiting-recovery-code'. RecoveryCodeUnlockModal component --------------------------------- - Reads the singleton vault client via getVaultClient() - Single text input + submit button - On submit: 1. Calls vaultClient.unlockWithRecoveryCode(input) 2. On success: clears input, calls onUnlocked() prop → parent hides the modal, app boots normally 3. On RecoveryCodeFormatError: shows a format hint 4. On any other error (wrong code OR corrupted blob — surfaced uniformly so an attacker can't distinguish): shows "Recovery-Code falsch, prüfe deine Eingabe" - Non-dismissable: there's no Cancel button. Without the recovery code the app cannot read encrypted data and would just sit in a half-broken state. The user can sign out from the header (the auth flow runs above the encryption layer) if they need to bail. - Help text at the bottom is honest about the irreversible nature of losing the recovery code. Layout integration ------------------ +layout.svelte: - Imports the modal - New `needsRecoveryCode = $state(false)` flag - The vault-unlock effect now switches on three branches instead of just success/failure: 'unlocked' → needsRecoveryCode = false 'awaiting-recovery-code' → needsRecoveryCode = true (mount modal) anything else → console.warn (unchanged) - Logout path also resets needsRecoveryCode so the modal doesn't leak across sessions - {#if needsRecoveryCode} mounts the component at the bottom of the markup (above the existing global toasts and banners) The autofocus warning is suppressed via svelte-ignore — the input needs immediate focus because it's the only thing the user can interact with on this surface, and screen-reader users will hear the modal's accessible name from the role="dialog" + aria-labelledby binding. End-to-end smoke flow that now works: 1. User goes to /settings/security on Device A, enables ZK 2. User signs out, signs back in on Device B 3. Layout effect calls vaultClient.unlock() → server returns recovery blob → vaultClient state goes to awaiting-recovery-code 4. Modal mounts, user pastes their recovery code from password manager 5. unlockWithRecoveryCode runs the inline AES-GCM unwrap, imports the MK as non-extractable, caches the bytes for a future disable, transitions to 'unlocked' 6. Modal calls onUnlocked → layout dismisses modal → rest of the app boots and renders decrypted data Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/RecoveryCodeUnlockModal.svelte | 236 ++++++++++++++++++ apps/mana/apps/web/src/routes/+layout.svelte | 24 +- 2 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/components/RecoveryCodeUnlockModal.svelte diff --git a/apps/mana/apps/web/src/lib/components/RecoveryCodeUnlockModal.svelte b/apps/mana/apps/web/src/lib/components/RecoveryCodeUnlockModal.svelte new file mode 100644 index 000000000..a492e2343 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/RecoveryCodeUnlockModal.svelte @@ -0,0 +1,236 @@ + + + + + + diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index b6e385e06..92d4a6a3c 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -11,6 +11,7 @@ import { getVaultClient, hasAnyEncryption } from '$lib/data/crypto'; import SuggestionToast from '$lib/components/SuggestionToast.svelte'; import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte'; + import RecoveryCodeUnlockModal from '$lib/components/RecoveryCodeUnlockModal.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte'; @@ -25,6 +26,12 @@ // (root layout, settings/security page, future settings sub-pages). const vaultClient = getVaultClient(); + /** True iff the vault unlock landed in the Phase 9 zero-knowledge + * branch and is waiting for the user to type their recovery code + * into the modal. The unlock effect below sets it after the + * vaultClient.unlock() call returns 'awaiting-recovery-code'. */ + let needsRecoveryCode = $state(false); + // Push the active user id into the data layer whenever auth state changes. // The Dexie creating-hook reads this to auto-stamp `userId` on every record, // so module stores never need to know who the current user is. @@ -52,12 +59,21 @@ // hasAnyEncryption() flips to true once Phase 3 enables a pilot. if (userId && hasAnyEncryption()) { vaultClient.unlock().then((state) => { - if (state.status !== 'unlocked') { - console.warn('[mana] encryption vault unlock failed:', state); + if (state.status === 'unlocked') { + needsRecoveryCode = false; + return; } + if (state.status === 'awaiting-recovery-code') { + // Phase 9: server is in zero-knowledge mode. Show the + // modal that collects the user's recovery code. + needsRecoveryCode = true; + return; + } + console.warn('[mana] encryption vault unlock failed:', state); }); } else if (!userId) { vaultClient.lock(); + needsRecoveryCode = false; } }); @@ -93,3 +109,7 @@ + +{#if needsRecoveryCode} + (needsRecoveryCode = false)} /> +{/if}