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 index c6e826ca6..1fdbba3cf 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/aes.test.ts @@ -250,14 +250,19 @@ describe('encryption registry', () => { }); it('returns null for registered tables that are disabled', () => { - // Phase 1: every entry is enabled:false + // `notes` was flipped to enabled:true in Phase 4 β€” pick two + // tables that are still on the safe default for the assertion. 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('returns the field list for tables that are enabled', () => { + // Phase 4: notes is the pilot, expected to be flipped on. + expect(getEncryptedFields('notes')).toEqual(['title', 'content']); + }); + + it('hasAnyEncryption returns true once at least one table is enabled', () => { + expect(hasAnyEncryption()).toBe(true); }); it('getRegisteredTables lists every table in the registry', () => { 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 991699e7f..140e0afdd 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 @@ -55,19 +55,22 @@ export class VaultLockedError extends Error { * 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. + * + * Generic constraint: `T extends object` so domain interfaces (LocalNote, + * LocalMessage, etc.) can be passed directly without an explicit + * `as Record` cast at every call site. Internal + * field reads/writes go through a Record view. */ -export async function encryptRecord>( - tableName: string, - record: T -): Promise { +export async function encryptRecord(tableName: string, record: T): Promise { const fields = getEncryptedFields(tableName); if (!fields) return record; + const view = record as unknown as 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]; + const value = view[field]; if (value === null || value === undefined) continue; // Already encrypted? Skip β€” happens when applyServerChanges // hands a record (with encrypted blobs from the wire) back @@ -81,8 +84,7 @@ export async function encryptRecord>( if (!key) throw new VaultLockedError(tableName); for (const field of todo) { - const wrapped = await wrapValue(record[field], key); - (record as Record)[field] = wrapped; + view[field] = await wrapValue(view[field], key); } return record; } @@ -96,21 +98,19 @@ export async function encryptRecord>( * 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 { +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 + const view = record as unknown as Record; for (const field of fields) { - const value = record[field]; + const value = view[field]; if (typeof value !== 'string' || !isEncrypted(value)) continue; try { - (record as Record)[field] = await unwrapValue(value, key); + view[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 @@ -128,7 +128,7 @@ export async function 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>( +export async function decryptRecords( tableName: string, records: (T | null | undefined)[] ): Promise { diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 1309fae2b..4aba474fd 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -47,7 +47,9 @@ export const ENCRYPTION_REGISTRY: Record = { }, // ─── Notes ─────────────────────────────────────────────── - notes: { enabled: false, fields: ['title', 'body', 'content'] }, + // Phase 4 pilot β€” first table flipped to enabled:true. The schema + // uses `title` + `content` (no separate `body` column). + notes: { enabled: true, fields: ['title', 'content'] }, // ─── Dreams ────────────────────────────────────────────── dreams: { enabled: false, fields: ['title', 'content', 'notes'] }, diff --git a/apps/mana/apps/web/src/lib/modules/notes/notes-encryption.test.ts b/apps/mana/apps/web/src/lib/modules/notes/notes-encryption.test.ts new file mode 100644 index 000000000..29ae754c0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/notes/notes-encryption.test.ts @@ -0,0 +1,208 @@ +/** + * Notes encryption integration test β€” Phase 4 pilot. + * + * Exercises the full writeβ†’storeβ†’read pipeline against fake-indexeddb, + * with a real Web Crypto master key plumbed through the MemoryKeyProvider. + * The goal is to lock in three properties: + * + * 1. What lands on disk is CIPHERTEXT for title/content, plaintext for + * everything else (id, color, isPinned, isArchived, timestamps). + * 2. liveQuery results coming back from useAllNotes / useNote are + * transparently decrypted β€” the UI sees Note objects with the + * original strings. + * 3. The Dexie pending-change tracker captures the same ciphertext + * blob, so what gets pushed to the server is also opaque. + * + * Without this test the registry flip from Phase 4.1 is just a config + * change with no behavioural guarantee. With it, any future regression + * (registry typo, hook scope leak, accidental decrypt-on-write) blows + * up immediately. + */ + +import 'fake-indexeddb/auto'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '$lib/data/database'; +import { setCurrentUserId } from '$lib/data/current-user'; +import { + generateMasterKey, + MemoryKeyProvider, + setKeyProvider, + isEncrypted, +} from '$lib/data/crypto'; +import { notesStore } from './stores/notes.svelte'; +import type { LocalNote } from './types'; + +// 50ms is enough for the fire-and-forget setTimeout(0) inside the +// Dexie creating-hook (trackPendingChange) to flush before assertions. +const flushAsync = () => new Promise((r) => setTimeout(r, 50)); + +let provider: MemoryKeyProvider; + +beforeEach(async () => { + const key = await generateMasterKey(); + provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + setCurrentUserId('test-user'); + + // Each test starts with empty state. Clear plus pending-change + // bookkeeping so cross-test contamination is impossible. + await db.table('notes').clear(); + await db.table('_pendingChanges').clear(); + await db.table('_activity').clear(); +}); + +describe('notes encryption pilot', () => { + it('stores title + content as ciphertext on disk', async () => { + const created = await notesStore.createNote({ + title: 'My private idea', + content: 'Do not show this to the family laptop', + }); + + // The optimistic snapshot returned to the UI is plaintext β€” + // that's what the optimistic render uses. + expect(created.title).toBe('My private idea'); + expect(created.content).toBe('Do not show this to the family laptop'); + + // What lives in IndexedDB after the await is ciphertext. + const stored = (await db.table('notes').get(created.id)) as LocalNote; + expect(stored).toBeDefined(); + expect(isEncrypted(stored.title)).toBe(true); + expect(isEncrypted(stored.content)).toBe(true); + expect(stored.title).not.toContain('private'); + expect(stored.content).not.toContain('family'); + }); + + it('leaves structural fields plaintext on disk', async () => { + const created = await notesStore.createNote({ + title: 'Pinned note', + content: 'irrelevant', + color: '#3b82f6', + }); + const stored = (await db.table('notes').get(created.id)) as LocalNote; + + // id, color, flags, timestamps remain queryable plaintext. + expect(stored.id).toBe(created.id); + expect(stored.color).toBe('#3b82f6'); + expect(stored.isPinned).toBe(false); + expect(stored.isArchived).toBe(false); + expect(stored.userId).toBe('test-user'); + // Auto-stamped __fieldTimestamps stays plaintext too β€” LWW relies on it. + expect((stored as unknown as Record).__fieldTimestamps).toBeDefined(); + }); + + it('updates encrypt the modified content fields, leave flags untouched', async () => { + const created = await notesStore.createNote({ title: 'orig title', content: 'orig body' }); + + await notesStore.updateNote(created.id, { + title: 'new title', + content: 'new body', + }); + + const stored = (await db.table('notes').get(created.id)) as LocalNote; + expect(isEncrypted(stored.title)).toBe(true); + expect(isEncrypted(stored.content)).toBe(true); + expect(stored.title).not.toContain('new'); + // Updated flag stays plaintext + expect(stored.isPinned).toBe(false); + }); + + it('togglePin and archiveNote do not touch encrypted fields', async () => { + const created = await notesStore.createNote({ title: 'My note', content: 'My body' }); + const before = (await db.table('notes').get(created.id)) as LocalNote; + const titleBlob = before.title; + const contentBlob = before.content; + + await notesStore.togglePin(created.id); + const afterPin = (await db.table('notes').get(created.id)) as LocalNote; + // Title/content blobs are byte-identical β€” no re-encryption happened. + expect(afterPin.title).toBe(titleBlob); + expect(afterPin.content).toBe(contentBlob); + expect(afterPin.isPinned).toBe(true); + + await notesStore.archiveNote(created.id); + const afterArchive = (await db.table('notes').get(created.id)) as LocalNote; + expect(afterArchive.title).toBe(titleBlob); + expect(afterArchive.content).toBe(contentBlob); + expect(afterArchive.isArchived).toBe(true); + }); + + it('the pending-change record carries ciphertext, not plaintext', async () => { + const created = await notesStore.createNote({ + title: 'Buy birthday present', + content: 'For Marie', + }); + await flushAsync(); // wait for setTimeout(0) in trackPendingChange + + const pending = await db + .table('_pendingChanges') + .filter((p: { recordId?: string }) => p.recordId === created.id) + .toArray(); + + expect(pending).toHaveLength(1); + const change = pending[0] as { op: string; data: Record }; + expect(change.op).toBe('insert'); + expect(typeof change.data.title).toBe('string'); + expect(typeof change.data.content).toBe('string'); + expect(isEncrypted(change.data.title)).toBe(true); + expect(isEncrypted(change.data.content)).toBe(true); + expect(change.data.title).not.toContain('birthday'); + expect(change.data.content).not.toContain('Marie'); + // Plaintext metadata flows through unchanged + expect(change.data.isPinned).toBe(false); + expect(change.data.userId).toBe('test-user'); + }); + + it('a record encrypted with one key cannot be read with another', async () => { + const created = await notesStore.createNote({ title: 'Secret', content: 'Sauce' }); + const stored = (await db.table('notes').get(created.id)) as LocalNote; + + // Swap to a different key + const otherKey = await generateMasterKey(); + provider.setKey(otherKey); + + // decryptRecords logs the failure but leaves blobs in place. + // Verify the title is STILL the encrypted blob (i.e. not silently + // returning garbage plaintext). + const { decryptRecords } = await import('$lib/data/crypto'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const [decrypted] = await decryptRecords('notes', [stored]); + errSpy.mockRestore(); + expect(decrypted).toBeDefined(); + // Both fields stayed encrypted because both failed to unwrap. + expect(isEncrypted(decrypted.title)).toBe(true); + expect(isEncrypted(decrypted.content)).toBe(true); + }); + + it('locked vault refuses to encrypt new writes', async () => { + provider.setKey(null); + await expect( + notesStore.createNote({ title: 'cannot write', content: 'because locked' }) + ).rejects.toThrow(/vault is locked/); + }); + + it('locked vault still serves blobs (no plaintext leak, no crash)', async () => { + // Write while unlocked + const created = await notesStore.createNote({ title: 'before lock', content: 'body' }); + + // Lock the vault + provider.setKey(null); + + // Direct DB read still returns the encrypted blob β€” no exception + const stored = (await db.table('notes').get(created.id)) as LocalNote; + expect(isEncrypted(stored.title)).toBe(true); + + // decryptRecords with locked vault returns the blob unchanged + const { decryptRecords } = await import('$lib/data/crypto'); + const [out] = await decryptRecords('notes', [stored]); + expect(out).toBeDefined(); + expect(isEncrypted(out.title)).toBe(true); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/notes/queries.ts b/apps/mana/apps/web/src/lib/modules/notes/queries.ts index 851bc9660..4a8964a0e 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/queries.ts @@ -1,9 +1,22 @@ /** * Reactive Queries & Pure Helpers for Notes module. + * + * Phase 4 encryption pilot: notes are encrypted at rest. Reads decrypt + * on the fly via decryptRecords() before mapping to the public Note + * shape. Sort and filter operations all run against PLAINTEXT metadata + * (`isPinned`, `isArchived`, `updatedAt`, `deletedAt`) so the indexes + * still work without ever touching ciphertext. + * + * Search: keep the existing in-memory `searchNotes()` helper. It runs + * AFTER decryption (against the public Note objects), so a free-text + * search through ~hundreds of notes still works in the UI without + * leaking anything to the server. Real searchable-encrypted index is + * a future concern only if note volume per user grows past that. */ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalNote, Note } from './types'; // ─── Type Converters ─────────────────────────────────────── @@ -25,17 +38,36 @@ export function toNote(local: LocalNote): Note { export function useAllNotes() { return useLiveQueryWithDefault(async () => { - const locals = await db.table('notes').toArray(); - return locals - .filter((n) => !n.deletedAt && !n.isArchived) - .map(toNote) - .sort((a, b) => { - if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; - return b.updatedAt.localeCompare(a.updatedAt); - }); + // Filter on plaintext metadata first β€” none of these fields are + // in the encryption registry, so they stay readable even with + // the vault locked. Cuts the decrypt workload to only what the + // view actually renders. + const visible = (await db.table('notes').toArray()).filter( + (n) => !n.deletedAt && !n.isArchived + ); + // Locked vault returns the blobs untouched so the UI can render + // a "πŸ”’" placeholder where title/content would be. + const decrypted = await decryptRecords('notes', visible); + return decrypted.map(toNote).sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + return b.updatedAt.localeCompare(a.updatedAt); + }); }, [] as Note[]); } +/** Single note by id, decrypted. Used by detail views. */ +export function useNote(id: string) { + return useLiveQueryWithDefault( + async () => { + const local = await db.table('notes').get(id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('notes', [local]); + return decrypted ? toNote(decrypted) : null; + }, + null as Note | null + ); +} + // ─── Pure Helpers ────────────────────────────────────────── /** Search notes by title and content */ diff --git a/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts b/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts index 3ba2dfe5d..cbf6709f7 100644 --- a/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/notes/stores/notes.svelte.ts @@ -1,10 +1,23 @@ /** * Notes Store β€” Mutation-Only Service + * + * Phase 4 encryption pilot: title + content are encrypted at rest. + * Every write that touches one of those fields is routed through + * encryptRecord() before hitting Dexie. The Dexie hook then sees the + * already-encrypted blob and stores it as opaque text β€” the rest of + * the sync layer (pending changes, LWW, server push) handles it + * exactly like any other string column. + * + * Updates that touch ONLY plaintext fields (togglePin, archiveNote, + * deleteNote) bypass encryption automatically because encryptRecord + * skips fields not in the registry's allowlist for the table β€” no + * special-casing needed at the call sites. */ import { noteTable } from '../collections'; import { toNote } from '../queries'; import type { LocalNote } from '../types'; +import { encryptRecord } from '$lib/data/crypto'; export const notesStore = { async createNote(data: { title?: string; content?: string; color?: string | null }) { @@ -17,18 +30,26 @@ export const notesStore = { isArchived: false, }; + // Plaintext copy returned to the caller for optimistic UI render β€” + // the persisted record (and the sync wire) carries ciphertext. + const plaintextSnapshot = toNote(newLocal); + await encryptRecord('notes', newLocal); await noteTable.add(newLocal); - return toNote(newLocal); + return plaintextSnapshot; }, async updateNote( id: string, data: Partial> ) { - await noteTable.update(id, { + // encryptRecord mutates the diff in place. Fields not in the notes + // allowlist (color, isPinned, isArchived) pass through untouched. + const diff: Partial = { ...data, updatedAt: new Date().toISOString(), - }); + }; + await encryptRecord('notes', diff); + await noteTable.update(id, diff); }, async deleteNote(id: string) {