mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
716466e757
commit
b8987562ba
4 changed files with 82 additions and 5 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue