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 fa7979dd5..41242a3b4 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 @@ -88,6 +88,12 @@ export class MemoryKeyProvider implements KeyProvider { this.key = key; const nowUnlocked = key !== null; + if (wasUnlocked !== nowUnlocked) { + console.info( + `[mana-crypto:key] MemoryKeyProvider: vault ${nowUnlocked ? 'UNLOCKED' : 'LOCKED'}` + ); + } + if (typeof window !== 'undefined') { if (nowUnlocked) sessionStorage.setItem('mana-vault-unlocked', '1'); else sessionStorage.removeItem('mana-vault-unlocked'); @@ -123,6 +129,9 @@ export class MemoryKeyProvider implements KeyProvider { waitForKey(timeoutMs: number): Promise { if (this.key) return Promise.resolve(this.key); + console.debug( + `[mana-crypto:key] waitForKey — waiting up to ${timeoutMs}ms for vault unlock...` + ); return new Promise((resolve) => { let settled = false; const dispose = this.onChange((unlocked) => { @@ -130,12 +139,16 @@ export class MemoryKeyProvider implements KeyProvider { settled = true; clearTimeout(timer); dispose(); + console.debug('[mana-crypto:key] waitForKey — vault unlocked during wait'); resolve(this.key); }); const timer = setTimeout(() => { if (settled) return; settled = true; dispose(); + console.warn( + `[mana-crypto:key] waitForKey — timed out after ${timeoutMs}ms, vault still locked` + ); resolve(this.key); // null on miss }, timeoutMs); }); @@ -148,7 +161,9 @@ let _activeProvider: KeyProvider = new NullKeyProvider(); /** Replace the active provider. Called once at app boot in Phase 3. */ export function setKeyProvider(provider: KeyProvider): void { + const prev = _activeProvider.constructor.name; _activeProvider = provider; + console.info(`[mana-crypto:key] setKeyProvider: ${prev} → ${provider.constructor.name}`); } /** Returns the currently-installed provider. */ 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 cb5e236a2..b6fccb345 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 @@ -88,7 +88,10 @@ export async function encryptRecord(tableName: string, record: // 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; + if (getCurrentUserId() === null) { + console.debug(`[mana-crypto] encryptRecord(${tableName}) — guest mode, writing plaintext`); + return record; + } // Boot-time race: the layout's `vaultClient.unlock()` runs in the // same tick as authStore.initialize(), so the very first user @@ -115,12 +118,27 @@ export async function encryptRecord(tableName: string, record: * throwing. Views are expected to handle the blob → "🔒" rendering * themselves. Plaintext fields (id, timestamps, status) stay readable. */ +/** Throttle "vault locked" warnings so liveQuery refreshes don't spam the console. */ +const _lockedWarnings = new Set(); + export async function decryptRecord(tableName: string, record: T): Promise { const fields = getEncryptedFields(tableName); if (!fields) return record; const key = getActiveKey(); - if (!key) return record; // locked: leave blobs as-is + if (!key) { + // Log once per table to avoid flooding the console from liveQuery loops + if (!_lockedWarnings.has(tableName)) { + _lockedWarnings.add(tableName); + console.warn( + `[mana-crypto] decryptRecord(${tableName}) — vault is LOCKED, returning encrypted blobs as-is. ` + + `Encrypted fields: [${fields.join(', ')}]` + ); + } + return record; // locked: leave blobs as-is + } + // Clear the throttle flag so we log again if the vault re-locks later + _lockedWarnings.delete(tableName); const view = record as unknown as Record; for (const field of fields) { @@ -133,7 +151,7 @@ export async function decryptRecord(tableName: string, record: // keyed to a previous master. Log + leave the blob in place // so the UI can show a "decryption failed" marker. console.error( - `[mana-crypto] decrypt failed for ${tableName}.${field}: ${(err as Error).message}` + `[mana-crypto] decrypt FAILED for ${tableName}.${field}: ${(err as Error).message}` ); } } @@ -149,6 +167,12 @@ export async function decryptRecords( tableName: string, records: (T | null | undefined)[] ): Promise { + const fields = getEncryptedFields(tableName); + if (fields && !isVaultUnlocked() && records.length > 0) { + console.warn( + `[mana-crypto] decryptRecords(${tableName}) — ${records.length} record(s) will stay encrypted (vault locked)` + ); + } const out: T[] = []; for (const r of records) { if (r) out.push(await decryptRecord(tableName, r)); 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 2760311f3..775359579 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 @@ -249,8 +249,12 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { let res: Response; try { res = await fetch(`${authUrl}${path}`, init); - } catch { + } catch (err) { lastStatus = 0; // network error + console.warn( + `[mana-crypto:vault] fetchVault ${path} — network error (attempt ${attempt + 1}/${RETRY_MAX_ATTEMPTS})`, + (err as Error).message + ); if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt)); continue; } @@ -263,10 +267,20 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { lastStatus = res.status; if (!isRetriableStatus(res.status)) { const body = await res.json().catch(() => undefined); + console.warn( + `[mana-crypto:vault] fetchVault ${path} — permanent error ${res.status}`, + body + ); return { ok: false, status: res.status, body }; } + console.warn( + `[mana-crypto:vault] fetchVault ${path} — retriable error ${res.status} (attempt ${attempt + 1}/${RETRY_MAX_ATTEMPTS})` + ); if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt)); } + console.error( + `[mana-crypto:vault] fetchVault ${path} — all ${RETRY_MAX_ATTEMPTS} attempts exhausted, last status: ${lastStatus}` + ); return { ok: false, status: lastStatus }; } @@ -318,6 +332,7 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { async function unlock(): Promise { if (provider.isUnlocked()) { + console.debug('[mana-crypto:vault] unlock() — provider already unlocked, skipping fetch'); pendingRecoveryBlob = null; state = { status: 'unlocked' }; return state; @@ -325,10 +340,12 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { const token = await getToken(); if (!token) { + console.warn('[mana-crypto:vault] unlock() — no auth token available'); state = { status: 'error', reason: 'auth' }; return state; } + console.info('[mana-crypto:vault] unlock() — fetching GET /key...'); // Try GET /key first. const fetchRes = await fetchVault('/api/v1/me/encryption-vault/key', { method: 'GET', @@ -340,12 +357,14 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { // of an unwrapped MK. Stash it and wait for the UI to collect // the user's recovery code. if (isRecoveryBlob(fetchRes.data)) { + console.info('[mana-crypto:vault] unlock() — server returned recovery blob (ZK mode)'); awaitRecoveryCode({ recoveryWrappedMk: fetchRes.data.recoveryWrappedMk, recoveryIv: fetchRes.data.recoveryIv, }); return state; } + console.info('[mana-crypto:vault] unlock() — GET /key succeeded, importing master key'); await applyMasterKey((fetchRes.data as { masterKey: string }).masterKey); pendingRecoveryBlob = null; state = { status: 'unlocked' }; @@ -354,27 +373,38 @@ export function createVaultClient(options: VaultClientOptions): VaultClient { // 404 with VAULT_NOT_INITIALISED → bootstrap by calling /init. if (fetchRes.status === 404 && fetchRes.body?.code === 'VAULT_NOT_INITIALISED') { + console.info('[mana-crypto:vault] unlock() — vault not initialised, calling POST /init...'); const initRes = await fetchVault('/api/v1/me/encryption-vault/init', { method: 'POST', ...authHeaders(token), }); if (initRes.ok) { if (isRecoveryBlob(initRes.data)) { + console.info('[mana-crypto:vault] unlock() — /init returned recovery blob (ZK mode)'); awaitRecoveryCode({ recoveryWrappedMk: initRes.data.recoveryWrappedMk, recoveryIv: initRes.data.recoveryIv, }); return state; } + console.info('[mana-crypto:vault] unlock() — /init succeeded, importing master key'); await applyMasterKey((initRes.data as { masterKey: string }).masterKey); pendingRecoveryBlob = null; state = { status: 'unlocked' }; return state; } + console.error( + `[mana-crypto:vault] unlock() — /init failed with status ${initRes.status}`, + initRes.body + ); state = await categorise(initRes.status); return state; } + console.error( + `[mana-crypto:vault] unlock() — GET /key failed with status ${fetchRes.status}`, + fetchRes.body + ); state = await categorise(fetchRes.status); return state; } diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index fa9b5a66c..c0933c85a 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { migrateGuestDataToUser } from '$lib/data/guest-migration'; import { installDataLayerListeners } from '$lib/data/data-layer-listeners'; import { getVaultClient, hasAnyEncryption } from '$lib/data/crypto'; + import { toast } from '$lib/stores/toast.svelte'; import RecoveryCodeUnlockModal from '$lib/components/RecoveryCodeUnlockModal.svelte'; import SyncConflictToast from '$lib/components/SyncConflictToast.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; @@ -58,18 +59,25 @@ // Skip the network round-trip entirely while no table is encrypted — // hasAnyEncryption() flips to true once Phase 3 enables a pilot. if (userId && hasAnyEncryption()) { + console.info('[mana-crypto] vault unlock started — userId present, encryption enabled'); vaultClient.unlock().then((state) => { if (state.status === 'unlocked') { + console.info('[mana-crypto] vault unlocked successfully'); 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. + console.info('[mana-crypto] vault awaiting recovery code (zero-knowledge mode)'); needsRecoveryCode = true; return; } - console.warn('[mana] encryption vault unlock failed:', state); + const reason = 'reason' in state ? state.reason : 'unknown'; + console.error(`[mana-crypto] vault unlock FAILED — reason: ${reason}`, state); + toast.error( + `Verschlüsselungs-Vault konnte nicht entsperrt werden (${reason}). Verschlüsselte Inhalte sind nicht lesbar.` + ); }); } else if (!userId) { vaultClient.lock();