Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 9 additions & 22 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,7 @@ const App: FC<AppProps> = ({ isNewUser }) => {
document.body.classList.add(isDark ? 'dark-theme' : 'light-theme');
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
// QNBS-v3: Sepia has distinct dark/light surface colors; reflect them in the
// mobile browser chrome so the status bar matches the app shell.
// QNBS-v3: Sepia has distinct dark/light surface colors; reflect them in the mobile browser chrome so the status bar matches the app shell.
const themeColor =
settings.appearancePreset === 'sepia'
? isDark
Expand Down Expand Up @@ -278,8 +277,7 @@ const App: FC<AppProps> = ({ isNewUser }) => {
);
}, [settings.accessibility.highContrast]);

// QNBS-v3: Tag the body for desktop-scoped styling (is-desktop + data-os). Tauri-ness is constant
// for the session, so this runs once; no-op on the web. Pairs with the `.is-desktop` CSS layer.
// QNBS-v3: Tag the body for desktop-scoped styling (is-desktop + data-os) — Tauri-ness is constant for the session, so this runs once; no-op on the web. Pairs with the `.is-desktop` CSS layer.
useEffect(() => {
applyDesktopRuntimeFlags();
}, []);
Expand Down Expand Up @@ -340,24 +338,19 @@ const App: FC<AppProps> = ({ isNewUser }) => {
document.documentElement.dir = featureFlags.enableRtlLayout ? 'rtl' : localeDir;
}, [language, featureFlags.enableRtlLayout]);

// QNBS-v3: Sync enablePluginSystem flag into pluginRegistry so execute/executeAsync/loadPlugin
// are properly gated without the registry needing direct Redux access.
// QNBS-v3: Sync enablePluginSystem flag into pluginRegistry so execute/executeAsync/loadPlugin are properly gated without the registry needing direct Redux access.
useEffect(() => {
pluginRegistry.setEnabled(featureFlags.enablePluginSystem);
}, [featureFlags.enablePluginSystem]);

// QNBS-v3: Sync inference telemetry into telemetryService — the service cannot import the Redux
// store without a circular dep, so App.tsx acts as the bridge. SEC: telemetry now also honours the
// Settings → Privacy "Analytics" opt-out, mirroring the DuckDB persistence gate in listenerMiddleware
// (isAnalyticsPersistenceAllowed). Re-runs on either input change so toggling the opt-out is live.
// QNBS-v3: Sync inference telemetry into telemetryService (can't import the Redux store directly — circular dep) — also honours Settings → Privacy "Analytics" opt-out, mirroring listenerMiddleware's isAnalyticsPersistenceAllowed gate; re-runs on either input change so toggling is live.
useEffect(() => {
void import('./services/ai/telemetryService').then(({ setTelemetryEnabled }) => {
setTelemetryEnabled(featureFlags.enableDuckDbAnalytics && settings.privacy.analyticsEnabled);
});
}, [featureFlags.enableDuckDbAnalytics, settings.privacy.analyticsEnabled]);

// QNBS-v3: Issue 5 — set the window adaptive-AI gate on cold start if the flag is already on
// (listener only fires on OFF→ON transitions, not on initial true state from localStorage)
// QNBS-v3: Issue 5 — set the window adaptive-AI gate on cold start if the flag is already on (listener only fires on OFF→ON transitions, not on initial true state from localStorage).
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-shot on mount only; flag changes handled by listenerMiddleware
useEffect(() => {
initAdaptiveAiOnStartup(featureFlags.enableAdaptiveAiEngine);
Expand Down Expand Up @@ -428,8 +421,7 @@ const App: FC<AppProps> = ({ isNewUser }) => {
}
}, [dispatch, isPortalActive, t]);

// QNBS-v3: Translated view announcement instead of raw text (WCAG 4.1.3 status messages).
// requestAnimationFrame focus ensures the new view is mounted before focus moves (WCAG 2.4.3).
// QNBS-v3: Translated view announcement instead of raw text (WCAG 4.1.3) — requestAnimationFrame focus ensures the new view is mounted before focus moves (WCAG 2.4.3).
useEffect(() => {
if (isInitialLoad || isPortalActive) return;
if (prevViewRef.current === currentView) return;
Expand Down Expand Up @@ -467,15 +459,11 @@ const App: FC<AppProps> = ({ isNewUser }) => {
}
}, [project, isPortalActive, isI18nReady, dispatch, t]);

// QNBS-v3: PR3 — auto-launch the product tour once for first-run installs, after the welcome
// portal closes and the nav has rendered. Returning users (or anyone who already finished/closed
// it) are never interrupted; they can still start it manually from the Dashboard or Help.
// QNBS-v3: PR3 — auto-launch the product tour once for first-run installs, after the welcome portal closes and the nav has rendered; returning users are never interrupted and can start it manually from the Dashboard or Help.
const tourStartedRef = useRef(false);
useEffect(() => {
if (!isNewUser || isInitialLoad || isPortalActive) return;
// QNBS-v3: never hijack an automated browser session — the tour's full-screen overlay intercepts
// pointer events and breaks E2E. navigator.webdriver is true only under automation, never for
// real users, so this is invisible in production.
// QNBS-v3: never hijack an automated browser session — the tour's overlay intercepts pointer events and breaks E2E; navigator.webdriver is true only under automation, invisible in production.
if (typeof navigator !== 'undefined' && navigator.webdriver) return;
if (tourStartedRef.current || hasCompletedSpotlightTour()) return;
// QNBS-v3 (CodeAnt): set the once-guard when the timer actually fires, not before it. If a dep
Expand Down Expand Up @@ -603,8 +591,7 @@ const App: FC<AppProps> = ({ isNewUser }) => {
await exit(0);
}, [store]);

// QNBS-v3: executeCommandRef synced in its own effect (never assigned during render) so the menu
// effect below can depend on [t, quitApp] only and skip rebuilding on every executeCommand identity change.
// QNBS-v3: executeCommandRef synced in its own effect (never assigned during render) so the menu effect below can depend on [t, quitApp] only, skipping rebuilds on every executeCommand identity change.
const executeCommandRef = useRef(executeCommand);
useEffect(() => {
executeCommandRef.current = executeCommand;
Expand Down
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`{provider, apiKey}` together (not just the bare key string), so a ciphertext swapped between two
providers' files decrypts under the same key but fails the provider check, closing a cross-file
substitution gap.
- **Desktop project data now honors the at-rest encryption setting.** Previously, enabling
Settings → Privacy → "Encrypt project data at rest" only protected the browser/PWA build's
IndexedDB path — on the Tauri desktop build, `services/fs/*Store.ts` wrote project.json,
settings.json, snapshots, Codex, RAG vectors, and character/world images as plaintext
regardless of the setting, while still showing the same passphrase unlock screen. Desktop now
Comment thread
qnbs marked this conversation as resolved.
reuses `services/storage/storageEncryptionService.ts`'s real passphrase-derived key directly
(same pattern as the API-key fix above): AES-256-GCM protection when a passphrase is configured
and unlocked, honest plaintext otherwise. Migration is lazy/opportunistic — existing plaintext
files are protected on their next save (autosave already runs on a short interval); there is no
explicit "encrypt everything now" step and no data-loss risk either way. Snapshot files protect
only their `data` field, keeping name/date/word-count metadata plaintext so the snapshot list
never needs decryption to render. **Not yet covered**: binder-asset files — both the binary
blob (`.bin`) *and* its metadata sidecar (`.meta.json`, which includes `originalFileName` —
filenames can themselves carry sensitive project information). The `.bin` payload needs a
byte-native encrypt path rather than the JSON-serializing helpers used here; both remain
plaintext pending a follow-up.
**Review-loop follow-up fix to the same change:** disabling or rotating the at-rest passphrase
previously destroyed or swapped the shared salt/session key (`storageEncryptionService.ts`'s
`clearIdbPassphrase()`/`rotateIdbPassphrase()`) with no awareness that desktop's `services/fs/*`
data depends on the same key material — every fs-backed protected file (project.json, settings,
API keys, snapshots, Codex, RAG vectors, images) would have been permanently stranded under a
now-unrecoverable key. A new migration bridge (`services/fs/fsEncryptionMigration.ts`) now
converts every fs-backed protected file to plaintext (disable) or re-encrypts it under the
independently-derived new target key (rotate) *before* the sentinel/session key is touched; any
file that fails to decrypt under the still-active old key aborts the whole disable/rotate
operation instead of silently stranding it. Wired into `hooks/useSettingsView.ts`'s
`handlePassphraseConfirm`, gated on `isTauriRuntime()` (no-op on web).
**Second review-loop follow-up to the same change:** a locked session previously read as "no
project"/"no settings" on every fs-backed store (project, settings, Codex, RAG vectors,
snapshot, image) — the catch-all handlers swallowed `IdbStorageLockedError` into `null`, so
desktop could silently boot as a brand-new user instead of showing the unlock modal; all six now
re-throw it, reusing the web build's already-proven unlock-and-retry flow with no boot-sequence
changes needed. Rotating the passphrase now verifies the *current* passphrase against the
durable sentinel before re-keying any filesystem file, closing a mixed-key bug where a mistyped
current passphrase let the bridge re-key everything to a new key that `rotateIdbPassphrase()`
then never actually activates. The migration bridge's non-strict ('set') mode now also survives
a write failure or an unreadable directory (previously only read/decrypt failures on individual
files were caught) — a routine per-file I/O error can no longer crash first-time setup after the
sentinel is already active; when it still does (e.g. the migration marker itself can't be
written), `useSettingsView.ts` now rolls the just-created sentinel back via `clearIdbPassphrase()`
rather than leaving it active with the feature flag off. Fixed a write-ordering gap where an
older, slower-to-encrypt save's write could land in the per-path queue after a newer save and
silently overwrite it — encryption now happens *inside* the same queue slot that serializes the
atomic write, not before it. An API-key ciphertext swapped between two provider files is now
rejected (not laundered into a correctly-labeled file) by the migration path, mirroring the
existing ordinary-read guard. Neither of the app's two "nuclear reset" flows
(`resetAllDatabases()`, storage-init-failure recovery; `wipeAllAppData()`, factory reset) ever
touched Tauri filesystem data, while both destroy the KDF salt required to derive any key — any
already-protected fs file became permanently orphaned ciphertext after either reset. Both now
call a new `deleteAllFsData()` first, with failure propagating rather than being swallowed, so a
partial fs-delete failure never proceeds to destroy the salt. An interrupted migration (crash,
forced quit, power loss mid-operation) now leaves a durable marker detected at next startup and
surfaced as a status notification — not a full resumable migration yet, see
[issue #359](https://github.com/qnbs/WorldScript-Studio/issues/359) for that tracked gap.
**Third review-loop follow-up to the same change:** a truncated or bit-corrupted write that still
parses as JSON and claims `scheme: 'protected-v1'` but has a missing/invalid `data` field
previously fell through as "not protected" and was returned as plaintext — the corresponding
store would then deserialize the envelope shell itself as domain data, silently corrupting
in-memory state instead of surfacing the corruption. Any value claiming the protected scheme now
throws unless its envelope fully validates. `migrateAllProtectedFsData` used the ordinary
best-effort `listProjects()` API (which swallows every `readDir` failure to `[]`) to enumerate
the `projects/` directory — a transient permission/I/O error there would silently skip every
project/Codex/vector file while the migration still reported success; it now uses the same
failure-propagating helper every other directory scan in the bridge already uses. The fs
migration marker is no longer cleared by the bridge itself — for disable/rotate, an IDB-side
commit (`clearIdbPassphrase()`/`rotateIdbPassphrase()`) still has to run *after* the bridge
succeeds, and clearing the marker before that commit erased the only "an operation is mid-flight"
signal a crash in that remaining window would leave behind; the caller now clears it only once
the whole operation, including that later IDB commit, has actually succeeded.

### Fixed

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,8 @@ The current primary project, settings, snapshot, image, Codex, RAG, and binder-a

- **AES-256-GCM** with a PBKDF2-derived key (600 000 iterations, SHA-256, 32-byte random salt).
- Gated behind `featureFlags.enableIdbAtRestEncryption`. When a library is configured but locked, protected reads and writes fail closed rather than falling back to plaintext.
- Disable and passphrase rotation are temporarily unavailable until a journaled, cross-store migration protocol can prove recovery after interruption.
- **Web/PWA build only.** The unlock screen (`IdbUnlockModal`) and session-scoped in-memory key protect the IndexedDB-backed storage path used by the browser/PWA build. On the **Tauri desktop build**, primary project, settings, snapshot, image, Codex, RAG, and binder-asset data are written by the filesystem-backed store (`services/fs/*`), which is plaintext (LZ-string compressed, not encrypted) regardless of this setting — enabling it on desktop still shows the same unlock screen (the passphrase sentinel lives in the WebView's IndexedDB) but does not encrypt the actual manuscript files on disk. No `tauri-plugin-stronghold` or equivalent OS-keychain integration ships today — see the API-key encryption note below for the desktop-specific mechanism that does exist.
- Disable and passphrase rotation are available from Settings. IndexedDB uses its journal-backed migration protocol; desktop filesystem data is migrated before the shared key transition. An interrupted desktop filesystem migration blocks hydration for recovery rather than being treated as an empty library, but it is not yet crash-resumable (tracked in #359).
- **Tauri desktop build.** The unlock screen (`IdbUnlockModal`) and session-scoped in-memory key are shared with the browser/PWA build, and now genuinely protect the filesystem-backed store (`services/fs/*`) too — project, settings, snapshot, Codex, RAG, and image data reuse the same passphrase-derived key. Binder-asset files (`.bin` binary blob and `.meta.json` metadata sidecar) are the one exception and remain plaintext — see the encryption-mechanism table below. No `tauri-plugin-stronghold` or equivalent OS-keychain integration ships today — see the API-key encryption note below for the desktop-specific mechanism that does exist.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile the remaining encryption recovery guidance

Fresh evidence after the earlier documentation fix is that the immediately preceding README bullet still says disable and passphrase rotation are unavailable, while this new desktop paragraph and the implemented Settings flow say they are supported; locales/en/help.json's help.settingsGuide.flags.content also still says desktop filesystem storage is not covered. Users consulting these sections receive contradictory security and recovery instructions, so update all remaining canonical/help entries together.

Useful? React with 👍 / 👎.

- At-rest protection reduces disclosure from an extracted browser profile while the library is locked; it does not protect an unlocked renderer, a compromised device, or every persistence surface.

### 🔐 Encrypted Library Backup
Expand All @@ -326,14 +326,15 @@ One-click encrypted export of your entire project library from **Settings → Da

### 🔑 Encryption — which mechanism protects what

There is no single blanket "encrypted at rest" guarantee — four independent mechanisms protect
There is no single blanket "encrypted at rest" guarantee — five independent mechanisms protect
different data, with different key material:

| Data | Mechanism | Where |
|------|-----------|-------|
| **Browser BYOK API key** | Random, non-extractable AES-256-GCM key generated via `crypto.subtle.generateKey()` — no passphrase, nothing to derive | `services/storage/idbKeyStore.ts` |
| **Browser IDB-at-rest data** _(opt-in, B-1)_ | User passphrase → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/storage/storageEncryptionService.ts` |
| **Desktop (Tauri) BYOK API key** | Install-scoped secret material → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/fs/fsCore.ts`, `services/fs/settingsFsStore.ts` |
| **Desktop (Tauri) BYOK API key** | When at-rest encryption is configured and unlocked: user passphrase → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM over `{provider, apiKey}`. Without a configured at-rest passphrase, the key is deliberately stored as plaintext rather than under reconstructible pseudo-secret material. | `services/fs/fsCore.ts`, `services/fs/settingsFsStore.ts` |
| **Desktop (Tauri) project/settings/snapshot/Codex/RAG/image data** | User passphrase → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, same key material as the browser IDB-at-rest row above. Lazy/opportunistic: existing plaintext files are protected on their next save; first-time setup and disable/rotate additionally migrate every already-existing file immediately, not just future writes. ⚠️ **Not covered**: binder-asset files — both the binary blob (`.bin`) and its metadata sidecar (`.meta.json`, which includes the original filename) remain plaintext | `services/fs/*Store.ts`, `services/fs/fsEncryptionMigration.ts` |
| **Library backup vault** | User passphrase → PBKDF2 (600 000 iterations, SHA-256) → AES-256-GCM | `services/libraryBackupService.ts` |

See [`docs/SECURITY-THREAT-MODEL.md`](docs/SECURITY-THREAT-MODEL.md) for the full threat-model mapping.
Expand Down
Loading