feat(layout): lock-screen recovery code unlock modal

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 23:24:32 +02:00
parent 78d949d051
commit a48b2d5841
2 changed files with 258 additions and 2 deletions

View file

@ -0,0 +1,236 @@
<!--
Recovery Code Unlock Modal — Phase 9.
Mounts when the vault client is in `awaiting-recovery-code` state
(server returned a recovery blob from GET /key after the user
enabled zero-knowledge mode). Collects the user's recovery code,
calls vaultClient.unlockWithRecoveryCode(), dismisses on success.
Failure modes:
- RecoveryCodeFormatError → format hint inline
- Generic unwrap failure → "wrong code, try again" (we don't
distinguish wrong-code vs corrupted-blob; the recovery module
intentionally fails uniformly so an attacker gets no signal)
The modal is non-dismissable — there's no "cancel" path because
without the recovery code the app can't read encrypted data and
would just sit in a broken state. The user can sign out from the
header instead.
-->
<script lang="ts">
import { getVaultClient, RecoveryCodeFormatError } from '$lib/data/crypto';
interface Props {
/** Called once the unlock has succeeded so the parent can hide
* the modal. */
onUnlocked: () => void;
}
let { onUnlocked }: Props = $props();
const vaultClient = getVaultClient();
let codeInput = $state('');
let error = $state<string | null>(null);
let busy = $state(false);
async function handleSubmit(e?: Event) {
e?.preventDefault();
if (!codeInput.trim() || busy) return;
error = null;
busy = true;
try {
const result = await vaultClient.unlockWithRecoveryCode(codeInput);
if (result.status === 'unlocked') {
codeInput = '';
onUnlocked();
return;
}
// Should not happen — unlockWithRecoveryCode either throws or
// returns 'unlocked'. Defensive fallback.
error = 'Unbekannter Fehler beim Entsperren.';
} catch (e) {
if (e instanceof RecoveryCodeFormatError) {
error =
'Der Code hat das falsche Format. Erwartet: 16 Gruppen à 4 Hex-Zeichen, ' +
'getrennt durch Bindestriche (z.B. 1A2B-3C4D-...).';
} else {
// Either wrong code or corrupted blob — surface uniformly so
// an attacker can't distinguish the two cases.
error = 'Recovery-Code falsch. Bitte prüfe deine Eingabe und versuche es erneut.';
}
} finally {
busy = false;
}
}
</script>
<div class="modal-backdrop">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="recovery-modal-title">
<header>
<h2 id="recovery-modal-title">🔑 Recovery-Code erforderlich</h2>
</header>
<p>
Du hast den Zero-Knowledge-Modus aktiviert. Damit dein verschlüsselter Inhalt freigegeben
werden kann, brauchst du deinen Recovery-Code — den Code, den du beim Setup gespeichert hast.
</p>
<form onsubmit={handleSubmit}>
<!-- svelte-ignore a11y_autofocus -->
<input
class="recovery-input"
type="text"
bind:value={codeInput}
placeholder="1A2B-3C4D-5E6F-..."
autocomplete="off"
spellcheck="false"
autofocus
disabled={busy}
/>
{#if error}
<div class="error">⚠️ {error}</div>
{/if}
<div class="actions">
<button class="btn btn-primary" type="submit" disabled={busy || !codeInput.trim()}>
{busy ? 'Entsperre …' : 'Vault entsperren'}
</button>
</div>
</form>
<p class="help">
Du hast keinen Code mehr? Dann sind deine verschlüsselten Daten unwiderruflich verloren —
melde dich ab und lege ein neues Konto an, oder kontaktiere den Support für Hilfe beim
Account-Reset (deine Daten bleiben dabei verloren).
</p>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
backdrop-filter: blur(4px);
}
.modal {
max-width: 32rem;
width: 100%;
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
}
header {
margin-bottom: 1rem;
}
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
p {
margin: 0.75rem 0;
font-size: 0.9rem;
line-height: 1.5;
}
.recovery-input {
display: block;
width: 100%;
margin: 1rem 0 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.5rem;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 1rem;
background: var(--surface-muted, #f9fafb);
text-align: center;
letter-spacing: 0.05em;
}
.recovery-input:focus {
outline: 2px solid var(--primary, #6366f1);
outline-offset: 1px;
}
.recovery-input:disabled {
opacity: 0.6;
}
.error {
margin: 0.75rem 0;
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.85rem;
color: rgb(185, 28, 28);
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff);
font-size: 0.9rem;
cursor: pointer;
font-weight: 500;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary, #6366f1);
color: white;
border-color: transparent;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-dark, #4f46e5);
}
.help {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border, #e5e7eb);
font-size: 0.8rem;
color: var(--text-secondary, #6b7280);
}
@media (prefers-color-scheme: dark) {
.modal {
background: var(--surface, #1f2937);
border-color: var(--border, #374151);
}
.recovery-input {
background: var(--surface-muted, #111827);
border-color: var(--border, #374151);
color: #f3f4f6;
}
.help {
border-color: var(--border, #374151);
}
}
</style>

View file

@ -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 @@
<OfflineIndicator />
<PwaUpdatePrompt />
<EncryptionIntroBanner />
{#if needsRecoveryCode}
<RecoveryCodeUnlockModal onUnlocked={() => (needsRecoveryCode = false)} />
{/if}