Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,31 @@ 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 binary blobs
(`services/fs/assetFsStore.ts`'s `.bin` files) — they need a byte-native encrypt path rather
than the JSON-serializing helpers used here, and 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
10 changes: 10 additions & 0 deletions hooks/useSettingsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,20 @@ 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,
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 @@ -398,6 +401,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);
await clearIdbPassphrase((progress) => setMigrationProgress(progress));

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 Block filesystem saves across the key transition

During desktop disable or rotation, normal project/settings autosaves remain active while this bridge and the subsequent IDB migration run. Because filesystem writes resolve the still-active old key without consulting the IDB migration journal, a pending save can overwrite a file after the bridge processed it but before clearIdbPassphrase() deletes the key (the rotation branch has the same window before swapping it), leaving fresh ciphertext under the obsolete key. Hold an exclusive filesystem-write admission lock, or otherwise pause and drain saves, across both the filesystem migration and key commit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid finding, deliberately not fixed inline — same root cause as #360 (no admission lock held across the fs migration and the IDB key commit), tracked there.

} finally {
setMigrationProgress(null);
Expand All @@ -408,6 +413,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);
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
90 changes: 90 additions & 0 deletions services/fs/fsCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

import LZString from 'lz-string';
import { logger } from '../logger';
import {
idbDecryptWithKey,
idbEncryptWithKey,
resolveProtectedWriteKey,
} from '../storage/storageEncryptionService';

// Dynamic imports for Tauri v2 plugin APIs — fail gracefully in browser
export type TauriApis = {
Expand Down Expand Up @@ -149,6 +154,91 @@ export function writeFileAtomic(apis: TauriApis, path: string, data: Uint8Array)
});
}

// --- Protected text files (opportunistic at-rest encryption) ---
// QNBS-v3 (2026-08-13): desktop project/settings/snapshot/Codex/RAG-vector data was previously
// always plaintext, regardless of the "Encrypt project data at rest" setting — enabling it only
// gated the IndexedDB path (web build); this fs-backed store ignored it entirely. Reuses
// services/storage/storageEncryptionService.ts's real user-passphrase-derived key directly (same
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
// pattern as settingsFsStore.ts's API-key fix) rather than a second parallel crypto/migration
Comment thread
qnbs marked this conversation as resolved.
// system. Lazy/opportunistic migration by design: a save encrypts if a key is currently available;
// a read transparently handles either format. Existing plaintext files stay plaintext until their
// next save (autosave already runs on a short interval) — no explicit "migrate everything now"
// step, no data-loss risk, and no new failure mode beyond what saves already have.

const PROTECTED_TEXT_SCHEME = 'protected-v1';

interface ProtectedTextEnvelope {
scheme: typeof PROTECTED_TEXT_SCHEME;
data: string;
}

function parseProtectedTextEnvelope(raw: string): ProtectedTextEnvelope | null {
// QNBS-v3: plaintext content here is compressData()'s output — either plain JSON or an
// LZ-compressed string carrying its own \x00lz1\x00 sentinel, never valid envelope JSON. A
// JSON.parse failure (the LZ-compressed case) or a shape mismatch both simply mean "not
// protected" rather than an error — this is deliberately a probe, not a strict parser.
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed !== null &&
typeof parsed === 'object' &&
(parsed as Record<string, unknown>)['scheme'] === PROTECTED_TEXT_SCHEME &&
typeof (parsed as Record<string, unknown>)['data'] === 'string'
) {
return parsed as ProtectedTextEnvelope;
Comment thread
qnbs marked this conversation as resolved.
Outdated
}
} catch {
/* not JSON at all — definitely plaintext (or LZ-compressed plaintext) */
}
return null;
}

/**
* Protect a single text value (e.g. compressData()'s output), encrypted under the real at-rest key
* when one is available. Value-level, not file-level — lets a caller (snapshotFsStore.ts) embed
* the result as one field inside an otherwise-plaintext JSON envelope, so listing/metadata reads
* never need to decrypt (mirrors the IDB path's own "encryption applied at the value level" design).
*/
export async function protectTextValue(plaintext: string): Promise<string> {
const key = await resolveProtectedWriteKey();
if (!key) return plaintext;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
return JSON.stringify({
scheme: PROTECTED_TEXT_SCHEME,
data: bytesToBase64(await idbEncryptWithKey(key, plaintext)),
Comment thread
qnbs marked this conversation as resolved.
Comment thread
qnbs marked this conversation as resolved.
Comment on lines +228 to +230

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 Bind each ciphertext to its record identity

When multiple filesystem records use the same encryption key, these envelopes are transferable because neither the path nor a logical store/record identity is authenticated. A valid encrypted project.json, snapshot data field, image, or Codex/RAG payload can therefore be moved to another record and still decrypt successfully; the corresponding getters trust the decrypted payload without checking it belongs to the requested project, snapshot, or image. Pass a stable logical context as AES-GCM additional authenticated data, or encrypt and validate the record identity with the payload, so cross-file substitution fails authentication.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid finding, deliberately not fixed inline — tracked in #361 (opened this round). Binding ciphertext to its record identity (AAD or an embedded identity check, mirroring what the API-key path already does for {provider, apiKey}) is a design decision with its own migration story, scoped separately from this PR.

});
Comment on lines +221 to +231

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: Filesystem writes resolve and capture the current key without participating in withProtectedWriteAdmission or checking assertNoActiveEncryptionMigration. During passphrase rotation, a save can encrypt a file with the old key while the IDB migration completes and switches the active key to the new generation; subsequent reads then cannot decrypt that filesystem file. Hold the protected-write admission across key resolution, encryption, and the filesystem commit, or otherwise make filesystem writes part of the migration protocol. [race condition]

Severity Level: Critical 🚨
- ❌ Desktop project saves can become unreadable after passphrase rotation.
- ❌ `FsProjectStore.loadProject()` returns null after decryption failure.
- ⚠️ Filesystem data is outside the IndexedDB migration adapter set.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/fs/fsCore.ts
**Line:** 170:176
**Comment:**
	*Race Condition: Filesystem writes resolve and capture the current key without participating in `withProtectedWriteAdmission` or checking `assertNoActiveEncryptionMigration`. During passphrase rotation, a save can encrypt a file with the old key while the IDB migration completes and switches the active key to the new generation; subsequent reads then cannot decrypt that filesystem file. Hold the protected-write admission across key resolution, encryption, and the filesystem commit, or otherwise make filesystem writes part of the migration protocol.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid finding, deliberately not fixed inline — tracked in #360 (opened this round), which covers this together with the related read-side and set-time-write races other reviewers flagged on this same PR. Closing it properly means giving the fs path the same Web Locks admission control the IDB path already has, which is a real architectural addition best scoped on its own rather than folded into an already-large PR.

}

/**
* Reverse of protectTextValue — transparently handles either a protected envelope or plain
* (legacy/unprotected) text. Propagates IdbStorageLockedError when the value is protected but the
* session is locked — callers should let that fail closed like any other protected read, not fall
* back to treating it as absent.
*/
export async function unprotectTextValue(stored: string): Promise<string> {
const envelope = parseProtectedTextEnvelope(stored);
if (!envelope) return stored;
const key = await resolveProtectedWriteKey();
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
if (!key) {
// Sentinel was cleared/disabled since this value was saved — nothing left to decrypt with.
throw new Error('Protected value exists but at-rest encryption is no longer configured');
}
return idbDecryptWithKey<string>(key, base64ToBytes(envelope.data));
Comment thread
qnbs marked this conversation as resolved.
Outdated
}

/** Whole-file variant of protectTextValue, for stores with no separate plaintext metadata to preserve. */
export async function writeProtectedTextFileAtomic(
apis: TauriApis,
path: string,
plaintext: string,
): Promise<void> {
await writeTextFileAtomic(apis, path, await protectTextValue(plaintext));
Comment thread
qnbs marked this conversation as resolved.
Outdated
}

/** Whole-file variant of unprotectTextValue, for stores with no separate plaintext metadata to preserve. */
export async function readProtectedTextFile(apis: TauriApis, path: string): Promise<string> {
return unprotectTextValue(await retryFs(() => apis.readTextFile(path)));
Comment on lines +324 to +325

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 Acquire read admission before fetching ciphertext

When a disable or rotation overlaps a filesystem read that has already started, this fetches the old bytes before unprotectTextValue() acquires shared admission. The migration can therefore acquire exclusive admission in between, rewrite the file, and commit the new or absent key; the resumed read then decrypts stale ciphertext with the wrong key. In the disable case, snapshot reads swallow the resulting generic error as null, and restoreSnapshotThunk.fulfilled assigns that null payload to project state. Keep the filesystem read and decryption under one shared admission.

Useful? React with 👍 / 👎.

}

// --- LZ-String compression (mirrors dbService threshold and prefix) ---

const COMPRESS_THRESHOLD = 10_240;
Expand Down
Loading