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 new file mode 100644 index 000000000..c6e826ca6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts @@ -0,0 +1,285 @@ +/** + * AES-GCM wrap/unwrap primitive tests. + * + * Runs against the native Web Crypto API exposed on `globalThis.crypto` + * by Node 20+. No Dexie or fake-indexeddb needed — these are pure + * functions over the standard subtle crypto interface. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + wrapValue, + unwrapValue, + isEncrypted, + generateMasterKey, + importMasterKey, + exportMasterKey, + ENC_PREFIX, +} from './aes'; +import { MemoryKeyProvider, setKeyProvider, getActiveKey, isVaultUnlocked } from './key-provider'; +import { + getEncryptedFields, + hasAnyEncryption, + getRegisteredTables, + ENCRYPTION_REGISTRY, +} from './registry'; + +let key: CryptoKey; +let otherKey: CryptoKey; + +beforeAll(async () => { + key = await generateMasterKey(); + otherKey = await generateMasterKey(); +}); + +describe('isEncrypted', () => { + it('detects the encryption prefix', () => { + expect(isEncrypted('enc:1:abc.def')).toBe(true); + }); + + it('rejects non-string values', () => { + expect(isEncrypted(null)).toBe(false); + expect(isEncrypted(undefined)).toBe(false); + expect(isEncrypted(42)).toBe(false); + expect(isEncrypted({})).toBe(false); + expect(isEncrypted([])).toBe(false); + }); + + it('rejects strings without the prefix', () => { + expect(isEncrypted('hello world')).toBe(false); + expect(isEncrypted('')).toBe(false); + expect(isEncrypted('enc:')).toBe(false); + expect(isEncrypted('enc:2:abc.def')).toBe(false); // wrong version + }); +}); + +describe('wrapValue / unwrapValue roundtrip', () => { + it('roundtrips a plain string', async () => { + const blob = await wrapValue('Buy milk', key); + expect(typeof blob).toBe('string'); + expect((blob as string).startsWith(ENC_PREFIX)).toBe(true); + expect(await unwrapValue(blob, key)).toBe('Buy milk'); + }); + + it('roundtrips an empty string', async () => { + const blob = await wrapValue('', key); + expect(await unwrapValue(blob, key)).toBe(''); + }); + + it('roundtrips a unicode string', async () => { + const original = 'Schlüssel — 日本語 — 🔐 — ☃'; + const blob = await wrapValue(original, key); + expect(await unwrapValue(blob, key)).toBe(original); + }); + + it('roundtrips an object', async () => { + const original = { title: 'Meeting', attendees: ['Alice', 'Bob'], priority: 2 }; + const blob = await wrapValue(original, key); + expect(await unwrapValue(blob, key)).toEqual(original); + }); + + it('roundtrips an array', async () => { + const original = [1, 'two', { three: true }, null]; + const blob = await wrapValue(original, key); + expect(await unwrapValue(blob, key)).toEqual(original); + }); + + it('roundtrips a number', async () => { + const blob = await wrapValue(42, key); + expect(await unwrapValue(blob, key)).toBe(42); + }); + + it('roundtrips a boolean', async () => { + const trueBlob = await wrapValue(true, key); + const falseBlob = await wrapValue(false, key); + expect(await unwrapValue(trueBlob, key)).toBe(true); + expect(await unwrapValue(falseBlob, key)).toBe(false); + }); + + it('roundtrips a large string (10KB)', async () => { + const large = 'x'.repeat(10_000); + const blob = await wrapValue(large, key); + expect(await unwrapValue(blob, key)).toBe(large); + }); + + it('passes null through unchanged on wrap', async () => { + expect(await wrapValue(null, key)).toBe(null); + }); + + it('passes undefined through unchanged on wrap', async () => { + expect(await wrapValue(undefined, key)).toBe(undefined); + }); + + it('passes plaintext strings through unchanged on unwrap', async () => { + expect(await unwrapValue('not encrypted', key)).toBe('not encrypted'); + }); + + it('passes null/undefined through unchanged on unwrap', async () => { + expect(await unwrapValue(null, key)).toBe(null); + expect(await unwrapValue(undefined, key)).toBe(undefined); + }); +}); + +describe('IV uniqueness', () => { + it('produces a different ciphertext for the same plaintext on each call', async () => { + const a = await wrapValue('same input', key); + const b = await wrapValue('same input', key); + const c = await wrapValue('same input', key); + expect(a).not.toBe(b); + expect(b).not.toBe(c); + expect(a).not.toBe(c); + // All three still decrypt to the same value + expect(await unwrapValue(a, key)).toBe('same input'); + expect(await unwrapValue(b, key)).toBe('same input'); + expect(await unwrapValue(c, key)).toBe('same input'); + }); +}); + +describe('wrong key rejection', () => { + it('throws when the wrong key tries to decrypt', async () => { + const blob = await wrapValue('secret', key); + await expect(unwrapValue(blob, otherKey)).rejects.toThrow(); + }); +}); + +describe('tampered ciphertext rejection', () => { + it('throws when the ciphertext bytes are flipped', async () => { + const blob = (await wrapValue('secret', key)) as string; + // Flip the last character to corrupt the auth tag + const lastChar = blob.charAt(blob.length - 1); + const swapChar = lastChar === 'A' ? 'B' : 'A'; + const tampered = blob.slice(0, -1) + swapChar; + await expect(unwrapValue(tampered, key)).rejects.toThrow(); + }); +}); + +describe('malformed blob handling', () => { + it('throws when the iv/ct separator is missing', async () => { + await expect(unwrapValue('enc:1:noSeparatorHere', key)).rejects.toThrow( + /missing iv\/ct separator/ + ); + }); +}); + +describe('importMasterKey / exportMasterKey', () => { + it('roundtrips a key through raw bytes', async () => { + const exported = await exportMasterKey(key); + expect(exported.length).toBe(32); + + const reimported = await importMasterKey(exported); + // Use the reimported key to decrypt something the original encrypted + const blob = await wrapValue('roundtrip via raw bytes', key); + expect(await unwrapValue(blob, reimported)).toBe('roundtrip via raw bytes'); + }); + + it('rejects raw input that is not 32 bytes', async () => { + await expect(importMasterKey(new Uint8Array(16))).rejects.toThrow(/32-byte/); + await expect(importMasterKey(new Uint8Array(64))).rejects.toThrow(/32-byte/); + }); +}); + +describe('KeyProvider', () => { + it('NullKeyProvider is the default and reports locked', () => { + // Re-set to default explicitly in case a previous test left state + setKeyProvider( + new (class { + getKey() { + return null; + } + isUnlocked() { + return false; + } + onChange() { + return () => {}; + } + })() + ); + expect(getActiveKey()).toBe(null); + expect(isVaultUnlocked()).toBe(false); + }); + + it('MemoryKeyProvider holds a key and reports unlocked', async () => { + const provider = new MemoryKeyProvider(); + expect(provider.isUnlocked()).toBe(false); + expect(provider.getKey()).toBe(null); + + provider.setKey(key); + expect(provider.isUnlocked()).toBe(true); + expect(provider.getKey()).toBe(key); + + provider.setKey(null); + expect(provider.isUnlocked()).toBe(false); + }); + + it('MemoryKeyProvider notifies listeners on lock/unlock transitions', () => { + const provider = new MemoryKeyProvider(); + const events: boolean[] = []; + const dispose = provider.onChange((unlocked) => events.push(unlocked)); + + provider.setKey(key); + provider.setKey(key); // no transition — should not fire + provider.setKey(null); + provider.setKey(null); // no transition — should not fire + provider.setKey(key); + + expect(events).toEqual([true, false, true]); + dispose(); + + // After dispose, no more notifications + provider.setKey(null); + expect(events).toEqual([true, false, true]); + }); + + it('setKeyProvider swaps the active provider', async () => { + const provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + + expect(isVaultUnlocked()).toBe(true); + expect(getActiveKey()).toBe(key); + + // Reset for next tests + provider.setKey(null); + expect(isVaultUnlocked()).toBe(false); + }); +}); + +describe('encryption registry', () => { + it('returns null for tables not in the registry', () => { + expect(getEncryptedFields('not_a_real_table')).toBe(null); + }); + + it('returns null for registered tables that are disabled', () => { + // Phase 1: every entry is enabled:false + expect(getEncryptedFields('messages')).toBe(null); + expect(getEncryptedFields('notes')).toBe(null); + expect(getEncryptedFields('contacts')).toBe(null); + }); + + it('hasAnyEncryption returns false in Phase 1', () => { + expect(hasAnyEncryption()).toBe(false); + }); + + it('getRegisteredTables lists every table in the registry', () => { + const tables = getRegisteredTables(); + // Spot-check the most sensitive ones + expect(tables).toContain('messages'); + expect(tables).toContain('notes'); + expect(tables).toContain('memos'); + expect(tables).toContain('contacts'); + expect(tables).toContain('cycleDayLogs'); + }); + + it('every registry entry has a non-empty fields list', () => { + for (const [table, config] of Object.entries(ENCRYPTION_REGISTRY)) { + expect(config.fields.length, `${table} has empty fields list`).toBeGreaterThan(0); + } + }); + + it('field lists contain no duplicates', () => { + for (const [table, config] of Object.entries(ENCRYPTION_REGISTRY)) { + const unique = new Set(config.fields); + expect(unique.size, `${table} has duplicate field names`).toBe(config.fields.length); + } + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/crypto/aes.ts b/apps/mana/apps/web/src/lib/data/crypto/aes.ts new file mode 100644 index 000000000..031ff9a3f --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/aes.ts @@ -0,0 +1,186 @@ +/** + * AES-GCM-256 wrap/unwrap primitives. + * + * Pure crypto layer with no state and no Dexie dependency. The higher-level + * registry/key-provider modules use these to encrypt configured fields on + * the way into IndexedDB and decrypt them on the way out. + * + * Wire format + * `enc:${VERSION}:${base64(iv)}.${base64(ct)}` + * + * The string-prefix format (rather than a JSON envelope) is deliberate: + * - One scan to detect "is this encrypted?" — `value.startsWith('enc:1:')` + * - Survives JSON.stringify when records flow through the sync wire + * - Compact: ~1.4× the original byte length, vs ~2× for a JSON envelope + * - Trivial to bump VERSION for future format migrations + * + * Authenticated encryption: AES-GCM provides both confidentiality and + * tamper-detection. A modified ciphertext fails decryption with an + * OperationError instead of returning silent garbage — `unwrapValue` + * surfaces that as a thrown error so callers can react. + * + * Value types: anything JSON-serialisable. The plaintext is JSON.stringified + * before encryption, JSON.parsed after decryption. `null` and `undefined` + * pass through unchanged so callers can blindly wrap optional fields + * without checking each one first. + */ + +/** Bumped if the wire format ever changes. Old blobs stay readable as long + * as `unwrapValue` knows how to handle their version prefix. */ +export const ENCRYPTION_VERSION = 1; + +/** All encrypted blobs start with this exact prefix — used by `isEncrypted`. */ +export const ENC_PREFIX = `enc:${ENCRYPTION_VERSION}:`; + +/** AES-GCM standard IV length is 96 bits (12 bytes). Larger IVs are not + * recommended by NIST and would only burn entropy. */ +const IV_LENGTH = 12; + +// ─── Base64 helpers ─────────────────────────────────────────── +// +// We avoid `btoa(String.fromCharCode(...bytes))` because the spread operator +// hits the JS argument limit (~65k) for large records. The manual loop is +// O(n) and works for any size. + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +} + +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; +} + +/** + * TypeScript 5.7+ parameterised Uint8Array with the underlying buffer + * type, which now includes SharedArrayBuffer. Web Crypto's `BufferSource` + * type still expects a plain ArrayBuffer-backed view, so we need to copy + * the bytes through a fresh ArrayBuffer to satisfy the strict type check. + * + * This is a TypeScript-only annoyance — at runtime the call would have + * worked fine with the original Uint8Array. The copy is O(n) and + * negligible for the field sizes we encrypt (< 100 KB typical). + */ +function toBufferSource(bytes: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(bytes.length); + new Uint8Array(buf).set(bytes); + return buf; +} + +// ─── Public API ─────────────────────────────────────────────── + +/** + * Returns true iff `value` is a string carrying the encryption prefix. + * + * Cheap synchronous detection — no decryption attempted. Use this to + * decide whether a field needs to be unwrapped on read, or whether a + * value coming back from a backend pull is already encrypted. + */ +export function isEncrypted(value: unknown): boolean { + return typeof value === 'string' && value.startsWith(ENC_PREFIX); +} + +/** + * Encrypts `value` with `key` and returns the wire-format string. Pass- + * through for `null` / `undefined` so optional-field call sites stay + * concise: + * + * record.title = await wrapValue(record.title, key); + * record.notes = await wrapValue(record.notes, key); // safe even if null + * + * Throws if `key` is unusable (wrong algorithm, wrong usages). Each call + * generates a fresh random IV — never reuse one for the same key. + */ +export async function wrapValue(value: unknown, key: CryptoKey): Promise { + if (value === null || value === undefined) return value; + + const json = JSON.stringify(value); + const plaintext = new TextEncoder().encode(json); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + key, + toBufferSource(plaintext) + ); + + return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct)); +} + +/** + * Decrypts a wire-format string back to its original JS value. Pass- + * through for non-strings, `null`/`undefined`, and any string that + * doesn't carry the encryption prefix — that way `unwrapValue` is safe + * to apply unconditionally to mixed records. + * + * Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed + * blobs, or wrong key. Callers should treat the throw as data corruption + * — there's no soft-recovery path. + */ +export async function unwrapValue(blob: unknown, key: CryptoKey): Promise { + if (!isEncrypted(blob)) return blob; + + const body = (blob as string).slice(ENC_PREFIX.length); + const dotIndex = body.indexOf('.'); + if (dotIndex === -1) { + throw new Error('mana-crypto: malformed encrypted blob (missing iv/ct separator)'); + } + + const iv = base64ToBytes(body.slice(0, dotIndex)); + const ct = base64ToBytes(body.slice(dotIndex + 1)); + + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toBufferSource(iv) }, + key, + toBufferSource(ct) + ); + + const json = new TextDecoder().decode(plaintext); + return JSON.parse(json); +} + +/** + * Generates a fresh AES-GCM-256 key. Used at vault initialisation time + * (Phase 2: server-side; tests: in-memory) to mint the per-user master + * key. The key is `extractable: true` so the server can wrap it with + * the KEK before storing — set to `false` for client-side derived keys + * that should never leave the browser. + */ +export async function generateMasterKey(extractable = true): Promise { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, extractable, [ + 'encrypt', + 'decrypt', + ]); +} + +/** + * Imports a raw 32-byte buffer as an AES-GCM-256 key. Used by the + * Phase 3 client to take the bytes the vault endpoint returns and turn + * them into a non-extractable CryptoKey instance for runtime use. + */ +export async function importMasterKey(rawBytes: Uint8Array): Promise { + if (rawBytes.length !== 32) { + throw new Error(`mana-crypto: expected 32-byte master key, got ${rawBytes.length}`); + } + return crypto.subtle.importKey( + 'raw', + toBufferSource(rawBytes), + { name: 'AES-GCM', length: 256 }, + false, // non-extractable: once it's in the browser, it stays there + ['encrypt', 'decrypt'] + ); +} + +/** + * Exports a key back to its raw 32 bytes. Only works on extractable + * keys; non-extractable keys throw. Used by tests and the Phase 2 + * server-side wrap path. + */ +export async function exportMasterKey(key: CryptoKey): Promise { + const raw = await crypto.subtle.exportKey('raw', key); + return new Uint8Array(raw); +} diff --git a/apps/mana/apps/web/src/lib/data/crypto/index.ts b/apps/mana/apps/web/src/lib/data/crypto/index.ts new file mode 100644 index 000000000..1c6e9ace4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/index.ts @@ -0,0 +1,45 @@ +/** + * Public surface of the data-layer encryption module. + * + * Phase 1 (this commit) ships the foundation only: + * - AES-GCM-256 wrap/unwrap primitives + * - KeyProvider interface with NullKeyProvider as the default + * - Strict allowlist registry of fields-to-encrypt (all `enabled: false`) + * + * No table is actually encrypted yet. Phase 2 wires up the mana-auth + * server vault that mints + serves the per-user master key. Phase 3 + * flips the registry entries to `enabled: true` table by table and + * teaches the Dexie hooks to call wrapValue/unwrapValue on the right + * fields. Phase 4 polishes the UX (settings page, lock state UI). + * + * Importers should pull from this barrel rather than reaching into the + * sub-files directly, so future internal refactors stay invisible. + */ + +export { + ENCRYPTION_VERSION, + ENC_PREFIX, + isEncrypted, + wrapValue, + unwrapValue, + generateMasterKey, + importMasterKey, + exportMasterKey, +} from './aes'; + +export { + type KeyProvider, + MemoryKeyProvider, + setKeyProvider, + getKeyProvider, + getActiveKey, + isVaultUnlocked, +} from './key-provider'; + +export { + type EncryptionConfig, + ENCRYPTION_REGISTRY, + getEncryptedFields, + hasAnyEncryption, + getRegisteredTables, +} from './registry'; 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 new file mode 100644 index 000000000..00cc2d4c4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/key-provider.ts @@ -0,0 +1,134 @@ +/** + * Pluggable master-key provider. + * + * The encryption pipeline (Dexie hooks, liveQuery wrapper) reads the active + * master key through `getActiveKey()` rather than holding a reference itself. + * That indirection lets us swap implementations without touching call sites: + * + * - NullKeyProvider — default. No key available → encryption is a + * no-op. Used during the gradual rollout while + * individual tables are still `enabled: false`, + * and in tests that don't care about crypto. + * - MemoryKeyProvider — holds an unwrapped CryptoKey in memory only. + * Wired up in Phase 3 with the bytes returned by + * the mana-auth `/me/encryption-key` endpoint. + * + * Why an interface and not a global variable? + * - Tests can swap in fixed keys without monkey-patching imports + * - Future PasskeyKeyProvider can replace the in-memory one without + * touching the rest of the data layer + * - Lock/unlock state changes are observable via `onChange`, so the + * UI can react (show "vault locked" overlay, refetch encrypted lists) + */ + +export interface KeyProvider { + /** Returns the active master key, or `null` if the vault is locked + * or no key has been provided yet. Synchronous on purpose so the + * Dexie hooks can call it inline without async overhead. */ + getKey(): CryptoKey | null; + + /** True iff a key is currently available. Cheaper to check than + * `getKey() !== null` if the caller doesn't need the key itself. */ + isUnlocked(): boolean; + + /** 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; +} + +// ─── NullKeyProvider — default ───────────────────────────────── + +/** + * Always-locked provider. Encryption call sites silently skip when this + * is active, so the app keeps running with plaintext data even though + * the encryption code paths are technically wired up. This is the safe + * default during Phase 1 (no tables flipped to `enabled: true` yet). + */ +class NullKeyProvider implements KeyProvider { + getKey(): null { + return null; + } + isUnlocked(): boolean { + return false; + } + onChange(): () => void { + return () => {}; + } +} + +// ─── MemoryKeyProvider — Phase 3 production path ─────────────── + +/** + * Holds a CryptoKey in process memory. The key never touches localStorage, + * IndexedDB, or any other persistent surface — only sessionStorage gets a + * one-bit "is unlocked" sentinel for the UI to read on hard reload (so it + * knows whether to immediately show the locked overlay or wait for the + * vault fetch). + */ +export class MemoryKeyProvider implements KeyProvider { + private key: CryptoKey | null = null; + private listeners = new Set<(unlocked: boolean) => void>(); + + /** Set or clear the active key. `null` locks the vault. */ + setKey(key: CryptoKey | null): void { + const wasUnlocked = this.key !== null; + this.key = key; + const nowUnlocked = key !== null; + + if (typeof window !== 'undefined') { + if (nowUnlocked) sessionStorage.setItem('mana-vault-unlocked', '1'); + else sessionStorage.removeItem('mana-vault-unlocked'); + } + + if (wasUnlocked !== nowUnlocked) { + for (const fn of this.listeners) { + try { + fn(nowUnlocked); + } catch (err) { + // Listeners are UI subscribers — never let one bad listener + // break the lock/unlock cycle. + console.error('[mana-crypto] vault listener threw:', err); + } + } + } + } + + getKey(): CryptoKey | null { + return this.key; + } + + isUnlocked(): boolean { + return this.key !== null; + } + + onChange(listener: (unlocked: boolean) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } +} + +// ─── Module-level active provider ────────────────────────────── + +let _activeProvider: KeyProvider = new NullKeyProvider(); + +/** Replace the active provider. Called once at app boot in Phase 3. */ +export function setKeyProvider(provider: KeyProvider): void { + _activeProvider = provider; +} + +/** Returns the currently-installed provider. */ +export function getKeyProvider(): KeyProvider { + return _activeProvider; +} + +/** Convenience: returns the active key or `null` if locked. */ +export function getActiveKey(): CryptoKey | null { + return _activeProvider.getKey(); +} + +/** Convenience: synchronous lock check. */ +export function isVaultUnlocked(): boolean { + return _activeProvider.isUnlocked(); +} diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts new file mode 100644 index 000000000..1309fae2b --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -0,0 +1,166 @@ +/** + * Encryption registry — single source of truth for which fields on which + * tables get encrypted. + * + * Strict allowlist semantics: anything not listed here stays plaintext. + * Adding a new module = adding an entry here. Forgetting to add a field + * means it ships in plaintext, which is the safer failure mode than the + * inverse (a typo'd field name silently failing to decrypt). + * + * Why a central registry instead of per-module config? + * - One pull request to audit ahead of a release: "what is encrypted?" + * - The Dexie hook in database.ts iterates this map once at startup + * instead of looking up per-module config files at write time + * - Keeps the encryption surface visible to security review without + * hunting through 27 module directories + * + * Phasing: + * `enabled: false` is the safe default for Phase 1. The actual flip + * to `true` happens in Phase 3, table by table, after the server-side + * vault is wired up and the key provider is no longer NullKeyProvider. + * This means Phase 1 can land on main without changing app behaviour. + * + * Field selection rules: + * - Encrypt: user-typed text, transcripts, PII, free-form notes, + * anything that would embarrass or harm the user if it leaked + * - Plaintext: IDs, foreign keys, timestamps, status flags, sort keys, + * enum discriminators, anything indexed for queries + * - When in doubt about a free-form field, encrypt it + * - When in doubt about a structural field, leave it plaintext (the + * query layer needs it) + */ + +export interface EncryptionConfig { + /** Field names that get encrypted on write, decrypted on read. */ + readonly fields: readonly string[]; + /** Phase 1 hard-default: false. Flipped table by table in Phase 3. */ + readonly enabled: boolean; +} + +export const ENCRYPTION_REGISTRY: Record = { + // ─── Chat ──────────────────────────────────────────────── + messages: { enabled: false, fields: ['messageText'] }, + conversations: { enabled: false, fields: ['title'] }, + chatTemplates: { + enabled: false, + fields: ['name', 'description', 'systemPrompt', 'initialQuestion'], + }, + + // ─── Notes ─────────────────────────────────────────────── + notes: { enabled: false, fields: ['title', 'body', 'content'] }, + + // ─── Dreams ────────────────────────────────────────────── + dreams: { enabled: false, fields: ['title', 'content', 'notes'] }, + dreamSymbols: { enabled: false, fields: ['name', 'meaning'] }, + + // ─── Memoro ────────────────────────────────────────────── + memos: { enabled: false, fields: ['title', 'intro', 'transcript'] }, + memories: { enabled: false, fields: ['title', 'content'] }, + + // ─── Contacts ──────────────────────────────────────────── + contacts: { + enabled: false, + fields: [ + 'firstName', + 'lastName', + 'email', + 'phone', + 'mobile', + 'birthday', + 'street', + 'city', + 'postalCode', + 'country', + 'notes', + 'website', + 'linkedin', + 'twitter', + 'instagram', + 'github', + ], + }, + + // ─── Tasks ─────────────────────────────────────────────── + tasks: { enabled: false, fields: ['title', 'description', 'subtasks', 'metadata'] }, + + // ─── Calendar ──────────────────────────────────────────── + events: { enabled: false, fields: ['title', 'description', 'location'] }, + + // ─── Cycles ────────────────────────────────────────────── + cycles: { enabled: false, fields: ['notes'] }, + cycleDayLogs: { enabled: false, fields: ['notes', 'symptoms', 'mood'] }, + + // ─── NutriPhi ──────────────────────────────────────────── + meals: { enabled: false, fields: ['description', 'notes', 'aiAnalysis'] }, + + // ─── Planta ────────────────────────────────────────────── + plants: { enabled: false, fields: ['name', 'notes', 'careNotes'] }, + + // ─── Cards ─────────────────────────────────────────────── + cards: { enabled: false, fields: ['front', 'back', 'notes'] }, + cardDecks: { enabled: false, fields: ['title', 'description'] }, + + // ─── Presi ─────────────────────────────────────────────── + presiDecks: { enabled: false, fields: ['title', 'description'] }, + slides: { enabled: false, fields: ['content', 'notes'] }, + + // ─── Context ───────────────────────────────────────────── + documents: { enabled: false, fields: ['title', 'content', 'body'] }, + + // ─── Storage ───────────────────────────────────────────── + files: { enabled: false, fields: ['name', 'originalName', 'notes'] }, + + // ─── Picture ───────────────────────────────────────────── + images: { enabled: false, fields: ['prompt', 'negativePrompt', 'revisedPrompt', 'notes'] }, + + // ─── Music ─────────────────────────────────────────────── + songs: { enabled: false, fields: ['title', 'artist', 'album', 'lyrics', 'notes'] }, + mukkePlaylists: { enabled: false, fields: ['name', 'description'] }, + + // ─── Questions ─────────────────────────────────────────── + questions: { enabled: false, fields: ['title', 'body', 'notes'] }, + answers: { enabled: false, fields: ['body'] }, + + // ─── Events (social gatherings) ────────────────────────── + socialEvents: { enabled: false, fields: ['title', 'description', 'notes'] }, + eventGuests: { enabled: false, fields: ['name', 'email', 'phone', 'notes'] }, + + // ─── Finance ───────────────────────────────────────────── + transactions: { enabled: false, fields: ['description', 'notes', 'merchant'] }, + + // ─── uLoad ─────────────────────────────────────────────── + links: { enabled: false, fields: ['title', 'description', 'targetUrl'] }, + manaLinks: { enabled: false, fields: ['label', 'url', 'notes'] }, + + // ─── Inventar ──────────────────────────────────────────── + invItems: { enabled: false, fields: ['name', 'description', 'notes'] }, +}; + +/** + * Returns the field allowlist for `tableName`, or `null` if the table is + * either not registered, currently disabled, or has an empty field list. + * Hot-path helper used by the Dexie hooks — must stay synchronous and + * allocation-free for the common (non-encrypted) case. + */ +export function getEncryptedFields(tableName: string): readonly string[] | null { + const config = ENCRYPTION_REGISTRY[tableName]; + if (!config || !config.enabled || config.fields.length === 0) return null; + return config.fields; +} + +/** True if at least one table is currently flipped to encrypted. Used by + * the Phase 3 boot path to decide whether to fetch the master key at + * all — no point asking the server for a key when nothing uses it. */ +export function hasAnyEncryption(): boolean { + for (const config of Object.values(ENCRYPTION_REGISTRY)) { + if (config.enabled) return true; + } + return false; +} + +/** All table names that have an encryption entry, regardless of whether + * they're currently enabled. Used by the rollout audit and the + * DATA_LAYER_AUDIT.md doc to confirm coverage. */ +export function getRegisteredTables(): string[] { + return Object.keys(ENCRYPTION_REGISTRY); +}