Status: Complete. Fail-closed locked writes, a durable cross-database migration journal, and production disable/rekey wiring (Phase 4, issue #338) all ship. Interrupted migrations are resumable via a dedicated recovery UX. Remaining: E2E test coverage for the disable/rotate/recovery round trips (unit + component tests only so far).
Feature flag: enableIdbAtRestEncryption (on by default since v1.23 β manage via Settings β Privacy)
Tracking: SEC-3 (Master Plan Phase 2 delivery); Phase 4 production wiring: issue #338
WorldScript Studio stores project data in IndexedDB. API keys use a separate encrypted-secret mechanism, routed through storageService to services/storage/idbKeyStore.ts (random, non-extractable AES-GCM key β see the Tauri Desktop Layer section below for the full call path). Optional IDB at-rest encryption uses a passphrase-derived AES-256-GCM key for the primary project, settings, snapshot, image, Codex, RAG, and binder-asset persistence paths. Other persistence surfaces must be inventoried and integrated through the same policy before a blanket βall IndexedDB dataβ claim is valid.
The encryption service is gated behind featureFlags.enableIdbAtRestEncryption (on by default since v1.23 β manage in Settings β Privacy β "Encrypt project data at rest"):
IdbUnlockModalβ prompts for passphrase on cold start when flag is on; rate-limiting: 3 failures β 5 s lockout, 6 failures β 30 s lockout- Session lock β
lockSession()clears the in-memory key; "Lock Session" button in Settings β Privacy - Locked writes fail closed β when a passphrase sentinel exists but no runtime key is available, protected reads and writes return a typed locked error rather than storing plaintext.
- Disable and passphrase rotation β
PassphraseModal's'disable'/'rotate'modes driveclearIdbPassphrase()/rotateIdbPassphrase(), which run a full journal-backed migration (services/storage/encryptionMigrationOrchestrator.ts) across every primary and secondary protected store, verify it, and only then touch the passphrase sentinel. A live progress bar shows per-store migration/verification progress. - Cross-tab write admission β
services/storage/protectedWriteAdmission.ts(Web Locks API) admits ordinary protected writes/deletes in shared mode and migration batches in exclusive mode, closing the write-vs-migration TOCTOU race a standalone journal read alone could not. - Interrupted-migration recovery β if a disable/rotate migration is interrupted (reload/crash) before reaching
'completed',EncryptionRecoveryModal(App.tsx startup guard, takes priority overIdbUnlockModal) lets the user re-enter their passphrase(s) to resume viaresumeEncryptionMigration(). A'recovery-required'journal (the migration's own verification found an inconsistency) is surfaced as a distinct, honest stuck state requiring manual/support recovery β never auto-fixed.
The authoritative lifecycle states, transition rules, and journal requirements are in ADR 0018.
| Threat | Addressed |
|---|---|
| Physical access to browser profile directory | β Encrypted blobs unreadable without passphrase |
| Malicious browser extension reading IDB | β Same β ciphertext only |
| XSS in an unlocked renderer | β Out of scope β an attacker executing in the app origin can use the in-memory key through application APIs |
| Man-in-the-middle on sync / export | Out of scope (handled by transport layer) |
| User forgets passphrase |
This design does not protect against an attacker who can execute arbitrary code in the page's origin (e.g. a full XSS compromise), since the CryptoKey is held in memory during the session.
passphrase (UTF-8)
β
βΌ
PBKDF2(SHA-256, salt=appSalt, iterations=600,000)
β
βΌ
AES-256-GCM CryptoKey { extractable: false, usages: ['encrypt','decrypt','wrapKey','unwrapKey'] }
| Parameter | Value | Rationale |
|---|---|---|
| KDF | PBKDF2 | WebCrypto native; no WASM dependency |
| Hash | SHA-256 | OWASP 2024 minimum |
| Iterations | 600,000 | Current storageEncryptionService.ts PBKDF2-HMAC-SHA-256 setting |
| Salt | 32-byte random, stored in localStorage as worldscript-idb-kdf-salt-v1 |
Random per-install; setup fails if it cannot be persisted; a missing/invalid salt after a passphrase sentinel already exists fails closed with IdbEncryptionSaltLostError instead of silently deriving a new, incompatible key |
| Key output | AES-GCM 256-bit | Authenticated encryption β GCM tag detects corruption |
extractable |
false |
Key cannot be exported from WebCrypto context |
The salt is a 32-byte random value generated on first initialization:
const salt = crypto.getRandomValues(new Uint8Array(32));
// stored as: localStorage['worldscript-idb-kdf-salt-v1'] (plaintext β public by design)The salt is not secret. Its purpose is to prevent cross-device rainbow-table attacks. It is stored alongside the encrypted data.
Encryption is applied at the value level β store keys remain plaintext to allow IDBKeyRange queries to continue working.
Redux state / blob
β
βΌ LZ-String compression (> 10 KB threshold β existing behavior)
β
βΌ AES-256-GCM encrypt(key, random 12-byte IV, plaintext)
β
βΌ [ IV (12 bytes) || ciphertext || GCM tag (16 bytes) ] β Uint8Array
β
βΌ stored in IDB
Uint8Array from IDB
β
βΌ split: IV[0..12], ciphertext[12..]
β
βΌ AES-256-GCM decrypt(key, IV, ciphertext) β GCM tag verified before any data returned
β
βΌ LZ-String decompress (if \x00lz1\x00 prefix detected)
β
βΌ JSON.parse β typed value
- Wrong passphrase / corrupted data:
crypto.subtle.decryptrejects. The app surfaces a toast ("Unable to decrypt project data β check your storage passphrase") and does not attempt to return partial plaintext. - GCM tag mismatch: Same path β rejection surfaces as a user-visible error, never silently ignored.
| Store | DB | Priority | Notes |
|---|---|---|---|
app-data |
worldscript-state-db |
P1 | Contains Redux state including settings |
worldscript-snapshots |
worldscript-state-db |
P1 | Manuscript snapshots |
worldscript-data-images |
worldscript-data-db |
P2 | Binary blobs |
worldscript-rag-vectors |
worldscript-data-db |
P2 | Float32 embeddings |
worldscript-codex |
worldscript-data-db |
P3 | Extracted entities |
worldscript-binder-assets |
worldscript-data-db |
P3 | Binder attachments |
Stores proforge-memory-bank, scene-revisions, cross-project-index are in separate IDB databases; Phase 2 will encrypt them using the same derived key via a shared idbEncrypt / idbDecrypt helper.
// services/storage/storageEncryptionService.ts (v1.19.0, B-1)
export async function setupIdbEncryption(passphrase: string): Promise<void>
// Derives a non-extractable CryptoKey, persists an encrypted verifier, then activates the in-memory key.
export async function idbEncrypt(plaintext: unknown): Promise<Uint8Array>
// JSON.stringify β LZ-String compress (> 10 KB) β AES-256-GCM encrypt β [ IV || ciphertext ] Uint8Array
export async function idbReadSecure<T>(ciphertext: Uint8Array | unknown): Promise<T>
// Split IV[0..12] + ciphertext[12..] β AES-256-GCM decrypt (GCM tag verified) β decompress β JSON.parse β T
export function isIdbEncryptionReady(): boolean
// Returns true once initStorageEncryption() has resolved successfully
export async function clearIdbPassphrase(onProgress?: ProtectedStoreMigrationProgressCallback): Promise<void>
// Disable: journal-backed migration decrypts every protected store with the active key, verifies it,
// then deletes the sentinel + KDF salt and clears the session key. Requires an already-unlocked session.
export async function rotateIdbPassphrase(oldPassphrase: string, newPassphrase: string, onProgress?: ProtectedStoreMigrationProgressCallback): Promise<void>
// Rekey: verifies oldPassphrase against the sentinel, derives+authenticates the target key via a
// durable verifier, re-encrypts every protected store, verifies it, then saves the new sentinel.
export async function resumeEncryptionMigration(journal: EncryptionMigrationJournal, input: { sourcePassphrase: string; targetPassphrase?: string }, onProgress?: ProtectedStoreMigrationProgressCallback): Promise<void>
// Recovery UX entry point β re-derives keys from re-entered passphrase(s) and resumes an
// interrupted disable/rekey journal to the same end state as a fresh-start migration.Every protected store writer runs inside withProtectedWriteAdmission() (shared-mode Web Lock), so a concurrent migration batch (exclusive-mode lock) can never interleave with an ordinary write. The two call paths differ inside that admission: save paths call resolveProtectedWriteKey() to snapshot the write key and lock state atomically, then re-run only assertNoActiveEncryptionMigration() immediately before opening the transaction (re-checking the full lock guard here would wrongly reject a write that already captured a valid key before the session locked mid-write); delete paths, which encrypt nothing and so have no key to snapshot, call assertIdbProtectedWriteAllowed() directly. Either path rejects a configured-but-locked library before a transaction opens, so it never silently downgrades to plaintext.
This section previously claimed desktop shares the full encryption lifecycle described above. That was inaccurate β corrected below.
On the Tauri desktop build, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data is persisted by the filesystem-backed store (services/fs/*Store.ts), not IndexedDB. That store writes plaintext (LZ-string compressed only, no encryption) regardless of enableIdbAtRestEncryption. Enabling the setting on desktop still shows IdbUnlockModal/PassphraseModal (the passphrase sentinel lives in the WebView's own IndexedDB, which persists on desktop too), but that unlock flow gates nothing on the filesystem side today β only the UI, not the actual manuscript files under $APPDATA, is shared with the web build. Character and world image reads use storageService, so they now follow the same selected backend as image uploads; this removes the prior desktop filesystem/IndexedDB split-persistence availability bug. See README.md's "Encryption β which mechanism protects what" table for the authoritative per-mechanism breakdown. Extending real at-rest protection to the desktop filesystem store is a tracked, open gap β not yet implemented.
API keys (resolved 2026-08-14): all provider API keys, including Gemini, now route through storageService directly to the IndexedDB key store (services/storage/idbKeyStore.ts, random non-extractable AES-GCM key) on every platform, desktop included. The Tauri filesystem adapter's saveApiKey/getApiKey (services/fs/settingsFsStore.ts) is now a defense-in-depth backstop rather than the active path: saveApiKey throws if ever called, and getApiKey silently removes any pre-existing legacy key file β whether from the pre-2026-07-29 unsalted-SHA-256 scheme or the since-hardened but still filesystem-reconstructible PBKDF2 scheme β and returns null. A user-facing "API Key Reset Required" notification fires only when a failure occurs after the key-file path is resolved β during the existence check or the removal attempt itself, e.g. a transient permissions/IO error β and the catch block's own follow-up removal then succeeds; earlier failures (acquiring the platform APIs or resolving the app-data path) never reach the cleanup/notification logic at all, and if the follow-up removal also fails, the notification is likewise suppressed and only a warning is logged. The common case β no legacy file exists, or the initial removal succeeds outright β returns silently, since a re-prompt for a never-populated key is indistinguishable from normal first-use. encryptText/decryptText in fsCore.ts are no longer called anywhere in the codebase for API keys and remain only as shared crypto plumbing pending R-15's project-data encryption work (docs/native/CORE-MIGRATION-LEDGER.md row 10 β check the ledger directly for its current readiness marker rather than trusting a copied status here β additionally gated behind row 9, the project state-shape compatibility adapter, converging first). This also resolves the earlier Gemini split-persistence bug tracked in #358: components/ApiKeySection.tsx and services/geminiService.ts now both route through storageService.
The repository does not currently use tauri-plugin-stronghold, an OS keychain, or a transparent desktop-only passphrase store.
At-rest encryption is a breaking change to the IDB schema. Migration proceeds via dbMigration.ts:
- Existing plaintext records are readable only after unlock and may be encrypted when rewritten.
- The application must not claim complete library encryption until a verified full-store migration exists.
- A future migration must be journaled across databases; IndexedDB cannot make the current multi-database conversion atomic.
- Old writers must be blocked or coordinated during a future migration; rollback of code is not automatically rollback of data.
byte 0..4: '\x00enc1\x00' (6 bytes, distinct from LZ prefix '\x00lz1\x00')
byte 6..17: IV (12 bytes)
byte 18..: AES-GCM ciphertext + 16-byte GCM tag
decompressData() in dbService.ts already handles the \x00lz1\x00 sentinel β the same dispatch pattern extends to \x00enc1\x00.
- Multi-tab coordination: the migration journal's single-owner lease (
claimEncryptionMigrationOwnership) andprotectedWriteAdmission.ts's exclusive-mode Web Lock coordinate a migration against ordinary writers within the current tab set, but a non-extractableCryptoKeystill cannot be transferred to another tab β a second tab that opens mid-migration must re-derive its own key from a re-entered passphrase (same asEncryptionRecoveryModal) rather than receiving one viaBroadcastChannel. 'recovery-required'manual recovery: when the migration's own verification finds an inconsistency, the journal is deliberately left stuck (no automatic retry, no automatic data reconciliation) βEncryptionRecoveryModalsurfaces this honestly but does not resolve it. A dedicated manual-recovery procedure (support-assisted) is out of scope for Phase 4.- DuckDB OPFS: DuckDB WAL and data files are outside IDB and outside the Phase 4 migration's scope. A separate encryption layer requires its own threat model and recovery protocol.
- E2E coverage: the disable/rotate/recovery flows currently have unit + component test coverage (
tests/unit/storage/storageEncryptionService.test.ts,tests/unit/settings/PassphraseModal.test.tsx,tests/unit/settings/EncryptionRecoveryModal.test.tsx) but no Playwright E2E round trip yet.
services/storage/storageEncryptionService.tsβ implemented service (v1.19.0, B-1; Phase 4 disable/rotate/resume)services/storage/encryptionMigrationJournal.tsβ durable cross-database journal, single-owner lease, legal phase transitionsservices/storage/protectedStoreMigration.tsβ journal-owned adapter execution protocol + progress callbackservices/storage/protectedWriteAdmission.tsβ Web Locksβbased cross-tab admission (shared writers vs. exclusive migration batches)services/storage/primaryProtectedStoreAdapter.ts/primaryProtectedStoreAdapters.tsβ explicit-key/inline-keyPath adapter engine + the 5 generic-engine primary-store specsservices/storage/ragVectorsProtectedStoreAdapter.tsβ bespoke per-project RAG-vector adapter (aggregate β individual-record duality)services/storage/secondaryPayloadStoreAdapter.ts/secondaryProtectedStoreAdapters.tsβ pre-existing secondary-store adapters (scene revisions, inference cache)services/storage/encryptionMigrationOrchestrator.tsβ combines primary + secondary adapters into one production migration runcomponents/settings/PassphraseModal.tsx/EncryptionRecoveryModal.tsxβ set/unlock/disable/rotate UI + interrupted-migration recovery UXservices/storage/idbCore.tsβ IDB lifecycle; callsencryptForStorage/decryptFromStoragewhen flag is onservices/storage/(barrel) βidbProjectStore,idbSnapshotStore,idbKeyStore,idbCodexStore,idbAssetStoreservices/dbMigration.tsβ existing schema migration infrastructureservices/dbConstants.tsβDB_VERSION, store name constantsservices/collaborationService.tsβ reference PBKDF2 implementation (310,000 iterations, SHA-256)services/libraryBackupService.tsβ AES-GCM export path (export/import pattern reference)- OWASP Password Storage Cheat Sheet β PBKDF2 iteration guidance