mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(crypto): restore at-rest encryption sweep (lost to revert)
The 2e-followup originally shipped insidec413ab7dd(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 atc413ab7dd(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) <noreply@anthropic.com>
This commit is contained in:
parent
4250747523
commit
af4fd27769
2 changed files with 167 additions and 0 deletions
158
apps/mana/apps/web/src/lib/data/crypto/at-rest-sweep.ts
Normal file
158
apps/mana/apps/web/src/lib/data/crypto/at-rest-sweep.ts
Normal file
|
|
@ -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<number> {
|
||||||
|
const rows = (await db.table(tableName).toArray()) as Record<string, unknown>[];
|
||||||
|
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<void> {
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,15 @@
|
||||||
if (state.status === 'unlocked') {
|
if (state.status === 'unlocked') {
|
||||||
console.info('[mana-crypto] vault unlocked successfully');
|
console.info('[mana-crypto] vault unlocked successfully');
|
||||||
needsRecoveryCode = false;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (state.status === 'awaiting-recovery-code') {
|
if (state.status === 'awaiting-recovery-code') {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue