diff --git a/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts index 628db6739..04cb10c70 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts @@ -192,6 +192,9 @@ describe('KeyProvider', () => { onChange() { return () => {}; } + async waitForKey() { + return null; + } })() ); expect(getActiveKey()).toBe(null); diff --git a/apps/mana/apps/web/src/lib/data/crypto/key-provider.ts b/apps/mana/apps/web/src/lib/data/crypto/key-provider.ts index 00cc2d4c4..fa7979dd5 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/key-provider.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/key-provider.ts @@ -34,6 +34,14 @@ export interface KeyProvider { /** Subscribe to lock/unlock transitions. Returns a dispose function. * Listeners fire only on STATE CHANGES, not on every getKey call. */ onChange(listener: (unlocked: boolean) => void): () => void; + + /** Resolves with the active key as soon as the vault is unlocked, or + * with `null` if the timeout expires first. Used by encryptRecord to + * ride out the boot-time race where the user clicks a mutation button + * while the layout's `vaultClient.unlock()` round-trip is still in + * flight. Implementations that can never unlock (NullKeyProvider) + * resolve immediately with `null`. */ + waitForKey(timeoutMs: number): Promise; } // ─── NullKeyProvider — default ───────────────────────────────── @@ -54,6 +62,11 @@ class NullKeyProvider implements KeyProvider { onChange(): () => void { return () => {}; } + async waitForKey(): Promise { + // Null provider can never unlock — don't make callers wait the + // full timeout for a guaranteed-null answer. + return null; + } } // ─── MemoryKeyProvider — Phase 3 production path ─────────────── @@ -107,6 +120,26 @@ export class MemoryKeyProvider implements KeyProvider { this.listeners.delete(listener); }; } + + waitForKey(timeoutMs: number): Promise { + if (this.key) return Promise.resolve(this.key); + return new Promise((resolve) => { + let settled = false; + const dispose = this.onChange((unlocked) => { + if (settled || !unlocked) return; + settled = true; + clearTimeout(timer); + dispose(); + resolve(this.key); + }); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + dispose(); + resolve(this.key); // null on miss + }, timeoutMs); + }); + } } // ─── Module-level active provider ────────────────────────────── @@ -128,6 +161,13 @@ export function getActiveKey(): CryptoKey | null { return _activeProvider.getKey(); } +/** Convenience: waits up to `timeoutMs` (default 2s) for the vault to + * unlock. Used by encryptRecord to ride out the boot-time race between + * a user click and the layout's async vault unlock round-trip. */ +export function waitForActiveKey(timeoutMs: number = 2000): Promise { + return _activeProvider.waitForKey(timeoutMs); +} + /** Convenience: synchronous lock check. */ export function isVaultUnlocked(): boolean { return _activeProvider.isUnlocked(); diff --git a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.test.ts b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.test.ts index 5af759d48..8e8a9406b 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.test.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.test.ts @@ -12,6 +12,7 @@ import { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from ' import { generateMasterKey, isEncrypted } from './aes'; import { MemoryKeyProvider, setKeyProvider } from './key-provider'; import * as registry from './registry'; +import { setCurrentUserId } from '../current-user'; let key: CryptoKey; let provider: MemoryKeyProvider; @@ -24,6 +25,12 @@ beforeEach(async () => { provider.setKey(key); setKeyProvider(provider); + // encryptRecord silently skips when no user is signed in (guest + // mode falls back to plaintext writes; guest-migration re-encrypts + // on login). Stamp a fake user id so the encryption path actually + // runs in these tests. + setCurrentUserId('test-user'); + // Pretend the notes table is enabled with title + body fields. vi.spyOn(registry, 'getEncryptedFields').mockImplementation((tableName: string) => { if (tableName === TEST_TABLE) return ['title', 'body']; @@ -34,6 +41,7 @@ beforeEach(async () => { afterEach(() => { vi.restoreAllMocks(); provider.setKey(null); + setCurrentUserId(null); }); describe('encryptRecord', () => { @@ -83,12 +91,20 @@ describe('encryptRecord', () => { }); it('throws VaultLockedError when no key is available', async () => { + // encryptRecord waits ~2s for the boot-time unlock race before + // giving up. Use fake timers so this test doesn't actually idle + // for two seconds — flush the timer manually after kicking off + // the call. + vi.useFakeTimers(); provider.setKey(null); const record = { id: 'n', title: 'secret', body: 'also secret' }; - await expect(encryptRecord(TEST_TABLE, record)).rejects.toThrow(VaultLockedError); + const promise = encryptRecord(TEST_TABLE, record); + await vi.advanceTimersByTimeAsync(2500); + await expect(promise).rejects.toThrow(VaultLockedError); // Record was not partially mutated expect(record.title).toBe('secret'); expect(record.body).toBe('also secret'); + vi.useRealTimers(); }); it('does not throw when the vault is locked but no fields need encryption', async () => { diff --git a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts index 140e0afdd..cb5e236a2 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts @@ -34,8 +34,9 @@ */ import { wrapValue, unwrapValue, isEncrypted } from './aes'; -import { getActiveKey, isVaultUnlocked } from './key-provider'; +import { getActiveKey, isVaultUnlocked, waitForActiveKey } from './key-provider'; import { getEncryptedFields } from './registry'; +import { getCurrentUserId } from '../current-user'; /** Thrown by encryptRecord when no key is available. Module stores * catch this to surface "vault locked" UI. */ @@ -80,7 +81,23 @@ export async function encryptRecord(tableName: string, record: } if (todo.length === 0) return record; - const key = getActiveKey(); + // Guest mode: there is no auth token, so the server vault is + // unreachable by definition. Falling back to plaintext keeps the + // app usable for anonymous local-first writes — guestMigration.ts + // will encrypt these records as part of the guest → user re-stamp + // when the user eventually signs in. The compromise is documented + // in the data-layer audit; the alternative (refusing the write) + // hides the entire app behind a sign-up wall. + if (getCurrentUserId() === null) return record; + + // Boot-time race: the layout's `vaultClient.unlock()` runs in the + // same tick as authStore.initialize(), so the very first user + // mutation can land before the network round-trip finishes. Wait a + // short window for the provider to flip before we give up — this + // converts a near-miss race into a transparent ~ms delay instead of + // a thrown VaultLockedError that the UI silently swallows. + let key = getActiveKey(); + if (!key) key = await waitForActiveKey(2000); if (!key) throw new VaultLockedError(tableName); for (const field of todo) { diff --git a/apps/mana/apps/web/src/lib/data/guest-migration.ts b/apps/mana/apps/web/src/lib/data/guest-migration.ts index b2df98d02..c9eabfe95 100644 --- a/apps/mana/apps/web/src/lib/data/guest-migration.ts +++ b/apps/mana/apps/web/src/lib/data/guest-migration.ts @@ -20,10 +20,24 @@ * Deleting in guest mode is safe because nothing was ever pushed to the * server: `_pendingChanges` is cleared as part of the migration too, so the * delete is purely local and never reaches the sync layer. + * + * Encryption catch-up: while in guest mode, encryptRecord skips silently + * (no master key available, no auth token to fetch one). Records on + * encrypted tables therefore live as PLAINTEXT in IndexedDB until this + * migration runs. After login + vault unlock we walk the same set of + * records and re-encrypt the registry fields before re-inserting, so + * the post-migration state is indistinguishable from "user signed up + * first, then typed everything". The migration awaits the vault for up + * to 10 s — if it never opens we abort the migration and leave the + * guest data in place rather than re-inserting plaintext under the + * real user id. */ import { db, SYNC_APP_MAP, FIELD_TIMESTAMPS_KEY } from './database'; import { GUEST_USER_ID } from './current-user'; +import { encryptRecord } from './crypto/record-helpers'; +import { waitForActiveKey } from './crypto/key-provider'; +import { getEncryptedFields } from './crypto/registry'; export interface GuestMigrationResult { migratedRecords: number; @@ -41,6 +55,19 @@ export async function migrateGuestDataToUser(newUserId: string): Promise { for (const oldRecord of guestRecords) { const record = oldRecord as Record; @@ -78,6 +114,18 @@ export async function migrateGuestDataToUser(newUserId: string): Promise[0]) { + try { + await dayLogsStore.logDay(payload); + } catch (err) { + if (err instanceof VaultLockedError) { + toast.error('Vault gesperrt — bitte unter Einstellungen → Sicherheit entsperren.'); + return; + } + throw err; + } + } + async function setFlow(flow: Flow) { - await dayLogsStore.logDay({ logDate: editingDate, flow }); + await safeLogDay({ logDate: editingDate, flow }); } async function setMood(mood: Mood) { const next = selectedMood === mood ? null : mood; - await dayLogsStore.logDay({ logDate: editingDate, mood: next }); + await safeLogDay({ logDate: editingDate, mood: next }); } async function toggleSymptom(id: string) { const has = selectedSymptoms.includes(id); const next = has ? selectedSymptoms.filter((s) => s !== id) : [...selectedSymptoms, id]; - await dayLogsStore.logDay({ logDate: editingDate, symptoms: next }); + await safeLogDay({ logDate: editingDate, symptoms: next }); } async function saveTemperature() { const num = parseFloat(temperature); - await dayLogsStore.logDay({ + await safeLogDay({ logDate: editingDate, temperature: Number.isFinite(num) ? num : null, }); } async function saveNotes() { - await dayLogsStore.logDay({ logDate: editingDate, notes: notesText.trim() || null }); + await safeLogDay({ logDate: editingDate, notes: notesText.trim() || null }); } async function deleteEditingLog() { @@ -138,7 +156,7 @@ async function startPeriodToday() { await cyclesStore.createCycle({ startDate: todayIso }); - await dayLogsStore.logDay({ logDate: todayIso, flow: 'medium' }); + await safeLogDay({ logDate: todayIso, flow: 'medium' }); backToToday(); }