From af4fd27769389b147821ce1e7e35d0877a2bfa36 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 22 Apr 2026 22:42:53 +0200 Subject: [PATCH] feat(crypto): restore at-rest encryption sweep (lost to revert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2e-followup originally shipped inside c413ab7dd (misattributed to a test(mana-research) commit via lint-staged race). A later revert (c31dcdd66) undid that commit, and the re-apply (3a7bc7f1c) only restored the mana-research test files — dropping this at-rest-sweep payload. This commit puts it back cleanly with the correct message. - lib/data/crypto/at-rest-sweep.ts: post-vault-unlock one-shot sweep that iterates every ENCRYPTION_REGISTRY table with enabled:true and re-saves every row through encryptRecord(). Per-table localStorage sentinel for idempotency; change-tracking suppressed via beginApplyingTables so sync isn't flooded with re-encryption writes. Fire-and-forget from the caller; idempotent inside each row (isEncrypted gate in encryptRecord skips already-wrapped fields). - routes/+layout.svelte: after vaultClient.unlock() returns 'unlocked', dynamically import the sweep module and fire it. Same lazy-load pattern the rest of the post-unlock wiring uses. Plan doc's shipping-log entry stays pointed at c413ab7dd (the original commit) since that's where the history trail starts, but this commit is the one currently on main. Both are logged in the attribution notes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/data/crypto/at-rest-sweep.ts | 158 ++++++++++++++++++ apps/mana/apps/web/src/routes/+layout.svelte | 9 + 2 files changed, 167 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/data/crypto/at-rest-sweep.ts diff --git a/apps/mana/apps/web/src/lib/data/crypto/at-rest-sweep.ts b/apps/mana/apps/web/src/lib/data/crypto/at-rest-sweep.ts new file mode 100644 index 000000000..796bea221 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/crypto/at-rest-sweep.ts @@ -0,0 +1,158 @@ +/** + * One-shot at-rest encryption sweep. + * + * The Phase 2e encryption flip (docs/plans/space-scoped-data-model.md + * §2e) turned `enabled: true` on globalTags / tagGroups / + * workbenchScenes / aiMissions. Because `decryptRecords` is lenient + * (it skips fields that aren't already encrypted), rows written BEFORE + * the flip stay readable but remain plaintext at rest — a weakened + * security posture if the user's IndexedDB is ever inspected. + * + * This sweep closes that gap: after login (when the vault is + * unlocked) we iterate every row in every table that currently has + * encryption enabled AND hasn't been swept before, re-save it through + * `encryptRecord`, and mark the table done via a localStorage + * sentinel. + * + * Key design points: + * + * - **Per-table sentinel**: if a new table flips to enabled:true in + * the future, only that table is swept on the next run. Already- + * swept tables aren't touched. + * - **Change-tracking suppression**: writes inside the sweep go + * through `beginApplyingTables()` so the Dexie hook skips the + * `_pendingChanges` insert — we don't want to fire 100+ sync pushes + * for a re-encryption that never changed field values. + * - **Idempotent inside each row**: `encryptRecord` checks + * `isEncrypted(value)` before wrapping, so a row with 2 of 3 + * designated fields already encrypted (partial prior sweep, mixed + * boot state) gets only the remaining field wrapped. + * - **Fire-and-forget at call site**: the sweep is async and logs + * its progress; callers don't await it. A failed sweep is never + * fatal to the boot path. + */ + +import Dexie from 'dexie'; +import { db, beginApplyingTables } from '../database'; +import { isVaultUnlocked } from './key-provider'; +import { ENCRYPTION_REGISTRY } from './registry'; +import { encryptRecord } from './record-helpers'; + +const SENTINEL_PREFIX = 'mana:crypto:at-rest-sweep'; +const SENTINEL_VERSION = 'v1'; + +function sentinelKey(tableName: string): string { + return `${SENTINEL_PREFIX}:${tableName}:${SENTINEL_VERSION}:done`; +} + +function hasSwept(tableName: string): boolean { + if (typeof localStorage === 'undefined') return true; // SSR or test env — skip + try { + return localStorage.getItem(sentinelKey(tableName)) !== null; + } catch { + return true; + } +} + +function markSwept(tableName: string, rowCount: number): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem( + sentinelKey(tableName), + JSON.stringify({ at: new Date().toISOString(), rows: rowCount }) + ); + } catch { + /* storage quota — the sweep is a one-time optimisation, not load-bearing */ + } +} + +/** + * Sweep a single table: re-save every non-deleted row through + * `encryptRecord` so any plaintext fields from before the encryption + * flip get wrapped. Returns the number of rows touched. + */ +async function sweepTable(tableName: string): Promise { + const rows = (await db.table(tableName).toArray()) as Record[]; + if (rows.length === 0) return 0; + + const dispose = beginApplyingTables([tableName]); + try { + let touched = 0; + for (const row of rows) { + if (row.deletedAt) continue; + // encryptRecord mutates in place; isEncrypted() gate inside + // means fields already encrypted stay untouched. + await encryptRecord(tableName, row); + // put() overwrites the row — safe because we just mutated the + // same primary key. Dexie's default keyPath is 'id'; every + // Mana record schema uses that. + await db.table(tableName).put(row); + touched++; + } + return touched; + } finally { + dispose(); + } +} + +/** + * Run the sweep across every currently-enabled encryption target that + * hasn't been swept on this device before. Safe to call on every + * unlock — already-swept tables short-circuit via their localStorage + * sentinel. + */ +export async function runAtRestEncryptSweep(): Promise { + if (!isVaultUnlocked()) { + console.warn('[mana-crypto:at-rest-sweep] vault locked, skipping — re-run after unlock'); + return; + } + + const targets = Object.entries(ENCRYPTION_REGISTRY) + .filter(([, cfg]) => cfg.enabled && cfg.fields.length > 0) + .map(([tableName]) => tableName) + .filter((tableName) => !hasSwept(tableName)); + + if (targets.length === 0) return; // everything swept already + + console.info( + `[mana-crypto:at-rest-sweep] starting for ${targets.length} table(s): ${targets.join(', ')}` + ); + + for (const tableName of targets) { + try { + const touched = await sweepTable(tableName); + markSwept(tableName, touched); + if (touched > 0) { + console.info(`[mana-crypto:at-rest-sweep] ${tableName}: re-saved ${touched} row(s)`); + } + } catch (err) { + if (err instanceof Dexie.DexieError) { + console.error(`[mana-crypto:at-rest-sweep] ${tableName} failed (Dexie): ${err.message}`); + } else { + console.error(`[mana-crypto:at-rest-sweep] ${tableName} failed:`, err); + } + // Don't mark swept — the next unlock will retry this table. + } + } +} + +/** + * Test / recovery helper: clears every sweep sentinel so the next + * `runAtRestEncryptSweep()` re-processes all enabled tables. No UI + * hooks this up; exported for integration tests + manual recovery + * via the browser console. + */ +export function resetSweepSentinels(): void { + if (typeof localStorage === 'undefined') return; + const prefix = `${SENTINEL_PREFIX}:`; + try { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) keys.push(k); + } + for (const k of keys) localStorage.removeItem(k); + } catch { + /* ignore */ + } +} diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index 3cf4f938f..0246bf906 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -71,6 +71,15 @@ if (state.status === 'unlocked') { console.info('[mana-crypto] vault unlocked successfully'); needsRecoveryCode = false; + // Post-unlock: run the one-shot at-rest encryption + // sweep over tables whose encryption was flipped + // after they already had plaintext rows. Guarded by + // a per-table localStorage sentinel so it's idempotent + // and cheap on every subsequent unlock. Fire-and- + // forget — a failed sweep logs but never blocks. + void import('$lib/data/crypto/at-rest-sweep').then(({ runAtRestEncryptSweep }) => + runAtRestEncryptSweep() + ); return; } if (state.status === 'awaiting-recovery-code') {