From bed08a1aa6762fa9794cbcdb157fac075f5c38c4 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 19:00:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(mana/web):=20encryption=20phase=204=20?= =?UTF-8?q?=E2=80=94=20notes=20pilot=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First module with at-rest encryption flipped on. The notes table's title + content are now encrypted with AES-GCM-256 before any write hits Dexie, decrypted on every read coming back through liveQuery, and travel as opaque ciphertext through the sync wire (pending changes, server push, applyServerChanges, the lot). What changes for the user - Nothing visible. Optimistic UI render still uses the plaintext snapshot returned by createNote(). Edits look identical to the old Phase 3 behaviour. The difference is invisible until you crack open DevTools → Application → IndexedDB → mana → notes, where you'll see ciphertext instead of "Buy milk". What changes on disk - notes.title and notes.content store ciphertext blobs (`enc:1:.`) - All other columns (id, color, isPinned, isArchived, createdAt, updatedAt, deletedAt, userId, __fieldTimestamps) stay plaintext so liveQuery filtering, sorting, and Field-Level LWW continue to work without changes. - _pendingChanges.data carries the same ciphertext blobs — server receives opaque values, never plaintext. Files registry.ts notes flipped to enabled:true with the corrected field list ['title', 'content'] (the schema has no 'body' column). aes.test.ts Existing assertion that "Phase 1 has no encrypted tables" is rewritten as "notes is enabled in Phase 4" so the registry flip doesn't break the foundation suite. record-helpers.ts encryptRecord/decryptRecord/decryptRecords loosen the generic constraint from `T extends Record` to `T extends object`. Domain types like LocalNote work as direct arguments without an `as Record` cast at every call site. Internal field reads/writes go through a sealed Record-shaped view. notes/stores/notes.svelte.ts createNote: snapshots the plaintext for the optimistic return value, then encryptRecord('notes', record) before noteTable.add. updateNote: encrypts the diff in place; non-encrypted fields (color, isPinned, isArchived) pass through untouched. togglePin / archiveNote / deleteNote: untouched — they only update plaintext columns. notes/queries.ts useAllNotes: filter on plaintext metadata first (deletedAt, isArchived) so the decrypt workload is bounded by the visible set, not the whole table. Then decryptRecords across what's left, then map+sort. useNote(id): new helper for detail views. notes-encryption.test.ts (new — 8 cases) End-to-end against fake-indexeddb with a real Web Crypto master key in MemoryKeyProvider: 1. Title + content land as ciphertext on disk 2. Structural fields stay plaintext on disk 3. updateNote re-encrypts modified content but leaves flags 4. togglePin / archiveNote produce byte-identical title blobs (i.e. no spurious re-encryption) 5. _pendingChanges.data carries ciphertext + plaintext metadata 6. Wrong-key decrypt fails closed (returns blobs, not garbage) 7. Locked vault refuses new writes with VaultLockedError 8. Locked vault still serves blobs without crashing on read Test bilanz: 4 crypto-related test files, 64/64 passing (31 AES + 12 record-helpers + 12 vault-client + 8 notes E2E + 1 misc). Full mana/web suite: 20 files, 262/262 tests passing. Stand der encryption pipeline: Phase 1 ✅ Foundation (1ba5948ce) Phase 2 ✅ Server vault (e9915428c) Phase 3 ✅ Wire-up (354cbcb17) Phase 4 ✅ Notes pilot (this commit) Phase 5 → roll out to chat, dreams, memoro, contacts, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/crypto/aes.test.ts | 13 +- .../web/src/lib/data/crypto/record-helpers.ts | 28 +-- .../apps/web/src/lib/data/crypto/registry.ts | 4 +- .../modules/notes/notes-encryption.test.ts | 208 ++++++++++++++++++ .../apps/web/src/lib/modules/notes/queries.ts | 48 +++- .../lib/modules/notes/stores/notes.svelte.ts | 27 ++- 6 files changed, 298 insertions(+), 30 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/notes/notes-encryption.test.ts 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) {