Skip to content
Closed
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
21 changes: 20 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { getEffectiveTheme } from './services/commands/effectiveTheme';
import { approximateManuscriptWordCount } from './services/commands/wordCountApprox';
import { installDesktopMenu } from './services/desktop/desktopMenu';
import { installCloseToTray, installDesktopTray } from './services/desktop/desktopTray';
import { checkForInterruptedFsMigration } from './services/fs/fsEncryptionMigration';
import { logger } from './services/logger';
import { pluginRegistry } from './services/pluginRegistry';
import { repairProjectI18nFields } from './services/projectI18nRepair';
Expand All @@ -80,7 +81,7 @@ import {
isIdbEncryptionReady,
} from './services/storage/storageEncryptionService';
import { initTauriDeepLink } from './services/tauriDeepLink';
import { applyDesktopRuntimeFlags } from './services/tauriRuntime';
import { applyDesktopRuntimeFlags, isTauriRuntime } from './services/tauriRuntime';
import { viewNavigationLabelKey } from './services/viewNavigationLabels';
import type { View } from './types';

Expand Down Expand Up @@ -375,6 +376,24 @@ const App: FC<AppProps> = ({ isNewUser }) => {
})();
}, []);

// QNBS-v3: the fs-data migration bridge has no resumable journal yet (issue #359) — a marker
// left behind by an interrupted set/disable/rotate is the only signal available; surface it
// honestly rather than silently proceeding as if the desktop file state is fully consistent.
useEffect(() => {
if (!isTauriRuntime()) return;
void (async () => {
const marker = await checkForInterruptedFsMigration();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate hydration on an interrupted filesystem rotation

After a Tauri rotation is interrupted midway, some files may require the new key while the sentinel still authenticates the old key, but this marker is checked only in an App effect after bootApp() has already called loadPersistedRootState(). Once the user unlocks with the old passphrase, AES-GCM failures for already-rekeyed settings or project files are converted to null by their store readers, so the app can hydrate defaults and permit normal edits/autosaves before merely showing this notification. Check the marker before persisted-state hydration and block normal startup until the mixed-key state is recovered or explicitly handled.

Useful? React with 👍 / 👎.

if (!marker) return;
dispatch(
statusActions.addNotification({
type: 'error',
title: 'Encryption Migration Interrupted',
description: `A previous "${marker.operation}" encryption change did not finish (started ${marker.startedAt}). Some desktop files may still be encrypted under a different key than expected. Back up your data folder and contact support before changing the at-rest encryption passphrase again.`,
Comment thread
qnbs marked this conversation as resolved.
Outdated
}),
);
})();
}, [dispatch]);

// QNBS-v3: B-1 sentinel guard (async) — skips if flag off/unlocked/recovery-pending, auto-disables on a missing sentinel, else shows the unlock modal.
useEffect(() => {
if (!featureFlags.enableIdbAtRestEncryption || isIdbEncryptionReady() || recoveryJournal)
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@ 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).

### Fixed

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ 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.
- **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) 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
21 changes: 21 additions & 0 deletions hooks/useSettingsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@ import { settingsActions } from '../features/settings/settingsSlice';
import { statusActions } from '../features/status/statusSlice';
import { useTranslation } from '../hooks/useTranslation';
import { wipeAllAppData } from '../services/factoryResetService';
import { migrateAllProtectedFsData } from '../services/fs/fsEncryptionMigration';
import { logger } from '../services/logger';
import type { ProtectedStoreMigrationProgress } from '../services/storage/protectedStoreMigration';
import {
clearIdbEncryptionKey,
clearIdbPassphrase,
deriveRotationTargetKey,
isIdbEncryptionReady,
resolveProtectedWriteKey,
rotateIdbPassphrase,
setupIdbEncryption,
verifyAndInitIdbEncryption,
} from '../services/storage/storageEncryptionService';
import { storageService } from '../services/storageService';
import { isTauriRuntime } from '../services/tauriRuntime';
import type {
AccessibilitySettings,
AdvancedAiSettings,
Expand Down Expand Up @@ -385,6 +389,16 @@ export const useSettingsView = () => {
if (passphraseModal === 'set') {
// QNBS-v3: setupIdbEncryption derives key, writes sentinel to IDB, sets _activeKey
await setupIdbEncryption(newPassphrase);
// QNBS-v3: without this, "encryption active" would only mean future writes get protected —
// every already-existing fs-backed file (project.json, settings, API keys, snapshots, Codex,
// RAG, images) would stay plaintext until its next incidental save. strict:false because
// turning encryption ON must never be blocked by an unrelated pre-existing oddity (e.g. a
// stray file left over from a previous, since-forgotten encryption session) — such a file is
// logged and skipped rather than aborting setup for everything else.
if (isTauriRuntime()) {
const key = await resolveProtectedWriteKey();
if (key) await migrateAllProtectedFsData(key, 'set');
Comment thread
qnbs marked this conversation as resolved.
Outdated
}
dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(true));
setEncryptionReady(true);
// QNBS-v3: WCAG 4.1.3 — toast confirms success for keyboard/AT users who can't see status text
Expand All @@ -398,6 +412,8 @@ export const useSettingsView = () => {
// QNBS-v3: clearIdbPassphrase() requires an already-unlocked session key — no passphrase re-entry.
setMigrationProgress(null);
try {
// QNBS-v3: must convert fs-backed desktop data to plaintext BEFORE the sentinel below is destroyed — clearIdbPassphrase() has no awareness of services/fs/*, so ordering here is load-bearing, not cosmetic.
if (isTauriRuntime()) await migrateAllProtectedFsData(null, 'disable');
await clearIdbPassphrase((progress) => setMigrationProgress(progress));
} finally {
setMigrationProgress(null);
Expand All @@ -408,6 +424,11 @@ export const useSettingsView = () => {
} else if (passphraseModal === 'rotate') {
setMigrationProgress(null);
try {
// QNBS-v3: derives the SAME target key rotateIdbPassphrase() will activate (same salt/passphrase) and re-keys fs-backed desktop data under it BEFORE the active session key is swapped below — otherwise fs data stays under the old, soon-unrecoverable key.
if (isTauriRuntime()) {
const targetKey = await deriveRotationTargetKey(newPassphrase);
await migrateAllProtectedFsData(targetKey, 'rotate');
Comment thread
qnbs marked this conversation as resolved.
Outdated
}
await rotateIdbPassphrase(_current, newPassphrase, (progress) =>
setMigrationProgress(progress),
);
Expand Down
19 changes: 15 additions & 4 deletions services/fs/assetFsStore.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
/**
* FsAssetFsStore — Image and Binder binary asset filesystem storage.
* ENCRYPTION: plaintext — blob storage; at-rest encryption planned for Phase 2.
* ENCRYPTION: images — AES-256-GCM under the real at-rest passphrase when configured and
* unlocked, plaintext otherwise (see fsCore.ts's "Protected text files" section). Binder
* assets (`.bin`/`.meta.json`) remain plaintext — the binary path needs a byte-native encrypt
* (not the JSON-serializing helpers used here) and is tracked as a separate follow-up.
* QNBS-v3: Extracted from fileSystemService.ts.
*/

import { logger } from '../logger';
import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend';
import { retryFs, sanitizePathSegment, writeFileAtomic, writeTextFileAtomic } from './fsCore';
import {
protectTextValue,
retryFs,
sanitizePathSegment,
unprotectTextValue,
writeFileAtomic,
writeTextFileAtomic,
} from './fsCore';
import { FsSnapshotStore } from './snapshotFsStore';

export class FsAssetStore extends FsSnapshotStore {
Expand All @@ -23,7 +33,7 @@ export class FsAssetStore extends FsSnapshotStore {

const imageFile = await apis.join(imagesPath, `${sanitizePathSegment(id, 'image')}.png`);
const cleanBase64 = base64Data.replace(/^data:image\/png;base64,/, '');
await writeTextFileAtomic(apis, imageFile, cleanBase64);
await writeTextFileAtomic(apis, imageFile, await protectTextValue(cleanBase64));
}

async getImage(id: string): Promise<string | null> {
Expand All @@ -40,7 +50,8 @@ export class FsAssetStore extends FsSnapshotStore {
return null;
}

const base64Data = await retryFs(() => apis.readTextFile(imageFile));
const stored = await retryFs(() => apis.readTextFile(imageFile));
const base64Data = await unprotectTextValue(stored);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
return `data:image/png;base64,${base64Data}`;
} catch (error) {
logger.error('Failed to load image:', error);
Expand Down
18 changes: 10 additions & 8 deletions services/fs/codexFsStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* FsCodexStore — Story codex and RAG vector storage on the filesystem.
* ENCRYPTION: plaintext — project content; at-rest encryption planned for Phase 2 (P2-1).
* ENCRYPTION: AES-256-GCM under the real at-rest passphrase when configured and unlocked;
* plaintext otherwise — see fsCore.ts's "Protected text files" section.
* QNBS-v3: Extracted from fileSystemService.ts.
*/

Expand All @@ -9,9 +10,10 @@ import { logger } from '../logger';
import {
compressData,
decompressData,
readProtectedTextFile,
retryFs,
sanitizePathSegment,
writeTextFileAtomic,
writeProtectedTextFileAtomic,
} from './fsCore';
import { FsSettingsStore } from './settingsFsStore';

Expand All @@ -25,8 +27,8 @@ export class FsCodexStore extends FsSettingsStore {
const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const codexFile = await apis.join(codexDir, 'codex.snap');
// QNBS-v3: atomic write — a crash/power-loss mid-write must never leave codex.snap truncated.
await writeTextFileAtomic(apis, codexFile, compressData(codex));
// QNBS-v3: atomic + protected write — a crash/power-loss mid-write must never leave codex.snap truncated.
await writeProtectedTextFileAtomic(apis, codexFile, compressData(codex));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

async getStoryCodex(projectId: string): Promise<StoryCodex | null> {
Expand All @@ -36,7 +38,7 @@ export class FsCodexStore extends FsSettingsStore {
const safeId = sanitizePathSegment(projectId, 'project');
const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap');
if (!(await apis.exists(codexFile))) return null;
const content = await retryFs(() => apis.readTextFile(codexFile));
const content = await readProtectedTextFile(apis, codexFile);
return decompressData<StoryCodex>(content);
} catch (error) {
logger.error('Failed to load story codex:', error);
Expand Down Expand Up @@ -65,8 +67,8 @@ export class FsCodexStore extends FsSettingsStore {
const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const vectorsFile = await apis.join(codexDir, 'vectors.snap');
// QNBS-v3: atomic write — same crash-safety rationale as saveStoryCodex above.
await writeTextFileAtomic(apis, vectorsFile, compressData(vectors));
// QNBS-v3: atomic + protected write — same rationale as saveStoryCodex above.
await writeProtectedTextFileAtomic(apis, vectorsFile, compressData(vectors));
}

async getRagVectors(projectId: string): Promise<unknown[]> {
Expand All @@ -76,7 +78,7 @@ export class FsCodexStore extends FsSettingsStore {
const safeId = sanitizePathSegment(projectId, 'project');
const vectorsFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'vectors.snap');
if (!(await apis.exists(vectorsFile))) return [];
const content = await retryFs(() => apis.readTextFile(vectorsFile));
const content = await readProtectedTextFile(apis, vectorsFile);
return decompressData<unknown[]>(content);
} catch (error) {
logger.error('Failed to load RAG vectors:', error);
Expand Down
Loading