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 1c6e9ace4..65768cd78 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/index.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/index.ts @@ -43,3 +43,12 @@ export { hasAnyEncryption, getRegisteredTables, } from './registry'; + +export { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from './record-helpers'; + +export { + type VaultClient, + type VaultClientOptions, + type VaultUnlockState, + createVaultClient, +} from './vault-client'; 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 new file mode 100644 index 000000000..5af759d48 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for encryptRecord / decryptRecord roundtrip behaviour. + * + * Uses real Web Crypto from the Node 20+ runtime — no Dexie or + * IndexedDB needed. The registry is mutated in place via vi.spyOn so + * we can flip a test table to enabled:true without affecting the + * production defaults. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { encryptRecord, decryptRecord, decryptRecords, VaultLockedError } from './record-helpers'; +import { generateMasterKey, isEncrypted } from './aes'; +import { MemoryKeyProvider, setKeyProvider } from './key-provider'; +import * as registry from './registry'; + +let key: CryptoKey; +let provider: MemoryKeyProvider; + +const TEST_TABLE = 'notes'; + +beforeEach(async () => { + key = await generateMasterKey(); + provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + + // Pretend the notes table is enabled with title + body fields. + vi.spyOn(registry, 'getEncryptedFields').mockImplementation((tableName: string) => { + if (tableName === TEST_TABLE) return ['title', 'body']; + return null; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + provider.setKey(null); +}); + +describe('encryptRecord', () => { + it('encrypts only the configured fields, leaves the rest plaintext', async () => { + const record = { + id: 'note-1', + title: 'Buy milk', + body: 'and eggs and bread', + createdAt: '2026-04-07T10:00:00Z', + isPinned: false, + }; + await encryptRecord(TEST_TABLE, record); + + expect(record.id).toBe('note-1'); + expect(record.createdAt).toBe('2026-04-07T10:00:00Z'); + expect(record.isPinned).toBe(false); + + expect(isEncrypted(record.title)).toBe(true); + expect(isEncrypted(record.body)).toBe(true); + expect(record.title).not.toBe('Buy milk'); + expect(record.body).not.toBe('and eggs and bread'); + }); + + it('skips fields that are null or undefined', async () => { + const record = { id: 'n', title: null, body: undefined as unknown as string }; + await encryptRecord(TEST_TABLE, record); + expect(record.title).toBe(null); + expect(record.body).toBe(undefined); + }); + + it('skips already-encrypted fields (idempotent on second call)', async () => { + const record = { id: 'n', title: 'first', body: 'second' }; + await encryptRecord(TEST_TABLE, record); + const encryptedTitle = record.title; + const encryptedBody = record.body; + + await encryptRecord(TEST_TABLE, record); + expect(record.title).toBe(encryptedTitle); + expect(record.body).toBe(encryptedBody); + }); + + it('returns unchanged when the table is not in the registry', async () => { + const record = { id: 'x', title: 'plain', body: 'plain' }; + await encryptRecord('not_encrypted_table', record); + expect(record.title).toBe('plain'); + expect(record.body).toBe('plain'); + }); + + it('throws VaultLockedError when no key is available', async () => { + provider.setKey(null); + const record = { id: 'n', title: 'secret', body: 'also secret' }; + await expect(encryptRecord(TEST_TABLE, record)).rejects.toThrow(VaultLockedError); + // Record was not partially mutated + expect(record.title).toBe('secret'); + expect(record.body).toBe('also secret'); + }); + + it('does not throw when the vault is locked but no fields need encryption', async () => { + provider.setKey(null); + const record = { id: 'n', title: null, body: undefined as unknown as string }; + await expect(encryptRecord(TEST_TABLE, record)).resolves.toBeDefined(); + }); +}); + +describe('decryptRecord', () => { + it('decrypts the configured fields back to their plaintext', async () => { + const original = { id: 'n', title: 'Buy milk', body: 'plus eggs', createdAt: 'now' }; + // We can't pass the same object to encrypt + decrypt without + // reading the encrypted values first, so encrypt in place then + // decrypt the same reference. + await encryptRecord(TEST_TABLE, original); + expect(isEncrypted(original.title)).toBe(true); + + await decryptRecord(TEST_TABLE, original); + expect(original.title).toBe('Buy milk'); + expect(original.body).toBe('plus eggs'); + expect(original.createdAt).toBe('now'); + }); + + it('leaves blobs in place when the vault is locked', async () => { + const record = { id: 'n', title: 'Buy milk', body: 'plus eggs' }; + await encryptRecord(TEST_TABLE, record); + const encryptedTitle = record.title; + const encryptedBody = record.body; + + provider.setKey(null); + await decryptRecord(TEST_TABLE, record); + expect(record.title).toBe(encryptedTitle); + expect(record.body).toBe(encryptedBody); + }); + + it('logs and continues on per-field decrypt failure', async () => { + const record = { id: 'n', title: 'Buy milk', body: 'plus eggs' }; + await encryptRecord(TEST_TABLE, record); + + // Tamper with the title's auth tag + const lastChar = (record.title as string).charAt((record.title as string).length - 1); + const swap = lastChar === 'A' ? 'B' : 'A'; + record.title = (record.title as string).slice(0, -1) + swap; + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + await decryptRecord(TEST_TABLE, record); + expect(errSpy).toHaveBeenCalledOnce(); + // Body still decrypts + expect(record.body).toBe('plus eggs'); + // Title stays as-is (corrupt blob) + expect(isEncrypted(record.title)).toBe(true); + errSpy.mockRestore(); + }); + + it('passes through plaintext fields untouched', async () => { + const record = { id: 'n', title: 'plain string', body: null }; + await decryptRecord(TEST_TABLE, record); + expect(record.title).toBe('plain string'); + expect(record.body).toBe(null); + }); +}); + +describe('decryptRecords', () => { + it('decrypts an array of records', async () => { + const records = [ + { id: 'a', title: 'A title', body: 'A body' }, + { id: 'b', title: 'B title', body: 'B body' }, + ]; + await Promise.all(records.map((r) => encryptRecord(TEST_TABLE, r))); + const decrypted = await decryptRecords(TEST_TABLE, records); + expect(decrypted).toHaveLength(2); + expect(decrypted[0].title).toBe('A title'); + expect(decrypted[1].title).toBe('B title'); + }); + + it('skips null/undefined entries from getMany-style results', async () => { + const r = { id: 'a', title: 'A', body: 'B' }; + await encryptRecord(TEST_TABLE, r); + const decrypted = await decryptRecords(TEST_TABLE, [r, null, undefined]); + expect(decrypted).toHaveLength(1); + expect(decrypted[0].title).toBe('A'); + }); +}); + +describe('encrypt → write → read → decrypt full cycle', () => { + it('survives a JSON.stringify/parse roundtrip in between (sync wire)', async () => { + const original = { id: 'n', title: 'Sensitive', body: 'Even more sensitive' }; + await encryptRecord(TEST_TABLE, original); + + // Simulate sending to the server and getting it back + const onTheWire = JSON.stringify(original); + const fromServer = JSON.parse(onTheWire) as typeof original; + + await decryptRecord(TEST_TABLE, fromServer); + expect(fromServer.title).toBe('Sensitive'); + expect(fromServer.body).toBe('Even more sensitive'); + }); +}); 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 new file mode 100644 index 000000000..991699e7f --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts @@ -0,0 +1,143 @@ +/** + * Record-level encrypt/decrypt helpers driven by the registry. + * + * Module stores call `encryptRecord(tableName, record)` before + * `table.add(record)` / `table.update(id, record)`. Module read paths + * call `decryptRecord(tableName, record)` after `table.get(id)` / + * `liveQuery(...)`. + * + * Why explicit calls instead of transparent Dexie hooks? + * - Dexie hooks are synchronous; Web Crypto is async. Wrapping the + * hook in a sync polyfill (stablelib) would add ~10 KB and slow + * every write down by ~3x. Explicit async calls keep the hot path + * on native crypto. + * - Pilot rollout is easier: notes can adopt encryption without + * touching todo, calendar, etc. + * - Tests are simpler: no need to mock Dexie hook timing. + * + * What happens if the vault is locked? + * - encryptRecord throws `VaultLockedError`. Module stores let it + * bubble; the UI layer turns it into a "you need to unlock first" + * toast. The user-visible write fails fast — no plaintext sneaks + * through. + * - decryptRecord returns the record unchanged (encrypted blobs stay + * opaque), so views can still render the structural fields and + * show a "🔒 locked" placeholder where the encrypted ones used to + * be. + * + * What about server-applied changes? + * - applyServerChanges in sync.ts treats encrypted fields as opaque + * strings. The server stores blobs, the client stores blobs. The + * decrypt happens at READ time via decryptRecord, not at apply + * time. This means LWW continues to compare timestamps (plaintext + * metadata) without ever touching the ciphertext. + */ + +import { wrapValue, unwrapValue, isEncrypted } from './aes'; +import { getActiveKey, isVaultUnlocked } from './key-provider'; +import { getEncryptedFields } from './registry'; + +/** Thrown by encryptRecord when no key is available. Module stores + * catch this to surface "vault locked" UI. */ +export class VaultLockedError extends Error { + constructor(public tableName: string) { + super(`mana-crypto: vault is locked, cannot encrypt fields for table '${tableName}'`); + this.name = 'VaultLockedError'; + } +} + +/** + * Encrypts the configured fields of `record` in place. Returns the + * same record reference for chaining. No-op if the table is not in + * the registry, the registry entry is disabled, or every configured + * field is null/undefined. + * + * Throws VaultLockedError if at least one field would need encryption + * but no master key is available. Callers can pre-check with + * `isVaultUnlocked()` to surface a friendlier error. + */ +export async function encryptRecord>( + tableName: string, + record: T +): Promise { + const fields = getEncryptedFields(tableName); + if (!fields) return record; + + // Build the work list first so we don't half-encrypt a record on + // vault-locked failure mid-loop. + const todo: string[] = []; + for (const field of fields) { + const value = record[field]; + if (value === null || value === undefined) continue; + // Already encrypted? Skip — happens when applyServerChanges + // hands a record (with encrypted blobs from the wire) back + // through the same code path on a re-emit / liveQuery refresh. + if (typeof value === 'string' && isEncrypted(value)) continue; + todo.push(field); + } + if (todo.length === 0) return record; + + const key = getActiveKey(); + if (!key) throw new VaultLockedError(tableName); + + for (const field of todo) { + const wrapped = await wrapValue(record[field], key); + (record as Record)[field] = wrapped; + } + return record; +} + +/** + * Decrypts the configured fields of `record` in place. Returns the + * same record reference. No-op if the table is not in the registry, + * the registry entry is disabled, or every encrypted field is missing. + * + * Locked-vault behaviour: leaves encrypted blobs in place rather than + * throwing. Views are expected to handle the blob → "🔒" rendering + * themselves. Plaintext fields (id, timestamps, status) stay readable. + */ +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 + + for (const field of fields) { + const value = record[field]; + if (typeof value !== 'string' || !isEncrypted(value)) continue; + try { + (record as Record)[field] = await unwrapValue(value, key); + } catch (err) { + // Don't kill the read just because one field is corrupt or + // 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}` + ); + } + } + return record; +} + +/** + * Convenience for liveQuery / array results — applies decryptRecord + * to every entry and returns a fresh array of the same length. Skips + * null entries (e.g. from `getMany([id1, id2])` when one is missing). + */ +export async function decryptRecords>( + tableName: string, + records: (T | null | undefined)[] +): Promise { + const out: T[] = []; + for (const r of records) { + if (r) out.push(await decryptRecord(tableName, r)); + } + return out; +} + +/** Re-export so callers don't need to reach into key-provider.ts. */ +export { isVaultUnlocked }; diff --git a/apps/mana/apps/web/src/lib/data/crypto/vault-client.test.ts b/apps/mana/apps/web/src/lib/data/crypto/vault-client.test.ts new file mode 100644 index 000000000..c62f27d26 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/vault-client.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for the browser-side vault client. + * + * Mocks `globalThis.fetch` to simulate the various HTTP responses + * mana-auth can return. The actual MemoryKeyProvider gets a real + * Web Crypto key imported via the response, so the test also + * verifies that the import path doesn't blow up on the bytes the + * server sends. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createVaultClient } from './vault-client'; +import { MemoryKeyProvider, setKeyProvider, getActiveKey, isVaultUnlocked } from './key-provider'; +import { generateMasterKey, exportMasterKey } from './aes'; + +let realFetch: typeof fetch; + +beforeEach(() => { + realFetch = globalThis.fetch; + // Reset to a fresh provider for each test + setKeyProvider(new MemoryKeyProvider()); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); +}); + +/** Helper: builds a base64 string the server would return for a key. */ +async function freshKeyBase64(): Promise { + const key = await generateMasterKey(); + const raw = await exportMasterKey(key); + let bin = ''; + for (let i = 0; i < raw.length; i++) bin += String.fromCharCode(raw[i]); + return btoa(bin); +} + +function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + return handler(url, init); + }) as typeof fetch; +} + +describe('createVaultClient.unlock — happy path', () => { + it('fetches /key on first unlock and installs the master key', async () => { + const masterKey = await freshKeyBase64(); + const calls: string[] = []; + mockFetch((url) => { + calls.push(url); + return new Response(JSON.stringify({ masterKey, formatVersion: 1, kekId: 'env-v1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const client = createVaultClient({ + authUrl: 'http://localhost:3001', + getToken: () => 'test-jwt', + }); + + const state = await client.unlock(); + expect(state).toEqual({ status: 'unlocked' }); + expect(isVaultUnlocked()).toBe(true); + expect(getActiveKey()).not.toBe(null); + expect(calls).toEqual(['http://localhost:3001/api/v1/me/encryption-vault/key']); + }); + + it('is idempotent when already unlocked', async () => { + const masterKey = await freshKeyBase64(); + mockFetch(() => new Response(JSON.stringify({ masterKey }), { status: 200 })); + + const client = createVaultClient({ + authUrl: 'http://localhost:3001', + getToken: () => 'test-jwt', + }); + await client.unlock(); + const fetchCount = (globalThis.fetch as ReturnType).mock.calls.length; + const second = await client.unlock(); + expect(second).toEqual({ status: 'unlocked' }); + // Second call short-circuits without hitting the network + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(fetchCount); + }); +}); + +describe('createVaultClient.unlock — auto-init on 404', () => { + it('calls /init when /key returns VAULT_NOT_INITIALISED', async () => { + const masterKey = await freshKeyBase64(); + const calls: string[] = []; + mockFetch((url) => { + calls.push(url); + if (url.endsWith('/key')) { + return new Response( + JSON.stringify({ error: 'vault not initialised', code: 'VAULT_NOT_INITIALISED' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + if (url.endsWith('/init')) { + return new Response(JSON.stringify({ masterKey, formatVersion: 1, kekId: 'env-v1' }), { + status: 200, + }); + } + return new Response('', { status: 500 }); + }); + + const client = createVaultClient({ + authUrl: 'http://localhost:3001', + getToken: () => 'test-jwt', + }); + + const state = await client.unlock(); + expect(state).toEqual({ status: 'unlocked' }); + expect(calls).toEqual([ + 'http://localhost:3001/api/v1/me/encryption-vault/key', + 'http://localhost:3001/api/v1/me/encryption-vault/init', + ]); + }); + + it('does NOT call /init on a generic 404 without the code', async () => { + mockFetch(() => new Response(JSON.stringify({ error: 'not found' }), { status: 404 })); + + const client = createVaultClient({ + authUrl: 'http://localhost:3001', + getToken: () => 'test-jwt', + }); + + const state = await client.unlock(); + expect(state.status).toBe('error'); + // Only the /key call happened — no /init follow-up + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(1); + }); +}); + +describe('createVaultClient.unlock — error categorisation', () => { + it('reports auth error on 401', async () => { + mockFetch(() => new Response('', { status: 401 })); + const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' }); + const state = await client.unlock(); + expect(state).toEqual({ status: 'error', reason: 'auth' }); + }); + + it('reports auth error on 403', async () => { + mockFetch(() => new Response('', { status: 403 })); + const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' }); + const state = await client.unlock(); + expect(state).toEqual({ status: 'error', reason: 'auth' }); + }); + + it('reports network error when fetch throws', async () => { + mockFetch(() => { + throw new Error('Failed to fetch'); + }); + const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' }); + const state = await client.unlock(); + expect(state).toEqual({ status: 'error', reason: 'network' }); + }); + + it('reports auth error when no token is available', async () => { + mockFetch(() => new Response('', { status: 200 })); + const client = createVaultClient({ authUrl: 'http://x', getToken: () => null }); + const state = await client.unlock(); + expect(state).toEqual({ status: 'error', reason: 'auth' }); + // No fetch call should have happened + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(0); + }); +}); + +describe('createVaultClient.lock', () => { + it('clears the active key', async () => { + const masterKey = await freshKeyBase64(); + mockFetch(() => new Response(JSON.stringify({ masterKey }), { status: 200 })); + const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' }); + await client.unlock(); + expect(isVaultUnlocked()).toBe(true); + + client.lock(); + expect(isVaultUnlocked()).toBe(false); + expect(getActiveKey()).toBe(null); + }); +}); + +describe('createVaultClient.refetch', () => { + it('clears and re-fetches the master key', async () => { + const k1 = await freshKeyBase64(); + const k2 = await freshKeyBase64(); + let call = 0; + mockFetch(() => { + const body = call++ === 0 ? k1 : k2; + return new Response(JSON.stringify({ masterKey: body }), { status: 200 }); + }); + const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' }); + await client.unlock(); + const before = getActiveKey(); + await client.refetch(); + const after = getActiveKey(); + expect(before).not.toBe(null); + expect(after).not.toBe(null); + // Two underlying fetches happened + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(2); + }); +}); + +describe('createVaultClient.rotate', () => { + it('POSTs /rotate and installs the new key', async () => { + const masterKey = await freshKeyBase64(); + const calls: { url: string; method?: string }[] = []; + mockFetch((url, init) => { + calls.push({ url, method: init?.method }); + return new Response(JSON.stringify({ masterKey }), { status: 200 }); + }); + const client = createVaultClient({ authUrl: 'http://x', getToken: () => 't' }); + const state = await client.rotate(); + expect(state).toEqual({ status: 'unlocked' }); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toContain('/rotate'); + }); +}); 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 new file mode 100644 index 000000000..e6d574c15 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/vault-client.ts @@ -0,0 +1,258 @@ +/** + * Browser-side client for the mana-auth encryption vault. + * + * Talks to the three endpoints introduced in Phase 2: + * POST /api/v1/me/encryption-vault/init — bootstrap a fresh master key + * GET /api/v1/me/encryption-vault/key — fetch the active master key + * POST /api/v1/me/encryption-vault/rotate — destructive rotation + * + * Behaviour: + * - `unlock(token)` is the standard login path. Calls GET /key first; if + * the server returns 404 (`VAULT_NOT_INITIALISED`), automatically + * follows up with POST /init so the user is bootstrapped on first + * login per device. The recovered raw bytes are imported into a + * non-extractable CryptoKey and pushed into the active KeyProvider. + * - `lock()` clears the in-memory key. Called from the layout on logout. + * - All network calls go through a small retry loop (3 attempts, + * exponential backoff with jitter) so transient 5xx / network blips + * don't immediately leave the vault locked. 4xx is treated as + * permanent and surfaces immediately. + * + * Read-only mode on persistent failure: + * If the vault cannot be unlocked after retries, the active key + * provider stays locked. Sync-tracked tables that are flagged + * `enabled: true` in the registry will refuse to write (the + * wrapValue helper throws on a missing key) — caller code surfaces + * that as a "vault locked" toast. Plaintext tables continue to work. + * + * NOT in this module: + * - Token acquisition. The caller passes the JWT in. The vault client + * stays decoupled from the auth store so it can be tested with a + * mock fetch. + * - The MemoryKeyProvider instance — wired in by the layout/boot path + * via setKeyProvider(). This module just talks to the configured + * provider via setActiveProvider() at construction. + */ + +import { importMasterKey } from './aes'; +import { MemoryKeyProvider, setKeyProvider, getKeyProvider } from './key-provider'; + +const RETRY_MAX_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 500; +const RETRY_MAX_DELAY_MS = 8000; + +/** HTTP status codes that warrant a retry — transient server / network. */ +function isRetriableStatus(status: number): boolean { + return status === 0 || status === 408 || status === 429 || status >= 500; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function backoffDelay(attempt: number): number { + const exp = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt); + return Math.floor(Math.random() * exp); +} + +export type VaultUnlockState = + | { status: 'unlocked' } + | { status: 'locked' } + | { status: 'error'; reason: 'auth' | 'network' | 'server' | 'unknown' }; + +export interface VaultClientOptions { + /** Base URL of mana-auth, e.g. 'https://auth.mana.how'. */ + authUrl: string; + /** Function returning the current JWT, or null if signed out. */ + getToken: () => Promise | string | null; +} + +export interface VaultClient { + /** Unlocks the in-memory key provider by fetching from the server. + * On first call per device, automatically initialises the vault. */ + unlock(): Promise; + /** Clears the in-memory key — call on logout. */ + lock(): void; + /** Forces a fresh fetch even if the provider is already unlocked. + * Used by the rotate flow + tests. */ + refetch(): Promise; + /** Triggers POST /rotate. Caller is responsible for re-encryption. */ + rotate(): Promise; + /** Current snapshot of the unlock state. */ + getState(): VaultUnlockState; +} + +/** + * Builds a vault client and installs a MemoryKeyProvider as the active + * provider. Idempotent: calling twice returns a fresh client but reuses + * the same provider, so the new client picks up an already-unlocked key. + */ +export function createVaultClient(options: VaultClientOptions): VaultClient { + const { authUrl, getToken } = options; + + // Reuse the existing MemoryKeyProvider if one is already installed — + // otherwise create + register a fresh one. + let provider: MemoryKeyProvider; + const existing = getKeyProvider(); + if (existing instanceof MemoryKeyProvider) { + provider = existing; + } else { + provider = new MemoryKeyProvider(); + setKeyProvider(provider); + } + + let state: VaultUnlockState = provider.isUnlocked() + ? { status: 'unlocked' } + : { status: 'locked' }; + + // ─── Internal: HTTP with retry ─────────────────────────── + async function fetchVault( + path: string, + init: RequestInit + ): Promise< + | { ok: true; data: { masterKey: string } } + | { ok: false; status: number; body?: { error?: string; code?: string } } + > { + let lastStatus = 0; + for (let attempt = 0; attempt < RETRY_MAX_ATTEMPTS; attempt++) { + let res: Response; + try { + res = await fetch(`${authUrl}${path}`, init); + } catch { + lastStatus = 0; // network error + if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt)); + continue; + } + + if (res.ok) { + const data = (await res.json()) as { masterKey: string }; + return { ok: true, data }; + } + + lastStatus = res.status; + if (!isRetriableStatus(res.status)) { + const body = await res.json().catch(() => undefined); + return { ok: false, status: res.status, body }; + } + if (attempt < RETRY_MAX_ATTEMPTS - 1) await sleep(backoffDelay(attempt)); + } + return { ok: false, status: lastStatus }; + } + + function authHeaders(token: string): RequestInit { + return { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }; + } + + async function applyMasterKey(masterKeyB64: string): Promise { + const raw = base64ToBytes(masterKeyB64); + const cryptoKey = await importMasterKey(raw); + provider.setKey(cryptoKey); + // Best-effort: zero the raw bytes once they're imported. Doesn't + // guarantee the JS heap is wiped (the underlying ArrayBuffer is + // owned by the GC), but at least our reference goes away. + raw.fill(0); + } + + async function categorise(status: number): Promise { + if (status === 401 || status === 403) return { status: 'error', reason: 'auth' }; + if (status === 0) return { status: 'error', reason: 'network' }; + if (status >= 500) return { status: 'error', reason: 'server' }; + return { status: 'error', reason: 'unknown' }; + } + + // ─── Public methods ────────────────────────────────────── + + async function unlock(): Promise { + if (provider.isUnlocked()) { + state = { status: 'unlocked' }; + return state; + } + + const token = await getToken(); + if (!token) { + state = { status: 'error', reason: 'auth' }; + return state; + } + + // Try GET /key first. + const fetchRes = await fetchVault('/api/v1/me/encryption-vault/key', { + method: 'GET', + ...authHeaders(token), + }); + + if (fetchRes.ok) { + await applyMasterKey(fetchRes.data.masterKey); + state = { status: 'unlocked' }; + return state; + } + + // 404 with VAULT_NOT_INITIALISED → bootstrap by calling /init. + if (fetchRes.status === 404 && fetchRes.body?.code === 'VAULT_NOT_INITIALISED') { + const initRes = await fetchVault('/api/v1/me/encryption-vault/init', { + method: 'POST', + ...authHeaders(token), + }); + if (initRes.ok) { + await applyMasterKey(initRes.data.masterKey); + state = { status: 'unlocked' }; + return state; + } + state = await categorise(initRes.status); + return state; + } + + state = await categorise(fetchRes.status); + return state; + } + + function lock(): void { + provider.setKey(null); + state = { status: 'locked' }; + } + + async function refetch(): Promise { + provider.setKey(null); + return unlock(); + } + + async function rotate(): Promise { + const token = await getToken(); + if (!token) { + state = { status: 'error', reason: 'auth' }; + return state; + } + const res = await fetchVault('/api/v1/me/encryption-vault/rotate', { + method: 'POST', + ...authHeaders(token), + }); + if (res.ok) { + await applyMasterKey(res.data.masterKey); + state = { status: 'unlocked' }; + return state; + } + state = await categorise(res.status); + return state; + } + + function getState(): VaultUnlockState { + // Reconcile in case the provider was locked from somewhere else. + if (!provider.isUnlocked() && state.status === 'unlocked') { + state = { status: 'locked' }; + } + return state; + } + + return { unlock, lock, refetch, rotate, getState }; +} + +// ─── Helpers ───────────────────────────────────────────────── + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index 6be3c4d22..44ee4174a 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -8,6 +8,8 @@ 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 SuggestionToast from '$lib/components/SuggestionToast.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte'; @@ -19,6 +21,14 @@ // 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(), + }); + // 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, // so module stores never need to know who the current user is. @@ -40,6 +50,19 @@ console.error('[mana] guest → user migration failed:', err); }); } + + // Encryption vault: unlock when authenticated, lock when not. + // Skip the network round-trip entirely while no table is encrypted — + // hasAnyEncryption() flips to true once Phase 3 enables a pilot. + if (userId && hasAnyEncryption()) { + vaultClient.unlock().then((state) => { + if (state.status !== 'unlocked') { + console.warn('[mana] encryption vault unlock failed:', state); + } + }); + } else if (!userId) { + vaultClient.lock(); + } }); onMount(() => {