mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(settings): phase 9 milestone 4 — zero-knowledge UI section
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) <noreply@anthropic.com>
This commit is contained in:
parent
6de01937cf
commit
56312ff579
1 changed files with 363 additions and 0 deletions
|
|
@ -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<string | null>(null);
|
||||
let confirmCodeInput = $state('');
|
||||
let zkError = $state<string | null>(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}
|
||||
</section>
|
||||
|
||||
<!-- Phase 9: Recovery code + Zero-knowledge mode -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Zero-Knowledge-Modus</h2>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Optional, fortgeschritten.</strong> Im Zero-Knowledge-Modus speichert Mana deinen
|
||||
Schlüssel <em>nur noch in einer Form, die wir selbst nicht entschlüsseln können</em>. Du
|
||||
brauchst dann beim Login von einem neuen Gerät deinen Recovery-Code, um deine Daten
|
||||
freizuschalten.
|
||||
</p>
|
||||
<p class="muted">
|
||||
<strong>Vorteil:</strong> selbst ein Mana-Mitarbeiter mit Vollzugriff auf den Server kann
|
||||
deine Inhalte nicht mehr lesen. <strong>Risiko:</strong> wenn du den Recovery-Code verlierst, sind
|
||||
deine Daten unwiderruflich weg — wir haben dann keinen Backup-Schlüssel mehr.
|
||||
</p>
|
||||
|
||||
{#if zkError}
|
||||
<div class="zk-error">⚠️ {zkError}</div>
|
||||
{/if}
|
||||
|
||||
{#if zkSetupStep === 'idle'}
|
||||
<div class="zk-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
disabled={vaultState.status !== 'unlocked' || zkBusy}
|
||||
onclick={handleSetupRecoveryCode}
|
||||
>
|
||||
{zkBusy ? 'Generiere …' : 'Recovery-Code einrichten'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if zkSetupStep === 'generated' && generatedCode}
|
||||
<div class="zk-step">
|
||||
<h3>Schritt 1 von 3 — Code sicher aufschreiben</h3>
|
||||
<p>
|
||||
Speichere diesen Code an einem sicheren Ort (Passwort-Manager, ausgedruckt im Tresor, …).
|
||||
<strong>Wir zeigen ihn dir nur ein einziges Mal.</strong>
|
||||
</p>
|
||||
<div class="recovery-code">{generatedCode}</div>
|
||||
<div class="zk-actions">
|
||||
<button class="btn" type="button" onclick={handleCopyCode}>📋 Kopieren</button>
|
||||
<button class="btn btn-primary" type="button" onclick={handleStartConfirm}>
|
||||
Ich habe den Code gesichert →
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" onclick={handleResetSetup}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if zkSetupStep === 'confirming'}
|
||||
<div class="zk-step">
|
||||
<h3>Schritt 2 von 3 — Code zurück eintippen</h3>
|
||||
<p>
|
||||
Tippe (oder paste) den Code, den du gerade gespeichert hast. So stellen wir sicher, dass
|
||||
der Backup wirklich vollständig ist.
|
||||
</p>
|
||||
<input
|
||||
class="recovery-input"
|
||||
type="text"
|
||||
bind:value={confirmCodeInput}
|
||||
placeholder="1A2B-3C4D-5E6F-..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<div class="zk-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
disabled={!confirmCodeInput.trim()}
|
||||
onclick={handleConfirmCode}
|
||||
>
|
||||
Code bestätigen
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" onclick={handleResetSetup}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if zkSetupStep === 'enabling'}
|
||||
<div class="zk-step">
|
||||
<h3>Schritt 3 von 3 — Zero-Knowledge-Modus aktivieren</h3>
|
||||
<p>
|
||||
Wenn du jetzt aktivierst, löscht der Server seine Kopie deines Schlüssels. Ab sofort
|
||||
kannst du <strong>nur noch mit dem Recovery-Code</strong> auf deine verschlüsselten Daten zugreifen.
|
||||
</p>
|
||||
<p class="warn">
|
||||
⚠️ Diese Aktion ist nicht rückgängig zu machen ohne den Recovery-Code. Wenn du deinen Code
|
||||
verlegst, sind deine Inhalte verloren.
|
||||
</p>
|
||||
<div class="zk-actions">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
disabled={zkBusy}
|
||||
onclick={handleEnableZeroKnowledge}
|
||||
>
|
||||
{zkBusy ? 'Aktiviere …' : 'Ja, Zero-Knowledge-Modus aktivieren'}
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" disabled={zkBusy} onclick={handleResetSetup}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if zkSetupStep === 'enabled'}
|
||||
<div class="zk-step">
|
||||
<h3>✅ Zero-Knowledge-Modus aktiv</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{#if !confirmDisableZk}
|
||||
<div class="zk-actions">
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
disabled={vaultState.status !== 'unlocked' || zkBusy}
|
||||
onclick={() => (confirmDisableZk = true)}
|
||||
>
|
||||
Zero-Knowledge-Modus wieder deaktivieren …
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
<div class="zk-actions">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
disabled={zkBusy}
|
||||
onclick={handleDisableZeroKnowledge}
|
||||
>
|
||||
{zkBusy ? 'Deaktiviere …' : 'Ja, deaktivieren'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
disabled={zkBusy}
|
||||
onclick={() => (confirmDisableZk = false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Honest disclosure -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue