Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ change landed — all 14 findings CONFIRMED, no baseline drift, base SHA `f5f9c1
| F-02 | 🔴 P0 | Unhashed inline `<script>` in `index.html`, added in the same commit as the broken `script-src`, contradicting that commit's own CSP-strategy comment | Moved into `index.tsx` as a same-origin module |
| F-03 | 🔴 P0 | No `frame-src`/`child-src` anywhere → `blob:` iframes (Binder PDF preview, ManuscriptResearchSplit) fall back to `default-src 'self'` and are blocked | `frame-src 'self' blob:` added to all 5 surfaces |
| F-04 | 🔴 P0 | No gate could have caught F-01/F-02 — existing CSP tests check cross-surface consistency only; `smoke-prod-build.mjs` only listened for `pageerror`, which CSP violations never fire | New 3-layer test architecture: Layer A (consistency, existing) / Layer B (`tests/unit/cspCorrectness.test.ts`, new) / Layer C (hardened `smoke-prod-build.mjs`, real violation + WASM probes). `docs/CI.md` gate-governance table |
| F-05/F-06 | 🟠 P1 | Desktop API-key encryption derived its key from a single unsalted SHA-256 digest of publicly-derivable material — obfuscation, not encryption | PBKDF2 600k + random 32-byte salt in `services/fs/fsCore.ts`, matching `storageEncryptionService.ts`. Legacy files discarded (not migrated), one-time re-entry notification |
| F-05/F-06 | 🟠 P1 — **reopened 2026-08-13** | Desktop API-key encryption derived its key from a single unsalted SHA-256 digest of publicly-derivable material — obfuscation, not encryption | PBKDF2 600k + random 32-byte salt in `services/fs/fsCore.ts`, matching `storageEncryptionService.ts`. Legacy files discarded (not migrated), one-time re-entry notification. **Incomplete**: the salt/iteration upgrade only hardens against rainbow-table/multi-target reuse — the derivation *input* (`${appDataPath}\|${provider}\|WorldScriptStudio\|v1`) is still fully public, so the root finding (anyone with file-read access reconstructs the key in one PBKDF2 call, no brute force needed) was never actually closed. Real fix tracked: fold API keys into the user-passphrase-protected store scheme (`services/storage/encryptionMigrationOrchestrator.ts`), removing the derived-passphrase scheme entirely |
| F-07 | 🟠 P1 | README/CLAUDE.md made a blanket "encrypted at rest" claim; a fabricated `tauri-plugin-stronghold` OS-keychain claim had zero trace in the codebase; `SECURITY-THREAT-MODEL.md` never mentioned `fsCore.ts`/`settingsFsStore.ts` | Doc truth-up: differentiated 4-mechanism table in README, fabricated claim removed, threat-model gained a mitigations row + attack tree |
| F-08 | 🟠 P1 | Tauri `connect-src` missing LanguageTool's port and the Hugging Face hosts WebLLM/Transformers.js resolve models from | Fixed; scope widened during verification — the LanguageTool port was missing on **all 5** surfaces, not just Tauri; the real weight-file CDN (`us.aws.cdn.hf.co`) traced empirically via `curl`, not guessed |
| F-09 | 🟠 P1 | DuckDB-WASM loaded from an unversioned, floating-`latest` third-party CDN — already unreachable under the (correctly-scoped) `worker-src` CSP, so this was dead code, not just a supply-chain risk | Self-hosted from the pinned npm dependency via `scripts/copy-duckdb-assets.mjs` (gitignored, ~72 MB, never committed) |
Expand Down
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
single unsalted SHA-256 digest of publicly-derivable material — fixed to PBKDF2 (600,000
iterations) + a random 32-byte salt, matching the existing `storageEncryptionService.ts`
pattern. Pre-existing key files are discarded (not migrated) with a one-time notification
prompting re-entry.
prompting re-entry. **Correction (2026-08-13):** this hardened the KDF against rainbow-table
and multi-target reuse, but the derivation *input* itself — `${appDataPath}|${provider}|WorldScriptStudio|v1`
— remained fully public/reconstructible, so the root "obfuscation, not encryption" finding was
never actually closed. Tracked as an open gap — see `docs/IDB-ENCRYPTION.md` § Tauri Desktop Layer
and `AUDIT.md`'s F-05/F-06 row for current status. **Review-loop follow-up (2026-08-13):**
`docs/SECURITY-THREAT-MODEL.md`'s Desktop Local File-Read Attack Tree and document header still
called this "fixed" and said reading the ciphertext no longer reveals the key material, directly
contradicting the corrected Mitigation Mapping row above it — reconciled to say "not resolved"
consistently throughout. Also documented a separate, unrelated functional bug surfaced while
fact-checking this row: `components/ApiKeySection.tsx` never adopted the `storageService`/
`FsSettingsStore` path this desktop API-key row describes — it still reads/writes the Gemini key
through `dbService` (IndexedDB) even on desktop, while `services/geminiService.ts` reads through
`storageService` (filesystem on desktop), so a Gemini key saved via Settings → AI on desktop is
invisible to the code that actually uses it. Tracked in
[#358](https://github.com/qnbs/WorldScript-Studio/issues/358); not fixed by this entry.
**Second review-loop follow-up (2026-08-13):** tightened the attacker-capability wording (needs
both the ciphertext *and* the public derivation inputs, not the ciphertext alone) in both the
Mitigation Mapping row and the attack tree; added the same Gemini-exception note to the attack
tree (previously only in the table row); and added a new Mitigation Mapping row for desktop
project/settings/snapshot/Codex/RAG/image data, which this threat model previously didn't
mention at all despite it having neither confidentiality nor authentication on disk — real fix
in progress on [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356).
- **F-08 — Tauri/web `connect-src` completeness.** Added LanguageTool's default self-hosted port
(missing on **all 5** surfaces, not just Tauri) and the Hugging Face hosts WebLLM/Transformers.js
actually resolve models from, including the Xet CDN bridge (`us.aws.cdn.hf.co`) that real model
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,14 +326,16 @@ 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
different data, with different key material:
There is no single blanket "encrypted at rest" guarantee — five independent mechanisms cover
different data, with different key material, and two of them (marked ⚠️) currently provide no
real protection despite being present in code:

| 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** ⚠️ | PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM — but the passphrase input is a *public* string (`appDataPath\|provider\|WorldScriptStudio\|v1`), not a real secret; anyone with read access to the encrypted file can reconstruct it and decrypt in one step. Tracked, open gap — see `docs/IDB-ENCRYPTION.md` § Tauri Desktop Layer. **Does not apply to Gemini**: `components/ApiKeySection.tsx` still saves/reads the Gemini key through `dbService` (the browser IndexedDB row above) even on desktop, never through this filesystem path — a separate, functional bug tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358) | `services/fs/fsCore.ts`, `services/fs/settingsFsStore.ts` |
Comment thread
qnbs marked this conversation as resolved.
Outdated
| **Desktop (Tauri) project/settings/snapshot/Codex/RAG/asset data** ⚠️ | **None — plaintext (LZ-string compressed only).** Enabling "Encrypt project data at rest" still shows the passphrase unlock screen on desktop, but it doesn't gate this filesystem-backed store. Tracked, open gap | `services/fs/*Store.ts` |
Comment thread
qnbs marked this conversation as resolved.
| **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
8 changes: 7 additions & 1 deletion docs/IDB-ENCRYPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,13 @@ Every protected store writer runs inside `withProtectedWriteAdmission()` (shared

## Tauri Desktop Layer

Tauri uses the same WebView storage encryption lifecycle as the web build. The repository does **not** currently use `tauri-plugin-stronghold`, an OS keychain, or a transparent desktop-only passphrase store. Desktop users enter the passphrase through the same unlock flow and receive the same locked-write guarantees.
**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` — every writer is explicitly commented `ENCRYPTION: plaintext`. 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. 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.
Comment thread
qnbs marked this conversation as resolved.
Outdated

The one thing that *is* desktop-specific and already encrypted is per-provider API keys (`services/fs/settingsFsStore.ts`, via `encryptText`/`decryptText` in `services/fs/fsCore.ts`) — but that mechanism has its own, separate weakness: its PBKDF2 passphrase is derived from a string built entirely out of public/discoverable values (the app-data path, the provider name, and hardcoded constants), not a real secret. Anyone with filesystem read access to the encrypted key file — the exact threat model at-rest encryption exists to defend against — can reconstruct the same derivation and decrypt it. This is also a tracked, open gap, independent from the project-data gap above; do not treat the presence of `encryptText`/`decryptText` as evidence that desktop API keys are meaningfully protected today. **"Per-provider" excludes Gemini**: `components/ApiKeySection.tsx` never adopted the `storageService`/`FsSettingsStore` path at all — it still reads/writes the Gemini key directly through `dbService` (the browser IndexedDB store), even when running on desktop. `services/geminiService.ts` reads the key through `storageService`, which resolves to the filesystem backend on desktop — so a Gemini key saved via Settings → AI on desktop is written to a location `geminiService.ts` never looks in. This is a functional bug, not a security one; tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358), independent of the two gaps above.

The repository does **not** currently use `tauri-plugin-stronghold`, an OS keychain, or a transparent desktop-only passphrase store.
Comment thread
qnbs marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

---

Expand Down
14 changes: 9 additions & 5 deletions docs/SECURITY-THREAT-MODEL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Security Threat Model

**Version:** 1.0.0
**Date:** 2026-06-05 (baseline); desktop-crypto mitigation row updated 2026-07-29 (v1.24.2, F-05/F-06)
**Date:** 2026-06-05 (baseline); desktop-crypto mitigation row updated 2026-08-13 (F-05/F-06 reopened — not resolved by the 2026-07-29 change, see Mitigation Mapping below)
**Status:** v1.24.2 baseline

This document provides a formal STRIDE threat analysis for WorldScript Studio, mapping threats to mitigations and code locations.
Expand Down Expand Up @@ -39,7 +39,8 @@ This document provides a formal STRIDE threat analysis for WorldScript Studio, m
| Threat | Mitigation | Code Location |
|--------|------------|-------------|
| API key leakage via logs | StructuredLogger sanitization; never log keys | `services/logger.ts:sanitizeLogContext()` |
| Desktop API key exposure via local file-read access | AES-256-GCM with a PBKDF2-derived key (600 000 iterations, SHA-256, random 32-byte salt per encryption) — fixed 2026-07-29; the prior scheme derived the key from a single unsalted SHA-256 digest of publicly-derivable material (own file's parent path + provider name from the filename + a hardcoded literal), so anyone with read access to `config/<provider>_key.enc.json` could reconstruct the key in one hash operation (F-05/F-06). No migration path for pre-fix files by design — a legacy (unsalted) payload is discarded and the user is prompted to re-enter the key. | `services/fs/fsCore.ts:deriveFileSystemCryptoKey()`, `services/fs/settingsFsStore.ts:getApiKey()` |
| Desktop API key exposure via local file-read access _(does not apply to Gemini — see note below)_ | **Not resolved — corrected 2026-08-13.** The 2026-07-29 change (F-05/F-06) replaced a single unsalted SHA-256 digest with PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt), which stops rainbow-table and multi-target reuse attacks — but the derivation *input* passed to PBKDF2 is still `${appDataPath}\|${provider}\|WorldScriptStudio\|v1`, built entirely from a standard OS app-data path, a public provider-name enum, and hardcoded literals. An attacker with the ciphertext (`config/<provider>_key.enc.json` — the exact threat this row exists to cover) and those public/reconstructible derivation inputs can derive the key in one PBKDF2 call and decrypt the file; no brute force or precomputed table is needed, so the added iteration count provides no real defense here. The underlying "obfuscation, not encryption" finding was never actually closed. Real fix tracked: fold API keys into the same user-passphrase-protected store scheme `storageEncryptionService.ts`/`encryptionMigrationOrchestrator.ts` already provide, keyed by an actual user secret instead of a derived public string; honest plaintext fallback when no passphrase is set. **Gemini exception:** `components/ApiKeySection.tsx` never adopted this filesystem path at all — it reads/writes the Gemini key through `dbService` (browser IndexedDB) even on desktop, while `services/geminiService.ts` reads it through `storageService` (filesystem on desktop); this row's file-read analysis doesn't apply to Gemini, whose actual failure mode is a functional split-persistence bug (a key saved on desktop is invisible to the code that uses it), tracked in [#358](https://github.com/qnbs/WorldScript-Studio/issues/358). | `services/fs/fsCore.ts:deriveFileSystemCryptoKey()`, `services/fs/settingsFsStore.ts:getApiKey()` |
| Desktop project/settings/snapshot/Codex/RAG/image data exposure and tampering via local file-read access | **Not resolved as of 2026-08-13.** `services/fs/*Store.ts` writes this data as plaintext (LZ-string compressed only) regardless of the `enableIdbAtRestEncryption` setting — there is neither confidentiality (no encryption) nor authentication (no AEAD tag, so silent tampering is possible) for any of it on disk today, unlike the IndexedDB manuscript rows above. Enabling "Encrypt project data at rest" in Settings → Privacy shows the same unlock screen as the browser/PWA build but does not protect these files. Real fix in progress: [PR #356](https://github.com/qnbs/WorldScript-Studio/pull/356) reuses `storageEncryptionService.ts`'s real passphrase-derived key for this filesystem path; update this row to "Resolved" once that PR merges and is verified. | `services/fs/*Store.ts` |
Comment thread
qnbs marked this conversation as resolved.
Outdated
| Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | `services/storage/storageEncryptionService.ts` |
| Voice audio to cloud | Web Speech API consent gate | `components/voice/VoicePrivacyConsentModal.tsx` |
| DuckDB analytics unencrypted (SEC-6) | **Bounded by design, with one prose column now encrypted:** most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and **nothing leaves the device**. The one column that genuinely holds literal manuscript prose, `codex_mentions.excerpt`, is now cell-level encrypted (AES-256-GCM via `services/duckdb/duckdbEncryption.ts`, reusing the IDB at-rest encryption key) whenever `enableIdbAtRestEncryption` is active: `duckdbCodexWrite()` writes ciphertext into `excerpt_enc BLOB` and nulls the plaintext `excerpt` column; `services/duckdb/codexExcerptEncryptionMigration.ts` backfills any pre-existing plaintext rows once encryption is unlocked. Gated by `enableDuckDbAnalytics` **and** the Settings → Privacy "Analytics" opt-out (`isAnalyticsPersistenceAllowed` in `app/listenerMiddleware.ts`); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains **infeasible** — DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). | `app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed`, `services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite()`, `services/duckdb/duckdbEncryption.ts`, `services/duckdb/codexExcerptEncryptionMigration.ts` |
Expand Down Expand Up @@ -124,9 +125,12 @@ Goal: Intercept/decrypt collaboration traffic
```
Goal: Recover a user's cloud-provider API key from the Tauri desktop install
├─ OR: Read config/<provider>_key.enc.json directly (local process / malware with user-level FS access)
│ └─ Mitigation: AES-256-GCM with PBKDF2-derived key (600k iter, random 32-byte salt per file) —
│ reading the ciphertext no longer reveals the key material; the pre-2026-07-29 scheme derived
│ the key from data an attacker with file-read access already had (F-05/F-06, fixed)
│ └─ Mitigation: NOT RESOLVED as of 2026-08-13 (does not apply to Gemini — see Mitigation
│ Mapping below). The 2026-07-29 change (F-05/F-06) added PBKDF2 (600k iter, random 32-byte
│ salt), but the derivation *input* is still `${appDataPath}|${provider}|WorldScriptStudio|v1`
│ — entirely public/reconstructible by anyone with the file-read access this row assumes. An
│ attacker with the ciphertext and those public/reconstructible derivation inputs can derive
│ the key in one PBKDF2 call and decrypt the file; real fix tracked separately
├─ OR: Read the IDB-at-rest passphrase sentinel (enableIdbAtRestEncryption)
│ └─ Mitigation: same PBKDF2 + non-extractable-key pattern; session-scoped in-memory key, never
│ persisted to disk (`services/storage/storageEncryptionService.ts`)
Expand Down
Loading