Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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/binder-asset 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
16 changes: 11 additions & 5 deletions components/CharacterView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { ICONS } from '../constants';
import { CharacterViewContext, useCharacterViewContext } from '../contexts/CharacterViewContext';
import { uploadCharacterImageThunk } from '../features/project/thunks/characterThunks';
import { useCharacterView } from '../hooks/useCharacterView';
import { dbService } from '../services/dbService';
import { logger } from '../services/logger';
import {
characterCompleteness,
filterByQuery,
type RosterSort,
sortByMode,
} from '../services/rosterMetrics';
import { storageService } from '../services/storageService';
import type { Character } from '../types';
import { CompletenessRing } from './roster/CompletenessRing';
import { RosterToolbar } from './roster/RosterToolbar';
Expand All @@ -28,7 +29,7 @@ import { SectionIcon } from './ui/SectionIcon';
import { Select } from './ui/Select';
import { Spinner } from './ui/Spinner';

// A local hook to fetch image data on-demand from IndexedDB
// QNBS-v3: reads through the selected backend so Tauri uploads and views use the same storage location.
const useStoredImage = (id: string | undefined, hasImage: boolean | undefined) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
useEffect(() => {
Expand All @@ -38,9 +39,14 @@ const useStoredImage = (id: string | undefined, hasImage: boolean | undefined) =
}
let isMounted = true;
const fetchImage = async () => {
const base64 = await dbService.getImage(id);
if (isMounted && base64) {
setImageUrl(`data:image/png;base64,${base64}`);
try {
const image = await storageService.getImage(id);
if (isMounted && image) {
setImageUrl(image.startsWith('data:image/') ? image : `data:image/png;base64,${image}`);
}
} catch (error) {
// QNBS-v3: an unavailable image must retain the placeholder instead of causing an unhandled async rejection.
logger.warn('Failed to load character image', { error: String(error) });
}
};
fetchImage();
Expand Down
16 changes: 11 additions & 5 deletions components/WorldView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { ICONS } from '../constants';
import { useWorldViewContext, WorldViewContext } from '../contexts/WorldViewContext';
import { uploadWorldImageThunk } from '../features/project/thunks/worldThunks';
import { useWorldView } from '../hooks/useWorldView';
import { dbService } from '../services/dbService';
import { logger } from '../services/logger';
import {
filterByQuery,
type RosterSort,
sortByMode,
worldCompleteness,
} from '../services/rosterMetrics';
import { storageService } from '../services/storageService';
import type { World } from '../types';
import { CompletenessRing } from './roster/CompletenessRing';
import { RosterToolbar } from './roster/RosterToolbar';
Expand All @@ -28,7 +29,7 @@ import { SectionIcon } from './ui/SectionIcon';
import { Spinner } from './ui/Spinner';
import { Textarea } from './ui/Textarea';

// A local hook to fetch image data on-demand from IndexedDB
// QNBS-v3: reads through the selected backend so Tauri uploads and views use the same storage location.
const useStoredImage = (id: string | undefined, hasImage: boolean | undefined) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
useEffect(() => {
Expand All @@ -38,9 +39,14 @@ const useStoredImage = (id: string | undefined, hasImage: boolean | undefined) =
}
let isMounted = true;
const fetchImage = async () => {
const base64 = await dbService.getImage(id);
if (isMounted && base64) {
setImageUrl(`data:image/png;base64,${base64}`);
try {
const image = await storageService.getImage(id);
if (isMounted && image) {
setImageUrl(image.startsWith('data:image/') ? image : `data:image/png;base64,${image}`);
}
} catch (error) {
// QNBS-v3: an unavailable image must retain the placeholder instead of causing an unhandled async rejection.
logger.warn('Failed to load world image', { error: String(error) });
}
};
fetchImage();
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`. 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.
Comment thread
qnbs marked this conversation as resolved.

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
Loading
Loading