fix(mana/web): add logging + toast for encryption vault unlock failures

Vault unlock errors were silently swallowed, causing encrypted content
(enc:1:...) to render as ciphertext in the UI. Now logs each step of
the unlock flow and shows an error toast when the vault fails to unlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 17:29:02 +02:00
parent 716466e757
commit b8987562ba
4 changed files with 82 additions and 5 deletions

View file

@ -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<CryptoKey | null> {
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. */

View file

@ -88,7 +88,10 @@ export async function encryptRecord<T extends object>(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<T extends object>(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<string>();
export async function decryptRecord<T extends object>(tableName: string, record: T): Promise<T> {
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<string, unknown>;
for (const field of fields) {
@ -133,7 +151,7 @@ export async function decryptRecord<T extends object>(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<T extends object>(
tableName: string,
records: (T | null | undefined)[]
): Promise<T[]> {
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));

View file

@ -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<VaultUnlockState> {
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;
}

View file

@ -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();