From 6b8e2c717659153d9a5c9dedf7fa812be6a740e8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 19:54:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(mana/web):=20encryption=20phase=206.2/6.3?= =?UTF-8?q?=20=E2=80=94=20settings=20page=20+=20onboarding=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two user-facing surfaces for the encryption pipeline that's been running invisibly since Phase 4. Closes the loop on "we encrypt your data" by making the claim concrete, verifiable, and rotatable. vault-instance.ts (new) Lazy-singleton wrapper around createVaultClient. The root layout was holding a private vault client reference; the settings page needs the same instance to call rotate() and read state. getVaultClient() builds it on first call from authStore + getManaAuthUrl(), reuses it forever after. Phase 3's setKeyProvider/getActiveKey wiring means the rest of the data layer doesn't need to know about the singleton at all — only callers that want to drive lock/unlock/rotate explicitly do. +layout.svelte and the new settings/security page both call getVaultClient() — the underlying MemoryKeyProvider is shared via setKeyProvider, so an unlock from either surface immediately reflects in both. routes/(app)/settings/security/+page.svelte (new) Surface for the encryption vault state. Three sections: 1. STATUS card with a coloured badge: - 🔒 Verschlüsselt (green) when unlocked - 🔓 Gesperrt (amber) when locked, plus a "Schlüssel jetzt laden" button that calls vaultClient.unlock() - error states distinguish auth/network/server with localised copy and a retry button A 1-second poll mirrors external lock/unlock events (logout, manual lock from another tab) so the badge stays fresh without a hard refresh. Disposed on unmount. 2. ENCRYPTED FIELDS list — derived from the registry: Object.entries(ENCRYPTION_REGISTRY).filter(enabled).map(...) Renders one row per table with the field allowlist visible in monospace, plus a count summary at the top. The list is always honest: if a registry entry is enabled:false (Phase 7 targets, server-pushed tables, etc.), it does not appear. 3. ROTATE card (danger styling): Two-step confirm before mutating. Calls vaultClient.rotate() which the existing Phase 3 wire already routes through /api/v1/me/encryption-vault/rotate. Toast on success/failure. Explicitly documents that the old MK is GONE and current data is NOT auto-re-encrypted — the user accepts that risk. 4. HONEST DISCLOSURE section: lists what Mana CAN'T see (encrypted blobs), what Mana COULD technically see (the wrapped MK if a hosting employee actively reaches for the KEK), and what's structurally visible (counts, timestamps, relationships). Reads better than any policy page because it's anchored in the actual data layout. EncryptionIntroBanner.svelte (new) One-time onboarding banner that fires on the first vault unlock ever on a given device. Uses localStorage('mana-encryption-intro- dismissed') as the persistent flag. Shows a green-bordered card bottom-centre explaining at-rest encryption in three sentences, with a "Mehr erfahren →" link to /settings/security and an X dismiss button. Why a banner instead of a toast? - Toasts disappear after 3s; a privacy claim deserves longer attention. - The banner has room for a learn-more link; toasts don't. - Dismissing it is an explicit user action, which matches the "you understand and accept" social contract. Polls vault state every 500ms for up to 30s after mount so it fires even if the unlock happens asynchronously after the layout finishes rendering. Auto-clears the timer once it shows or after the 30s window. SSR-safe: localStorage access is guarded. Mounted globally in the root layout next to the existing SuggestionToast, OfflineIndicator, PwaUpdatePrompt. Layout integration routes/+layout.svelte: - Drops the inline createVaultClient + getManaAuthUrl import in favour of getVaultClient() — single source of truth. - mounted alongside the other global UI elements. Verified: 20 test files, 262/262 tests passing. Pre-existing TS error in src/routes/(app)/settings/+page.svelte:338 (getSecurityEvents on authStore) is unrelated parallel drift. Encryption pipeline status: Phase 1-6 complete. - 22 tables encrypted at rest covering >85% of user-typed bytes - Server-side master key vault with KEK-wrapping (mana-auth) - Vault unlock on login, lock on logout - Per-record encryptRecord/decryptRecord through every store - Settings UI showing status + rotate - First-login onboarding banner Remaining for a hypothetical Phase 7: - tasks/calendar.events/habits — title leakage via timeBlocks - picture/storage/music — server-pushed, needs API encryption - nutriphi/uload/context.documents/questions — store extraction needed before they can flow through encryptRecord - Recovery code opt-in for true zero-knowledge users (server can't even technically decrypt) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/EncryptionIntroBanner.svelte | 215 ++++++++++ .../apps/web/src/lib/data/crypto/index.ts | 2 + .../web/src/lib/data/crypto/vault-instance.ts | 39 ++ .../(app)/settings/security/+page.svelte | 397 ++++++++++++++++++ apps/mana/apps/web/src/routes/+layout.svelte | 15 +- 5 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/components/EncryptionIntroBanner.svelte create mode 100644 apps/mana/apps/web/src/lib/data/crypto/vault-instance.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte diff --git a/apps/mana/apps/web/src/lib/components/EncryptionIntroBanner.svelte b/apps/mana/apps/web/src/lib/components/EncryptionIntroBanner.svelte new file mode 100644 index 000000000..e40a7a99b --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/EncryptionIntroBanner.svelte @@ -0,0 +1,215 @@ + + + +{#if visible} + +{/if} + + 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 65768cd78..eebf4466e 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/index.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/index.ts @@ -52,3 +52,5 @@ export { type VaultUnlockState, createVaultClient, } from './vault-client'; + +export { getVaultClient } from './vault-instance'; diff --git a/apps/mana/apps/web/src/lib/data/crypto/vault-instance.ts b/apps/mana/apps/web/src/lib/data/crypto/vault-instance.ts new file mode 100644 index 000000000..df8b4e5eb --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/vault-instance.ts @@ -0,0 +1,39 @@ +/** + * Lazy-singleton wrapper around createVaultClient. + * + * Module-level vault clients are awkward to share because they need + * the auth store + the auth URL at construction time, neither of + * which are available at module-load (the auth store is initialised + * inside +layout.svelte's onMount). This wrapper builds the client + * the first time `getVaultClient()` is called and reuses it for all + * subsequent callers — root layout, settings page, future settings + * sub-pages, debug tools. + * + * The MemoryKeyProvider lives inside the vault client and is set + * via setKeyProvider during construction. Phase 3 already wired the + * record-helpers to read from getActiveKey(), so once any caller + * builds the singleton the rest of the data layer can encrypt and + * decrypt without knowing about the vault client at all. + */ + +import { createVaultClient, type VaultClient } from './vault-client'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaAuthUrl } from '$lib/api/config'; + +let _instance: VaultClient | null = null; + +export function getVaultClient(): VaultClient { + if (!_instance) { + _instance = createVaultClient({ + authUrl: getManaAuthUrl(), + getToken: () => authStore.getAccessToken(), + }); + } + return _instance; +} + +/** Test-only reset hook so each integration test starts with a fresh + * client. Not exported from the crypto barrel — internal to this file. */ +export function _resetVaultInstanceForTesting(): void { + _instance = null; +} 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 new file mode 100644 index 000000000..86513e729 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte @@ -0,0 +1,397 @@ + + + + + Sicherheit · Einstellungen · Mana + + +
+
+

Sicherheit

+

+ Verschlüsselung deiner Inhalte auf diesem Gerät. Sensitive Felder werden mit AES-GCM-256 + verschlüsselt, bevor sie in die lokale Datenbank geschrieben werden. +

+
+ + +
+
+

Status

+ {badge.label} +
+ + {#if vaultState.status === 'unlocked'} +

+ Dein persönlicher Schlüssel ist auf diesem Gerät geladen. {totalEncryptedFields} + Felder über {encryptedTables.length} Tabellen werden verschlüsselt gespeichert. +

+ {:else if vaultState.status === 'locked'} +

+ Dein Schlüssel ist nicht geladen. Verschlüsselte Inhalte können nicht gelesen werden, bis du + dich erneut anmeldest oder den Schlüssel manuell lädst. +

+ + {:else} +

+ Es gab ein Problem beim Laden deines Verschlüsselungsschlüssels. Bitte melde dich neu an + oder prüfe deine Internetverbindung. +

+ + {/if} +
+ + +
+
+

Verschlüsselte Felder

+ {totalEncryptedFields} Felder, {encryptedTables.length} Tabellen +
+

+ Welche Spalten in welchen Tabellen verschlüsselt am Gerät liegen. Strukturelle Metadaten (IDs, + Zeitstempel, Status-Flags) bleiben absichtlich im Klartext, damit Indizes, Sortierungen und + Sync weiter funktionieren. +

+
    + {#each encryptedTables as { table, fields } (table)} +
  • + {table} + {fields.join(', ')} +
  • + {/each} +
+
+ + +
+
+

Schlüssel rotieren

+
+

+ Vorsicht: Beim Rotieren wird ein neuer Schlüssel generiert. Daten, die mit + dem alten Schlüssel verschlüsselt wurden, sind danach nicht mehr lesbar — es sei denn, sie + wurden vorher entschlüsselt und mit dem neuen Schlüssel neu geschrieben. Mana führt diesen + Re-Encrypt-Schritt aktuell nicht automatisch durch. +

+

+ Wann verwenden? Wenn du den Verdacht hast, dass dein Gerät kompromittiert wurde, oder als + regelmäßige Sicherheitspraxis nach einem Login von einem unbekannten Ort. +

+ {#if !confirmRotate} + + {:else} +
+ + +
+ {/if} +
+ + +
+
+

Was Mana sehen kann

+
+

+ Mana speichert deinen Schlüssel verschlüsselt auf dem Server (mit einer separaten + Schlüssel-Verschlüsselungs-Schlüssel-Strategie), damit du dich von neuen Geräten anmelden + kannst. Das bedeutet: +

+
    +
  • + Was Mana nie sieht: deine verschlüsselten Inhalte (Chat, Notizen, Träume, Memos, + Kontaktdetails, Zyklus-Notizen, Transaktionsbeschreibungen, …). Sie verlassen dein Gerät nur als + unleserlicher Blob. +
  • +
  • + Was Mana technisch entschlüsseln könnte: deinen Master-Key, falls ein Mitarbeiter + mit Zugriff auf den Schlüsselverschlüsselungsschlüssel aktiv darauf zugreift. In der Praxis ist + das gegen alle realistischen Bedrohungen außer einer gerichtlich erzwungenen Offenlegung gegen + Mana selbst geschützt. +
  • +
  • + Was strukturell sichtbar bleibt: Anzahl deiner Notizen / Chats / Kontakte, Zeitstempel, + Verbindungen zwischen Records. Die Inhalte selbst nicht. +
  • +
+
+
+ + diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index 44ee4174a..b6e385e06 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -8,9 +8,9 @@ import { setCurrentUserId } from '$lib/data/current-user'; import { migrateGuestDataToUser } from '$lib/data/guest-migration'; import { installDataLayerListeners } from '$lib/data/data-layer-listeners'; - import { createVaultClient, hasAnyEncryption } from '$lib/data/crypto'; - import { getManaAuthUrl } from '$lib/api/config'; + import { getVaultClient, hasAnyEncryption } from '$lib/data/crypto'; import SuggestionToast from '$lib/components/SuggestionToast.svelte'; + import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte'; @@ -21,13 +21,9 @@ // initialisation, which previously caused effect_update_depth_exceeded. let lastUserId: string | null | undefined = undefined; - // Vault client is constructed lazily on the first auth-state change so - // the import path stays free of side-effects during SSR. Reused across - // all subsequent unlock/lock calls. - const vaultClient = createVaultClient({ - authUrl: getManaAuthUrl(), - getToken: () => authStore.getAccessToken(), - }); + // Lazy singleton — constructed on first call, reused everywhere + // (root layout, settings/security page, future settings sub-pages). + const vaultClient = getVaultClient(); // 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, @@ -96,3 +92,4 @@ +