mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
78d949d051
commit
a48b2d5841
2 changed files with 258 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue