diff --git a/apps/mana/apps/web/src/lib/data/crypto/index.ts b/apps/mana/apps/web/src/lib/data/crypto/index.ts index 6798a5caa..1f3961012 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/index.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/index.ts @@ -61,6 +61,8 @@ export { type VaultClient, type VaultClientOptions, type VaultUnlockState, + type VaultStatus, + type RecoveryCodeSetupResult, createVaultClient, } from './vault-client'; diff --git a/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts b/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts index 2269711c6..6f8849c27 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts @@ -83,6 +83,16 @@ export interface RecoveryCodeSetupResult { formattedCode: string; } +/** Snapshot of the server-side vault status. Returned by getStatus() so + * the UI can render the right section without triggering a full unwrap. */ +export interface VaultStatus { + vaultExists: boolean; + hasRecoveryWrap: boolean; + zeroKnowledge: boolean; + /** ISO timestamp string, or null if never set. */ + recoverySetAt: string | null; +} + export interface VaultClient { /** Unlocks the in-memory key provider by fetching from the server. * On first call per device, automatically initialises the vault. @@ -145,6 +155,13 @@ export interface VaultClient { * (wrong code or tampered blob — the caller maps both to "wrong * recovery code, try again"). */ unlockWithRecoveryCode(code: string): Promise; + + /** Cheap server-side metadata fetch. Returns the vault row's + * high-level state (exists, has recovery wrap, zero-knowledge + * active, recovery set timestamp) so the UI can render the + * right section without triggering a full unwrap. Used by the + * settings page on mount to hydrate after a reload. */ + getStatus(): Promise; } /** @@ -600,6 +617,22 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { return state; } + async function getStatus(): Promise { + const token = await getToken(); + if (!token) throw new Error('no auth token available'); + // Status is a cheap metadata read — no retry loop, no audit + // noise. If it fails, we let the caller decide whether to fall + // back to "vault not initialised" defaults or surface the error. + const res = await fetch(`${authUrl}/api/v1/me/encryption-vault/status`, { + method: 'GET', + ...authHeaders(token), + }); + if (!res.ok) { + throw new Error(`vault status fetch failed: ${res.status}`); + } + return (await res.json()) as VaultStatus; + } + return { unlock, lock, @@ -611,6 +644,7 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { enableZeroKnowledge, disableZeroKnowledge, unlockWithRecoveryCode, + getStatus, }; } 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 69555a6e6..0a94c079e 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 @@ -57,6 +57,11 @@ let zkBusy = $state(false); let confirmDisableZk = $state(false); let confirmClearRecovery = $state(false); + /** True iff a recovery wrap is stored on the server, regardless of + * whether ZK is currently active. Hydrated from getStatus() on + * mount so the UI can show "Recovery-Code entfernen" without + * walking through the setup flow again. */ + let hasRecoveryWrap = $state(false); async function handleSetupRecoveryCode() { zkError = null; @@ -65,6 +70,7 @@ const result = await vaultClient.setupRecoveryCode(); generatedCode = result.formattedCode; zkSetupStep = 'generated'; + hasRecoveryWrap = true; } catch (e) { zkError = (e as Error).message; } finally { @@ -133,6 +139,7 @@ toast.success('Recovery-Code entfernt'); confirmClearRecovery = false; zkSetupStep = 'idle'; + hasRecoveryWrap = false; } catch (e) { zkError = (e as Error).message; } finally { @@ -167,6 +174,25 @@ const next = vaultClient.getState(); if (next.status !== vaultState.status) vaultState = next; }, 1000); + + // Hydrate the ZK section from the server's actual vault state + // so a reload after enabling zero-knowledge doesn't drop the + // user back into the setup flow. Best-effort: failures leave + // zkSetupStep at 'idle' which is the safe default. + void vaultClient + .getStatus() + .then((status) => { + hasRecoveryWrap = status.hasRecoveryWrap; + if (status.zeroKnowledge) { + zkSetupStep = 'enabled'; + } + }) + .catch(() => { + // Status fetch failed (network, auth, server). Stay on the + // idle default — the user can still run the setup flow, + // and the server-side error handling will catch any + // inconsistencies. + }); }); onDestroy(() => { @@ -354,16 +380,68 @@ {/if} {#if zkSetupStep === 'idle'} -
- -
+ {#if hasRecoveryWrap} +
+ ℹ️ Du hast bereits einen Recovery-Code gespeichert, aber Zero-Knowledge ist noch nicht + aktiv. Du kannst direkt aktivieren oder den Code zurücksetzen. +
+
+ + + {#if !confirmClearRecovery} + + {:else} + + + {/if} +
+ {:else} +
+ +
+ {/if} {/if} {#if zkSetupStep === 'generated' && generatedCode} @@ -679,6 +757,16 @@ color: rgb(185, 28, 28); } + .zk-info { + margin-top: 0.75rem; + padding: 0.75rem 1rem; + background: rgba(245, 158, 11, 0.08); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 0.5rem; + font-size: 0.875rem; + color: rgb(180, 83, 9); + } + .zk-actions { display: flex; flex-wrap: wrap; diff --git a/services/mana-auth/src/routes/encryption-vault.ts b/services/mana-auth/src/routes/encryption-vault.ts index 9e67da780..acc8c5a4c 100644 --- a/services/mana-auth/src/routes/encryption-vault.ts +++ b/services/mana-auth/src/routes/encryption-vault.ts @@ -40,6 +40,18 @@ type AppContext = Context<{ Variables: { user: AuthUser } }>; export function createEncryptionVaultRoutes(vaultService: EncryptionVaultService) { const app = new Hono<{ Variables: { user: AuthUser } }>(); + // ─── GET /status ───────────────────────────────────────── + // Cheap metadata read used by the settings page to hydrate the UI + // after a reload. No decryption, no audit logging — pure SELECT. + // Returns the same shape regardless of whether the vault row + // exists yet, so the client can avoid a 404 dance for the + // "vault not initialised" case. + app.get('/status', async (c) => { + const user = c.get('user'); + const status = await vaultService.getStatus(user.userId); + return c.json(status); + }); + // ─── POST /init ────────────────────────────────────────── // Idempotent. First call creates a vault row; subsequent calls // return the existing master key. The client uses this on first diff --git a/services/mana-auth/src/services/encryption-vault/index.ts b/services/mana-auth/src/services/encryption-vault/index.ts index ca0f6912d..0f9d95466 100644 --- a/services/mana-auth/src/services/encryption-vault/index.ts +++ b/services/mana-auth/src/services/encryption-vault/index.ts @@ -74,6 +74,25 @@ export interface RecoveryWrapInput { recoveryIv: string; } +/** Snapshot of the vault row's high-level state, exposed via + * GET /api/v1/me/encryption-vault/status. The settings page reads + * this on mount to render the right UI section without having to + * trigger a full unwrap of the master key. Cheap (single SELECT, + * no decrypt). */ +export interface VaultStatus { + /** True iff a vault row exists for this user. */ + vaultExists: boolean; + /** True iff the user has a recovery wrap stored. Independent of + * whether zero-knowledge is currently active. */ + hasRecoveryWrap: boolean; + /** True iff zero-knowledge mode is active (server has no usable + * KEK wrap, recovery wrap is the only way to unlock). */ + zeroKnowledge: boolean; + /** ISO timestamp of when the recovery wrap was last set, or null + * if never set. Useful for "last backup" hints in the UI. */ + recoverySetAt: string | null; +} + export class EncryptionVaultService { constructor(private db: Database) {} @@ -262,6 +281,46 @@ export class EncryptionVaultService { }); } + /** + * Cheap status read for UI rendering. No decryption, no audit + * row (this gets called on every settings page mount and we don't + * want to flood the audit log with read-only metadata fetches). + * + * Returns sane defaults when the vault row doesn't exist yet — + * the page can render "vault not initialised" without needing a + * separate code path. + */ + async getStatus(userId: string): Promise { + return this.withUserScope(userId, async (tx) => { + const rows = await tx + .select({ + recoveryWrappedMk: encryptionVaults.recoveryWrappedMk, + recoverySetAt: encryptionVaults.recoverySetAt, + zeroKnowledge: encryptionVaults.zeroKnowledge, + }) + .from(encryptionVaults) + .where(eq(encryptionVaults.userId, userId)) + .limit(1); + + if (rows.length === 0) { + return { + vaultExists: false, + hasRecoveryWrap: false, + zeroKnowledge: false, + recoverySetAt: null, + }; + } + + const row = rows[0]; + return { + vaultExists: true, + hasRecoveryWrap: row.recoveryWrappedMk !== null, + zeroKnowledge: row.zeroKnowledge, + recoverySetAt: row.recoverySetAt ? row.recoverySetAt.toISOString() : null, + }; + }); + } + // ─── Phase 9: Recovery Wrap + Zero-Knowledge ───────────── /**