feat(crypto): Phase 2e — flip encryption on for tags/scenes/missions

Turns on at-rest encryption for the four tables staged in Phase 2a.
New writes now encrypt the user-typed fields; future code paths read
via decryptRecords as normal (the modules already call decrypt on
read, no changes needed).

Flipped:
- globalTags.name      — tag names can leak categorization intent
- tagGroups.name       — same
- workbenchScenes.name/description — scene labels often encode
                                     Space-specific context
- aiMissions.title/conceptMarkdown/objective — mission configuration
                                               is user-authored

Deliberately unchanged:
- color / icon / groupId / sortOrder / openApps / wallpaper /
  scopeTagIds / cadence / state / agentId — all structural, indexed,
  or FK data needed for query paths
- agents.name stays plaintext per the prior design note (Actor
  displayName cache key)

Migration approach — pre-live lenient: decryptRecords skips values
that aren't encrypted (isEncrypted gate in record-helpers.ts:256), so
existing plaintext rows stay readable after the flip. New writes
encrypt; existing rows get encrypted organically as the user edits
them. No Dexie migration needed. A post-login "encrypt-at-rest
sweep" over pre-existing rows is a follow-up if hard at-rest coverage
is required before launch.

Crypto audit: 196 Dexie tables (95 encrypted, +4 vs 91 before),
101 allowlisted plaintext. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 18:13:34 +02:00
parent 0f8fbb381b
commit 09e6a8b9df

View file

@ -574,17 +574,14 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
agents: { enabled: true, fields: ['systemPrompt', 'memory'] },
// ─── AI Missions ─────────────────────────────────────────
// docs/plans/space-scoped-data-model.md §2a — declared with
// enabled:false during prep so the audit script is happy; flips to
// true in 2c alongside the Dexie v35 encryption migration.
//
// docs/plans/space-scoped-data-model.md §2e — encryption enabled.
// User-typed content on missions: `title` (display label the user
// types at create time), `conceptMarkdown` (free-form context the
// planner reads), `objective` (the actionable goal string). State,
// cadence, inputs (FK-only), nextRunAt, iterations, agentId all
// stay plaintext — needed for the Runner's "due now" index walk
// and mission-detail filters.
aiMissions: { enabled: false, fields: ['title', 'conceptMarkdown', 'objective'] },
aiMissions: { enabled: true, fields: ['title', 'conceptMarkdown', 'objective'] },
// ─── User-level Tag Presets ──────────────────────────────
// Named templates the user applies when creating a new Space. The
@ -598,27 +595,32 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
userTagPresets: { enabled: true, fields: ['name', 'tags'] },
// ─── Tags (shared-stores) ────────────────────────────────
// docs/plans/space-scoped-data-model.md §2a — declared with
// enabled:false during prep; flips to true in 2c. Tag names like
// "Therapie" or "Finanzen-privat" can leak personal categorization,
// so they belong under encryption. `color` + `icon` + `groupId` +
// `sortOrder` stay plaintext: they're visual metadata + the group
// FK, none of which leak sensitive taxonomy. `name` is NOT indexed
// for .where() lookups today, so encrypting it is safe — dedupe-
// within-space lookups go through the new [spaceId+name] index after
// Phase 2b and run over already-decrypted rows in the scoped store.
globalTags: { enabled: false, fields: ['name'] },
tagGroups: { enabled: false, fields: ['name'] },
// docs/plans/space-scoped-data-model.md §2e — encryption enabled.
// Tag names like "Therapie" or "Finanzen-privat" can leak personal
// categorization. `color` + `icon` + `groupId` + `sortOrder` stay
// plaintext: they're visual metadata + the group FK, none of which
// leak sensitive taxonomy. `name` is NOT indexed for .where()
// lookups today, so encrypting it is safe — dedupe-within-space
// lookups go through the [spaceId+name] index from 2b and run over
// already-decrypted rows in the scoped store.
//
// Pre-live migration note: decryptRecords is lenient (isEncrypted()
// gate skips plaintext values), so existing rows from before the
// flip stay readable. New writes encrypt; existing rows get
// encrypted the next time they're edited. A post-login
// "encrypt-at-rest sweep" over the pre-existing rows is a Phase 2e
// follow-up if we want hard at-rest coverage before launch.
globalTags: { enabled: true, fields: ['name'] },
tagGroups: { enabled: true, fields: ['name'] },
// ─── Workbench Scenes ────────────────────────────────────
// docs/plans/space-scoped-data-model.md §2a — declared with
// enabled:false during prep; flips to true in 2c. `name` is the
// user-visible scene label ("Heute", "Q2-Launch") and `description`
// is the short subtitle — both are user-typed free text that can
// leak Space-specific context. openApps / order / wallpaper /
// viewingAsAgentId / scopeTagIds stay plaintext (structural /
// indexed / foreign-key data).
workbenchScenes: { enabled: false, fields: ['name', 'description'] },
// docs/plans/space-scoped-data-model.md §2e — encryption enabled.
// `name` is the user-visible scene label ("Heute", "Q2-Launch")
// and `description` is the short subtitle — both are user-typed
// free text that can leak Space-specific context. openApps /
// order / wallpaper / viewingAsAgentId / scopeTagIds stay
// plaintext (structural / indexed / foreign-key data).
workbenchScenes: { enabled: true, fields: ['name', 'description'] },
// ─── Articles (Pocket-style read-it-later) ──────────────
// Reading-behaviour data — same sensitivity class as newsArticles.