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
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** | Same random, non-extractable AES-256-GCM key store as the browser; no filesystem-derived secret material | `services/storage/idbKeyStore.ts`, `services/storageService.ts` |
| **Desktop (Tauri) BYOK API key** | Same random, non-extractable AES-256-GCM key store as the browser; no filesystem-derived secret material. Resolved 2026-08-14 — previously used a PBKDF2 derivation with a public/reconstructible input; also closed a Gemini-specific split-persistence bug ([#358](https://github.com/qnbs/WorldScript-Studio/issues/358)) where `ApiKeySection.tsx` and `geminiService.ts` read the key through different backends | `services/storage/idbKeyStore.ts`, `services/storageService.ts` |
| **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.

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 Correct the remaining at-rest Help guarantee

When a Tauri user opens Help → Advanced → At-Rest Encryption, locales/en/help.json:6—registered by services/help/helpCatalog.ts:298-299—still says primary project data, snapshots, and settings are AES-256-GCM protected and that reads and writes fail closed while locked. That directly contradicts this corrected desktop section, because those filesystem records remain plaintext and the unlock flow gates none of them; the same Help article also says changing or disabling encryption is unavailable even though Phase 4 now exposes both operations. Update this user-facing article and its runtime bundle so English desktop users do not retain a false protection guarantee.

AGENTS.md reference: AGENTS.md:L393-L397

Useful? React with 👍 / 👎.


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.

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 Correct the remaining in-app Stronghold claim

This correction is not reflected in the Help content users actually see: locales/en/help.json:74—mirrored across the generated locale bundles—still lists “Stronghold (optional)” as a desktop capability and says it can store the passphrase so the unlock modal never appears. A repository-wide search found no Stronghold dependency or configuration, so desktop users opening Help still receive the exact false protection claim this section removes; update the locale source and regenerated bundles as part of this truth-up.

Useful? React with 👍 / 👎.


---

Expand Down
Loading
Loading