From 8717e9403620b50f96ce5969fc55d43b0eee04ff Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:17:57 +0200 Subject: [PATCH 01/12] security(desktop): encrypt project data at rest (text stores) Enabling Settings -> Privacy -> "Encrypt project data at rest" only ever 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 images as plaintext regardless of the setting - the same passphrase unlock screen appeared on desktop, but it gated nothing on the filesystem side. Desktop now reuses services/storage/storageEncryptionService.ts's real passphrase-derived key directly, mirroring the API-key fix in the sibling fix/desktop-api-key-encryption branch: AES-256-GCM protection when a passphrase is configured and unlocked, honest plaintext otherwise. No new module, no changes to that already-audited service. Migration is lazy/opportunistic by design (per discussion): 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 "encrypt everything now" step, no data-loss risk, no new failure mode beyond what saves already have. New shared primitives in fsCore.ts: - protectTextValue/unprotectTextValue - value-level protection. Used directly by snapshotFsStore.ts so only the snapshot's `data` field is protected, keeping name/date/wordCount metadata plaintext - listing snapshots never needs to decrypt just to render names and dates. - writeProtectedTextFileAtomic/readProtectedTextFile - whole-file wrappers around the above + the existing atomic-write primitive, for stores with no metadata/content split (project.json, settings.json, codex.snap, vectors.snap, images). Wired into projectFsStore (project.json), settingsFsStore (settings.json only - API keys are the sibling PR's concern), codexFsStore (codex + RAG vectors), snapshotFsStore (data field only), assetFsStore (images). Not covered by this PR: binder-asset binary blobs (assetFsStore.ts's .bin files) - they need a byte-native encrypt path (StorageEncryptionService's encryptBytes/decryptBytes operating on Uint8Array directly) rather than the JSON-serializing idbEncryptWithKey/idbDecryptWithKey used here, which would be wasteful for potentially-large binary blobs. Tracked as a separate follow-up rather than forcing it into this PR. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 14 ++++ services/fs/assetFsStore.ts | 19 ++++- services/fs/codexFsStore.ts | 18 +++-- services/fs/fsCore.ts | 90 ++++++++++++++++++++++ services/fs/projectFsStore.ts | 10 ++- services/fs/settingsFsStore.ts | 16 +++- services/fs/snapshotFsStore.ts | 15 ++-- tests/unit/services/fs/fsCore.test.ts | 99 ++++++++++++++++++++++++- tests/unit/services/fs/fsStores.test.ts | 94 ++++++++++++++++++++++- 9 files changed, 348 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dcf4347..7a78573a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,20 @@ 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 + 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. ### Fixed diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts index 55e5833a..c50dc310 100644 --- a/services/fs/assetFsStore.ts +++ b/services/fs/assetFsStore.ts @@ -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 { @@ -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 { @@ -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); return `data:image/png;base64,${base64Data}`; } catch (error) { logger.error('Failed to load image:', error); diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts index ce7c0f75..940b2bf7 100644 --- a/services/fs/codexFsStore.ts +++ b/services/fs/codexFsStore.ts @@ -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. */ @@ -9,9 +10,10 @@ import { logger } from '../logger'; import { compressData, decompressData, + readProtectedTextFile, retryFs, sanitizePathSegment, - writeTextFileAtomic, + writeProtectedTextFileAtomic, } from './fsCore'; import { FsSettingsStore } from './settingsFsStore'; @@ -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)); } async getStoryCodex(projectId: string): Promise { @@ -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(content); } catch (error) { logger.error('Failed to load story codex:', error); @@ -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 { @@ -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(content); } catch (error) { logger.error('Failed to load RAG vectors:', error); diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 6d221db7..6e17495a 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -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 = { @@ -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 +// pattern as settingsFsStore.ts's API-key fix) rather than a second parallel crypto/migration +// 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)['scheme'] === PROTECTED_TEXT_SCHEME && + typeof (parsed as Record)['data'] === 'string' + ) { + return parsed as ProtectedTextEnvelope; + } + } 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 { + const key = await resolveProtectedWriteKey(); + if (!key) return plaintext; + return JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(key, plaintext)), + }); +} + +/** + * 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 { + const envelope = parseProtectedTextEnvelope(stored); + if (!envelope) return stored; + const key = await resolveProtectedWriteKey(); + 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(key, base64ToBytes(envelope.data)); +} + +/** 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 { + await writeTextFileAtomic(apis, path, await protectTextValue(plaintext)); +} + +/** Whole-file variant of unprotectTextValue, for stores with no separate plaintext metadata to preserve. */ +export async function readProtectedTextFile(apis: TauriApis, path: string): Promise { + return unprotectTextValue(await retryFs(() => apis.readTextFile(path))); +} + // --- LZ-String compression (mirrors dbService threshold and prefix) --- const COMPRESS_THRESHOLD = 10_240; diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 0f59d538..16259b72 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -1,6 +1,8 @@ /** * FsProjectStore — Project CRUD + import/export on the filesystem. - * ENCRYPTION: plaintext — manuscript data; at-rest encryption planned for Phase 2 (P2-1). + * ENCRYPTION: AES-256-GCM under the real at-rest passphrase (services/storage/ + * storageEncryptionService.ts) when one is configured and unlocked; plaintext otherwise — + * opportunistic/lazy migration, see fsCore.ts's writeProtectedTextFileAtomic doc comment. * QNBS-v3: Extracted from fileSystemService.ts. saveProject triggers auto-snapshot via FsSnapshotStore. */ @@ -13,8 +15,10 @@ import { FsAssetStore } from './assetFsStore'; import { compressData, decompressData, + readProtectedTextFile, retryFs, sanitizePathSegment, + writeProtectedTextFileAtomic, writeTextFileAtomic, } from './fsCore'; @@ -42,7 +46,7 @@ export class FsProjectStore extends FsAssetStore { } const projectFile = await apis.join(projectPath, 'project.json'); - await writeTextFileAtomic(apis, projectFile, compressData(flat)); + await writeProtectedTextFileAtomic(apis, projectFile, compressData(flat)); // QNBS-v3 (#332): documented best-effort abort — the project data above already saved; a failed marker write only degrades the next cold-boot's project selection, not worth failing this save over. await this.setActiveProjectId(projectId).catch((error) => { logger.warn('Failed to persist active-project marker (project save itself succeeded)', { @@ -93,7 +97,7 @@ export class FsProjectStore extends FsAssetStore { return null; } - const content = await retryFs(() => apis.readTextFile(projectFile)); + const content = await readProtectedTextFile(apis, projectFile); return decompressData(content); } catch (error) { logger.error('Failed to load project:', error); diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index 2ef7d323..db02b6e7 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -22,7 +22,15 @@ import { resolveProtectedWriteKey, } from '../storage/storageEncryptionService'; import type { TauriApis } from './fsCore'; -import { base64ToBytes, bytesToBase64, FsCore, retryFs, writeTextFileAtomic } from './fsCore'; +import { + base64ToBytes, + bytesToBase64, + FsCore, + readProtectedTextFile, + retryFs, + writeProtectedTextFileAtomic, + writeTextFileAtomic, +} from './fsCore'; const PLAINTEXT_SCHEME = 'plaintext-v1'; const PROTECTED_SCHEME = 'protected-v1'; @@ -38,6 +46,8 @@ interface ProtectedApiKeyPayload { } export class FsSettingsStore extends FsCore { + // ENCRYPTION: AES-256-GCM under the real at-rest passphrase when configured and unlocked, + // plaintext otherwise — see fsCore.ts's "Protected text files" section. async saveSettings(settings: Settings): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); @@ -48,7 +58,7 @@ export class FsSettingsStore extends FsCore { } const settingsFile = await apis.join(configPath, 'settings.json'); - await writeTextFileAtomic(apis, settingsFile, JSON.stringify(settings, null, 2)); + await writeProtectedTextFileAtomic(apis, settingsFile, JSON.stringify(settings, null, 2)); } async loadSettings(): Promise { @@ -61,7 +71,7 @@ export class FsSettingsStore extends FsCore { return null; } - const content = await retryFs(() => apis.readTextFile(settingsFile)); + const content = await readProtectedTextFile(apis, settingsFile); const parsed = JSON.parse(content) as Record; // QNBS-v3: reuse the same normalizer as the IDB path — older desktop settings files can predate newer required Settings fields (e.g. writingSurfaceStyle); an unchecked `as Settings` cast would let those fall through as undefined at runtime. return normalizePersistedSettings(parsed); diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts index f995271d..26eee824 100644 --- a/services/fs/snapshotFsStore.ts +++ b/services/fs/snapshotFsStore.ts @@ -10,17 +10,22 @@ import { compressData, countProjectWords, decompressData, + protectTextValue, retryFs, + unprotectTextValue, writeTextFileAtomic, } from './fsCore'; -// Envelope stored in each snapshot file — outer shell is plain JSON, `data` field is compressed. +// Envelope stored in each snapshot file — outer shell is plain JSON (id/name/date/wordCount stay +// plaintext so listSnapshots() never needs to decrypt just to render a list), `data` field is +// compressData()'d and, when at-rest encryption is configured and unlocked, further protected via +// protectTextValue — see fsCore.ts's "Protected text files" section. interface SnapshotEnvelope { id: number; name: string; date: string; wordCount: number; - data: string; // compressData(projectData) + data: string; // compressData(projectData), optionally protectTextValue()'d } export class FsSnapshotStore extends FsCodexStore { @@ -39,7 +44,7 @@ export class FsSnapshotStore extends FsCodexStore { name: snapshotLabel, date: new Date().toISOString(), wordCount: countProjectWords(data), - data: compressData(data), + data: await protectTextValue(compressData(data)), }; const snapshotFile = await apis.join(snapshotsPath, `${id}.json`); // QNBS-v3: atomic write — a crash/power-loss mid-write must never leave a snapshot truncated. @@ -59,9 +64,9 @@ export class FsSnapshotStore extends FsCodexStore { const content = await retryFs(() => apis.readTextFile(snapshotFile)); const envelope = JSON.parse(content) as SnapshotEnvelope; - // New format: envelope with compressed data field + // New format: envelope with compressed (optionally protected) data field if (envelope && typeof envelope.data === 'string') { - return decompressData(envelope.data); + return decompressData(await unprotectTextValue(envelope.data)); } // Legacy format: raw project data stored directly return envelope; diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts index 6bbedbff..747d076d 100644 --- a/tests/unit/services/fs/fsCore.test.ts +++ b/tests/unit/services/fs/fsCore.test.ts @@ -4,7 +4,7 @@ * sanitization, and word counting — no Tauri APIs required. */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TauriApis } from '../../../../services/fs/fsCore'; import { base64ToBytes, @@ -13,12 +13,41 @@ import { compressData, countProjectWords, decompressData, + protectTextValue, + readProtectedTextFile, retryFs, sanitizePathSegment, + unprotectTextValue, writeFileAtomic, + writeProtectedTextFileAtomic, writeTextFileAtomic, } from '../../../../services/fs/fsCore'; +// QNBS-v3: controllable fake for storageEncryptionService's IDB-backed sentinel/session state — see tests/unit/services/fs/fsStores.test.ts for the full rationale (same pattern, scoped here to the pure protectTextValue/unprotectTextValue helpers rather than a whole store). +const { cryptoState } = vi.hoisted(() => ({ + cryptoState: { activeKey: null as CryptoKey | null, sentinelConfigured: false }, +})); +vi.mock('../../../../services/storage/storageEncryptionService', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + hasPassphraseSentinel: () => Promise.resolve(cryptoState.sentinelConfigured), + resolveProtectedWriteKey: () => { + if (cryptoState.activeKey) return Promise.resolve(cryptoState.activeKey); + if (cryptoState.sentinelConfigured) return Promise.reject(new actual.IdbStorageLockedError()); + return Promise.resolve(null); + }, + }; +}); + +import { StorageEncryptionService } from '../../../../services/storage/storageEncryptionService'; + +beforeEach(() => { + cryptoState.activeKey = null; + cryptoState.sentinelConfigured = false; +}); + // QNBS-v3: entries are plain names, directories suffixed with '/' — just enough to model a nested tree for cleanupOrphanedTempFiles' recursive walk, independent of makeAtomicWriteFake above. function makeDirTreeFake(tree: Record) { const removed: string[] = []; @@ -46,7 +75,10 @@ function makeDirTreeFake(tree: Record) { function makeAtomicWriteFake() { const text = new Map(); const bin = new Map(); - const apis: Pick = { + const apis: Pick< + TauriApis, + 'writeTextFile' | 'writeFile' | 'readTextFile' | 'rename' | 'remove' + > = { writeTextFile: (p, c) => { text.set(p, c); return Promise.resolve(); @@ -55,6 +87,10 @@ function makeAtomicWriteFake() { bin.set(p, d); return Promise.resolve(); }, + readTextFile: (p) => { + if (!text.has(p)) return Promise.reject(new Error(`ENOENT ${p}`)); + return Promise.resolve(text.get(p) as string); + }, rename: (oldPath, newPath) => { if (text.has(oldPath)) { text.set(newPath, text.get(oldPath) as string); @@ -74,6 +110,14 @@ function makeAtomicWriteFake() { return { apis: apis as TauriApis, text, bin }; } +async function enableTestPassphrase(): Promise { + cryptoState.activeKey = await new StorageEncryptionService().deriveKey( + 'test-passphrase', + new Uint8Array(32).fill(7), + ); + cryptoState.sentinelConfigured = true; +} + describe('retryFs', () => { it('returns on first success without retrying', async () => { const fn = vi.fn().mockResolvedValue('ok'); @@ -288,6 +332,57 @@ describe('cleanupOrphanedTempFiles', () => { }); }); +describe('protectTextValue / unprotectTextValue / writeProtectedTextFileAtomic / readProtectedTextFile', () => { + it('passes plaintext through unchanged when no at-rest passphrase is configured', async () => { + const protectedValue = await protectTextValue('plain compressed data'); + expect(protectedValue).toBe('plain compressed data'); + expect(await unprotectTextValue(protectedValue)).toBe('plain compressed data'); + }); + + it('encrypts under the real key when a passphrase is configured and unlocked, and round-trips', async () => { + await enableTestPassphrase(); + const protectedValue = await protectTextValue('sensitive manuscript text'); + expect(protectedValue).not.toContain('sensitive manuscript text'); + expect(JSON.parse(protectedValue).scheme).toBe('protected-v1'); + expect(await unprotectTextValue(protectedValue)).toBe('sensitive manuscript text'); + }); + + it("treats LZ-compressed plaintext (compressData()'s large-payload output) as plaintext, not a protected envelope", async () => { + const lzLike = `\x00lz1\x00${'x'.repeat(50)}`; + expect(await unprotectTextValue(lzLike)).toBe(lzLike); + }); + + it('throws when a protected value exists but at-rest encryption is no longer configured', async () => { + await enableTestPassphrase(); + const protectedValue = await protectTextValue('secret'); + cryptoState.activeKey = null; + cryptoState.sentinelConfigured = false; // sentinel cleared/disabled since this was saved + await expect(unprotectTextValue(protectedValue)).rejects.toThrow(/no longer configured/); + }); + + it('propagates a locked error (fail closed) when a protected value exists but the session is locked', async () => { + await enableTestPassphrase(); + const protectedValue = await protectTextValue('secret'); + cryptoState.activeKey = null; // sentinelConfigured stays true — locked, not disabled + await expect(unprotectTextValue(protectedValue)).rejects.toThrow(/storage is locked/i); + }); + + it('writeProtectedTextFileAtomic + readProtectedTextFile round-trip through the filesystem, encrypted', async () => { + await enableTestPassphrase(); + const { apis, text } = makeAtomicWriteFake(); + await writeProtectedTextFileAtomic(apis, '/app/project.json', '{"title":"My Novel"}'); + + expect(text.get('/app/project.json')).not.toContain('My Novel'); + expect(await readProtectedTextFile(apis, '/app/project.json')).toBe('{"title":"My Novel"}'); + }); + + it('writeProtectedTextFileAtomic writes plaintext when no passphrase is configured (unchanged default)', async () => { + const { apis, text } = makeAtomicWriteFake(); + await writeProtectedTextFileAtomic(apis, '/app/project.json', '{"title":"My Novel"}'); + expect(text.get('/app/project.json')).toBe('{"title":"My Novel"}'); + }); +}); + describe('compressData / decompressData', () => { it('round-trips small data uncompressed (plain JSON)', () => { const data = { a: 1, b: ['x', 'y'], c: 'hello' }; diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 5e17e890..dedc2a9a 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -147,6 +147,14 @@ afterEach(() => { vi.clearAllMocks(); }); +async function enableTestPassphrase(): Promise { + cryptoState.activeKey = await new StorageEncryptionService().deriveKey( + 'test-passphrase', + new Uint8Array(32).fill(7), + ); + cryptoState.sentinelConfigured = true; +} + describe('FsProjectStore — projects', () => { const project = { id: 'p1', @@ -175,6 +183,26 @@ describe('FsProjectStore — projects', () => { expect(await store.listProjects()).toEqual([]); }); + // QNBS-v3 (2026-08-13): the actual fix under test — desktop project data previously ignored + // the at-rest encryption setting entirely (README/docs corrected in a companion PR); it's now + // real AES-GCM protection when a passphrase is configured and unlocked. + it('encrypts project.json on disk when at-rest encryption is configured and unlocked, and still round-trips', async () => { + await enableTestPassphrase(); + await store.saveProject(project as never); + + const onDisk = fake.text.get('/app/projects/p1/project.json') as string; + expect(onDisk).not.toContain('My Novel'); + expect(JSON.parse(onDisk).scheme).toBe('protected-v1'); + + expect((await store.loadProject('p1'))?.title).toBe('My Novel'); + }); + + it('leaves project.json as plaintext when no at-rest passphrase is configured (unchanged default)', async () => { + await store.saveProject(project as never); + const onDisk = fake.text.get('/app/projects/p1/project.json') as string; + expect(onDisk).toContain('My Novel'); + }); + // QNBS-v3 (#332): saveProject records the active-project marker so cold boot doesn't pick an arbitrary readDir() entry. it('records the saved project as the active-project marker, updating it on each subsequent save', async () => { expect(await store.getActiveProjectId()).toBeNull(); @@ -240,8 +268,18 @@ describe('FsSettingsStore — settings + encrypted API keys', () => { expect(await store.loadSettings()).toBeNull(); }); - // QNBS-v3 (2026-08-13, F-05/F-06 follow-up): no passphrase configured — honest plaintext, - // not a fake-secret derivation. + it('encrypts settings.json on disk when at-rest encryption is configured, and still round-trips', async () => { + await enableTestPassphrase(); + await store.saveSettings({ appearancePreset: 'sepia' } as never); + + const onDisk = fake.text.get('/app/config/settings.json') as string; + expect(onDisk).not.toContain('sepia'); + expect(JSON.parse(onDisk).scheme).toBe('protected-v1'); + + expect((await store.loadSettings())?.appearancePreset).toBe('sepia'); + }); + + // QNBS-v3 (2026-08-13, F-05/F-06 follow-up): no passphrase configured — honest plaintext, not a fake-secret derivation. it('round-trips an API key as plaintext when no at-rest passphrase is configured', async () => { await store.saveApiKey('openai', 'sk-secret-123'); const stored = JSON.parse(fake.text.get('/app/config/openai_key.enc.json') as string); @@ -443,6 +481,30 @@ describe('FsSnapshotStore — snapshots', () => { expect(await store.listSnapshots()).toEqual([]); expect(await store.hasSavedData()).toBe(false); }); + + // QNBS-v3: value-level protection, not file-level — only the `data` field is protected so + // listSnapshots() (name/date/wordCount) never needs to decrypt just to render a list. + it('protects only the data field when at-rest encryption is configured, keeping name/date/wordCount plaintext and listable', async () => { + await enableTestPassphrase(); + const id = await store.saveSnapshot('My Snapshot', { + manuscript: [{ content: 'secret prose' }], + }); + + const onDiskFile = [...fake.text.keys()].find((k) => k.endsWith(`${id}.json`)) as string; + const onDisk = JSON.parse(fake.text.get(onDiskFile) as string); + expect(onDisk.name).toBe('My Snapshot'); // metadata stays plaintext + expect(onDisk.data).not.toContain('secret prose'); // content is protected + expect(JSON.parse(onDisk.data).scheme).toBe('protected-v1'); + + // Listing must not require a passphrase/key at all — lock the session and confirm it still works. + cryptoState.activeKey = null; + const list = await store.listSnapshots(); + expect(list.find((s) => s.id === id)?.name).toBe('My Snapshot'); + + // Reading the actual content still requires (and correctly uses) the key once unlocked again. + await enableTestPassphrase(); + expect(await store.getSnapshotData(id)).toEqual({ manuscript: [{ content: 'secret prose' }] }); + }); }); describe('FsCodexStore — codex + RAG vectors', () => { @@ -461,6 +523,23 @@ describe('FsCodexStore — codex + RAG vectors', () => { await store.deleteRagVectors('p1'); expect(await store.getRagVectors('p1')).toEqual([]); }); + + it('encrypts codex.snap and vectors.snap on disk when at-rest encryption is configured, and still round-trips', async () => { + await enableTestPassphrase(); + await store.saveStoryCodex({ projectId: 'p1', entries: [{ k: 'secret-entity' }] } as never); + await store.saveRagVectors('p1', [{ id: 1 }]); + + const codexOnDisk = fake.text.get('/app/projects/p1/codex/codex.snap') as string; + const vectorsOnDisk = fake.text.get('/app/projects/p1/codex/vectors.snap') as string; + expect(codexOnDisk).not.toContain('secret-entity'); + expect(JSON.parse(codexOnDisk).scheme).toBe('protected-v1'); + expect(JSON.parse(vectorsOnDisk).scheme).toBe('protected-v1'); + + expect((await store.getStoryCodex('p1')) as { entries?: unknown[] } | null).toEqual( + expect.objectContaining({ entries: [{ k: 'secret-entity' }] }), + ); + expect(await store.getRagVectors('p1')).toEqual([{ id: 1 }]); + }); }); describe('FsAssetStore — images + binder assets', () => { @@ -471,6 +550,17 @@ describe('FsAssetStore — images + binder assets', () => { expect(await store.getImage('char-1')).toBeNull(); }); + it('encrypts an image on disk when at-rest encryption is configured, and still round-trips', async () => { + await enableTestPassphrase(); + await store.saveImage('char-1', 'data:image/png;base64,QUJD'); + + const onDisk = fake.text.get('/app/images/char-1.png') as string; + expect(onDisk).not.toContain('QUJD'); + expect(JSON.parse(onDisk).scheme).toBe('protected-v1'); + + expect(await store.getImage('char-1')).toBe('data:image/png;base64,QUJD'); + }); + it('round-trips a binder binary asset with metadata', async () => { const data = new Uint8Array([1, 2, 3, 4]).buffer; await store.saveBinderAsset('p1', 'a1', data, { From 8106b7a9879cd18d9d1fad9494512262ba71cf71 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:36:38 +0200 Subject: [PATCH 02/12] security(desktop): migrate fs-backed protected data before disable/rotate Disabling or rotating the at-rest passphrase destroyed/swapped the shared salt/session key with no awareness that desktop's services/fs/* data (project.json, settings, API keys, snapshots, Codex, RAG vectors, images) depends on the same key material, permanently stranding it. New services/fs/fsEncryptionMigration.ts converts every fs-backed protected file to plaintext (disable) or re-encrypts it under an independently-derived target key (rotate) BEFORE the sentinel/session key is touched; a file that fails to decrypt aborts the whole operation rather than silently stranding it. Wired into useSettingsView's handlePassphraseConfirm, gated on isTauriRuntime(). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 11 + hooks/useSettingsView.ts | 10 + services/fs/fsEncryptionMigration.ts | 149 +++++++++++ services/fs/settingsFsStore.ts | 35 +++ services/storage/storageEncryptionService.ts | 12 + tests/unit/hooks/useSettingsView.test.ts | 95 +++++++ .../services/fs/fsEncryptionMigration.test.ts | 250 ++++++++++++++++++ .../storage/storageEncryptionService.test.ts | 37 +++ 8 files changed, 599 insertions(+) create mode 100644 services/fs/fsEncryptionMigration.ts create mode 100644 tests/unit/services/fs/fsEncryptionMigration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a78573a..17858f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index 7e652f1b..266256ec 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -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, @@ -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)); } finally { setMigrationProgress(null); @@ -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); + } await rotateIdbPassphrase(_current, newPassphrase, (progress) => setMigrationProgress(progress), ); diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts new file mode 100644 index 00000000..b1c5d882 --- /dev/null +++ b/services/fs/fsEncryptionMigration.ts @@ -0,0 +1,149 @@ +/** + * Desktop fs-backed protected-data migration bridge. `services/storage/storageEncryptionService.ts`'s + * clearIdbPassphrase()/rotateIdbPassphrase() own the shared salt/sentinel/session key but have no + * awareness that services/fs/* (Tauri desktop project data + API keys) depends on that same key + * material via protectTextValue()/unprotectTextValue() (see fsCore.ts). Left uncoordinated, a + * disable would destroy the sentinel while fs-backed files stay encrypted under the now-unrecoverable + * old key; a rotate would swap the active key while fs-backed files stay under the old one — both + * permanently stranding desktop project data. This module must run to completion BEFORE either of + * those functions touches the sentinel/active key, using the still-valid OLD session key. + * QNBS-v3 (F-05/F-06 follow-up, 2026-08-13): callers must gate on isTauriRuntime() — a no-op cost + * on web, since fs-backed files simply don't exist there. + */ + +import { idbEncryptWithKey } from '../storage/storageEncryptionService'; +import { + bytesToBase64, + loadTauriApis, + type TauriApis, + unprotectTextValue, + writeTextFileAtomic, +} from './fsCore'; +import { fileSystemService } from './index'; + +const PROTECTED_TEXT_SCHEME = 'protected-v1'; + +async function listDirEntries( + apis: TauriApis, + dir: string, +): Promise<{ name?: string; isDirectory?: boolean }[]> { + try { + return await apis.readDir(dir); + } catch { + return []; // directory may not exist yet — nothing to migrate under it + } +} + +/** + * Re-keys a single whole-file-protected text file (project.json / settings.json / codex.snap / + * vectors.snap / images/*.png) under targetKey, or unwraps it to plain text when targetKey is + * null (disable). No-ops when the file is absent or not currently protected — unprotectTextValue() + * returns its input completely unchanged in that case, detected here via reference equality, so no + * envelope-shape knowledge needs to be duplicated from fsCore.ts. + */ +async function reprotectWholeFile( + apis: TauriApis, + path: string, + targetKey: CryptoKey | null, +): Promise { + const raw = await apis.readTextFile(path).catch(() => null); + if (raw === null) return; + const plaintext = await unprotectTextValue(raw); + if (plaintext === raw) return; // not a protected envelope — nothing to migrate + const content = targetKey + ? JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(targetKey, plaintext)), + }) + : plaintext; + await writeTextFileAtomic(apis, path, content); +} + +interface SnapshotEnvelopeShape { + data?: unknown; + [key: string]: unknown; +} + +/** Re-keys only the value-level-protected `data` field inside a snapshot envelope file. */ +async function reprotectSnapshotFile( + apis: TauriApis, + path: string, + targetKey: CryptoKey | null, +): Promise { + const raw = await apis.readTextFile(path).catch(() => null); + if (raw === null) return; + let envelope: SnapshotEnvelopeShape; + try { + envelope = JSON.parse(raw) as SnapshotEnvelopeShape; + } catch { + return; // legacy raw-project-data snapshot format predates the envelope — never protected + } + if (typeof envelope.data !== 'string') return; + const plaintext = await unprotectTextValue(envelope.data); + if (plaintext === envelope.data) return; // data field wasn't protected + envelope.data = targetKey + ? JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(targetKey, plaintext)), + }) + : plaintext; + await writeTextFileAtomic(apis, path, JSON.stringify(envelope)); +} + +/** + * Converts every fs-backed protected file to targetKey (rotate) or to plaintext (targetKey=null, + * disable). Any positively-identified protected file that fails to decrypt under the current + * session key throws immediately rather than being skipped — a partial migration must never + * silently strand a file at a key that's about to become unrecoverable; the caller's disable/ + * rotate action fails safely (nothing changed) rather than completing with lost data. + */ +export async function migrateAllProtectedFsData(targetKey: CryptoKey | null): Promise { + const apis = await loadTauriApis(); + const appDataPath = await apis.appDataDir(); + + const configPath = await apis.join(appDataPath, 'config'); + const configEntries = await listDirEntries(apis, configPath); + await Promise.all( + configEntries.map(async (entry) => { + if (!entry.name || entry.isDirectory) return; + if (entry.name === 'settings.json') { + const entryPath = await apis.join(configPath, entry.name); + await reprotectWholeFile(apis, entryPath, targetKey); + } else if (entry.name.endsWith('_key.enc.json')) { + const provider = entry.name.slice(0, -'_key.enc.json'.length); + await fileSystemService.reprotectApiKeyFile(provider, targetKey); + } + }), + ); + + const snapshotsPath = await apis.join(appDataPath, 'snapshots'); + const snapshotEntries = await listDirEntries(apis, snapshotsPath); + await Promise.all( + snapshotEntries.map(async (entry) => { + if (!entry.name?.endsWith('.json')) return; + const filePath = await apis.join(snapshotsPath, entry.name); + await reprotectSnapshotFile(apis, filePath, targetKey); + }), + ); + + const imagesPath = await apis.join(appDataPath, 'images'); + const imageEntries = await listDirEntries(apis, imagesPath); + await Promise.all( + imageEntries.map(async (entry) => { + if (!entry.name?.endsWith('.png')) return; + const filePath = await apis.join(imagesPath, entry.name); + await reprotectWholeFile(apis, filePath, targetKey); + }), + ); + + const projectIds = await fileSystemService.listProjects(); + await Promise.all( + projectIds.map(async (projectId) => { + const projectDir = await apis.join(appDataPath, 'projects', projectId); + await reprotectWholeFile(apis, await apis.join(projectDir, 'project.json'), targetKey); + const codexDir = await apis.join(projectDir, 'codex'); + await reprotectWholeFile(apis, await apis.join(codexDir, 'codex.snap'), targetKey); + await reprotectWholeFile(apis, await apis.join(codexDir, 'vectors.snap'), targetKey); + }), + ); +} diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index db02b6e7..11029847 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -203,6 +203,41 @@ export class FsSettingsStore extends FsCore { } } + /** + * Re-key a single provider's protected API key file for a passphrase disable/rotate operation: + * decrypts under the still-active OLD session key and re-encrypts under targetKey, or writes + * plaintext-v1 when targetKey is null (disable). No-op when no key file exists or it isn't + * currently protected. Must be called by the migration bridge (services/fs/ + * fsEncryptionMigration.ts) BEFORE storageEncryptionService.ts swaps/discards the active key — + * after that point the old key is unrecoverable and this file would be permanently stranded. + */ + async reprotectApiKeyFile(provider: string, targetKey: CryptoKey | null): Promise { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const keyFile = await apis.join(appDataPath, 'config', `${provider}_key.enc.json`); + if (!(await apis.exists(keyFile))) return; + const content = await retryFs(() => apis.readTextFile(keyFile)); + const parsed = JSON.parse(content) as Record; + if (parsed['scheme'] !== PROTECTED_SCHEME || typeof parsed['data'] !== 'string') return; + const sourceKey = await resolveProtectedWriteKey(); + if (!sourceKey) { + throw new Error( + `Protected API key for provider "${provider}" exists but at-rest encryption is no longer configured`, + ); + } + const decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( + sourceKey, + base64ToBytes(parsed['data']), + ); + const payload: ProtectedApiKeyPayload | PlaintextApiKeyPayload = targetKey + ? { + scheme: PROTECTED_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(targetKey, decrypted)), + } + : { scheme: PLAINTEXT_SCHEME, value: decrypted.apiKey }; + await writeTextFileAtomic(apis, keyFile, JSON.stringify(payload)); + } + async clearApiKey(provider: string): Promise { try { const apis = await this.getApis(); diff --git a/services/storage/storageEncryptionService.ts b/services/storage/storageEncryptionService.ts index 2fc986d3..7687326d 100644 --- a/services/storage/storageEncryptionService.ts +++ b/services/storage/storageEncryptionService.ts @@ -793,6 +793,18 @@ export async function deriveAndVerifySourceKeyFromSentinel(passphrase: string): return key; } +/** + * Derive the same target key rotateIdbPassphrase() will activate for a given new passphrase, + * without running any migration or touching the sentinel — same salt, same deriveKey() call, so + * the result is byte-identical to what rotation will use. QNBS-v3: lets the desktop fs-data + * migration bridge (services/fs/fsEncryptionMigration.ts) re-encrypt filesystem-backed protected + * files under the correct new key BEFORE rotateIdbPassphrase() swaps the active session key — + * without this, fs data re-keyed after the IDB rotation would need the now-discarded old key. + */ +export async function deriveRotationTargetKey(newPassphrase: string): Promise { + return _svc.deriveKey(newPassphrase, getExistingSalt()); +} + /** * Verify a candidate passphrase against a durable migration target verifier and return its * derived key, without activating a session. Used by the recovery UX to re-derive the target key diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index deca826a..a6b77bb5 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -39,6 +39,9 @@ const mockSetupIdbEncryption = vi.fn().mockResolvedValue(undefined); const mockVerifyAndInitIdbEncryption = vi.fn().mockResolvedValue(undefined); const mockClearIdbPassphrase = vi.fn().mockResolvedValue(undefined); const mockRotateIdbPassphrase = vi.fn().mockResolvedValue(undefined); +const mockDeriveRotationTargetKey = vi.fn().mockResolvedValue('mock-target-key'); +const mockMigrateAllProtectedFsData = vi.fn().mockResolvedValue(undefined); +const mockIsTauriRuntime = vi.fn(() => false); const mockSettings = { theme: 'dark' as const, @@ -205,6 +208,7 @@ vi.mock('../../../services/storage/storageEncryptionService', () => ({ clearIdbPassphrase: (onProgress?: unknown) => mockClearIdbPassphrase(onProgress), rotateIdbPassphrase: (oldPass: string, newPass: string, onProgress?: unknown) => mockRotateIdbPassphrase(oldPass, newPass, onProgress), + deriveRotationTargetKey: (newPassphrase: string) => mockDeriveRotationTargetKey(newPassphrase), })); vi.mock('../../../services/storageService', () => ({ @@ -215,6 +219,15 @@ vi.mock('../../../services/storageService', () => ({ }, })); +// QNBS-v3: services/fs/fsEncryptionMigration.ts transitively imports the real Tauri fs store chain (down to idbCodexStore.ts) — mocked here so this hook test stays isolated and doesn't need the full @tauri-apps/* + IDB mock surface fsStores.test.ts sets up. +vi.mock('../../../services/fs/fsEncryptionMigration', () => ({ + migrateAllProtectedFsData: (targetKey: unknown) => mockMigrateAllProtectedFsData(targetKey), +})); + +vi.mock('../../../services/tauriRuntime', () => ({ + isTauriRuntime: () => mockIsTauriRuntime(), +})); + // Stub URL.createObjectURL / URL.revokeObjectURL vi.stubGlobal('URL', { ...URL, @@ -611,6 +624,9 @@ describe('handlePassphraseConfirm — disable/rotate', () => { afterEach(() => { mockClearIdbPassphrase.mockResolvedValue(undefined); mockRotateIdbPassphrase.mockResolvedValue(undefined); + mockMigrateAllProtectedFsData.mockClear().mockResolvedValue(undefined); + mockDeriveRotationTargetKey.mockClear().mockResolvedValue('mock-target-key'); + mockIsTauriRuntime.mockReturnValue(false); }); it('calls clearIdbPassphrase with a progress callback, updates the flag, and toasts on success', async () => { @@ -735,6 +751,85 @@ describe('handlePassphraseConfirm — disable/rotate', () => { expect(result.current.migrationProgress).toBeNull(); expect(mockToastSuccess).not.toHaveBeenCalledWith('settings.privacy.encryptionChangedStatus'); }); + + it('does not touch fs-backed desktop data outside the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(false); + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('disable'); + }); + await act(async () => { + await result.current.handlePassphraseConfirm('', ''); + }); + + expect(mockMigrateAllProtectedFsData).not.toHaveBeenCalled(); + expect(mockDeriveRotationTargetKey).not.toHaveBeenCalled(); + }); + + it('migrates fs-backed desktop data to plaintext BEFORE clearIdbPassphrase runs, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + const callOrder: string[] = []; + mockMigrateAllProtectedFsData.mockImplementation(async () => { + callOrder.push('migrateAllProtectedFsData'); + }); + mockClearIdbPassphrase.mockImplementation(async () => { + callOrder.push('clearIdbPassphrase'); + }); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('disable'); + }); + await act(async () => { + await result.current.handlePassphraseConfirm('', ''); + }); + + expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith(null); + expect(mockDeriveRotationTargetKey).not.toHaveBeenCalled(); + expect(callOrder).toEqual(['migrateAllProtectedFsData', 'clearIdbPassphrase']); + }); + + it('derives the rotation target key and re-keys fs-backed desktop data BEFORE rotateIdbPassphrase runs, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + mockDeriveRotationTargetKey.mockResolvedValue('derived-target-key'); + const callOrder: string[] = []; + mockMigrateAllProtectedFsData.mockImplementation(async () => { + callOrder.push('migrateAllProtectedFsData'); + }); + mockRotateIdbPassphrase.mockImplementation(async () => { + callOrder.push('rotateIdbPassphrase'); + }); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('rotate'); + }); + await act(async () => { + await result.current.handlePassphraseConfirm('old-pass', 'new-pass'); + }); + + expect(mockDeriveRotationTargetKey).toHaveBeenCalledWith('new-pass'); + expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith('derived-target-key'); + expect(callOrder).toEqual(['migrateAllProtectedFsData', 'rotateIdbPassphrase']); + }); + + it('aborts before clearIdbPassphrase and leaves the modal open when the fs migration bridge fails', async () => { + mockIsTauriRuntime.mockReturnValue(true); + mockMigrateAllProtectedFsData.mockRejectedValueOnce(new Error('fs decrypt failed')); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('disable'); + }); + await act(async () => { + await expect(result.current.handlePassphraseConfirm('', '')).rejects.toThrow( + 'fs decrypt failed', + ); + }); + + expect(mockClearIdbPassphrase).not.toHaveBeenCalled(); + expect(result.current.passphraseModal).toBe('disable'); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts new file mode 100644 index 00000000..8a65ce5e --- /dev/null +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for services/fs/fsEncryptionMigration.ts — the desktop fs-backed protected-data migration + * bridge that must run BEFORE storageEncryptionService's clearIdbPassphrase()/rotateIdbPassphrase() + * swap or discard the active session key, or fs-backed project data / API keys would be stranded + * under a now-unrecoverable key. Same in-memory fake-Tauri scaffolding as fsStores.test.ts. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TauriApis } from '../../../../services/fs/fsCore'; + +const { fsHolder } = vi.hoisted(() => ({ fsHolder: { current: null as unknown as TauriApis } })); + +const { cryptoState } = vi.hoisted(() => ({ + cryptoState: { activeKey: null as CryptoKey | null, sentinelConfigured: false }, +})); +vi.mock('../../../../services/storage/storageEncryptionService', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + hasPassphraseSentinel: () => Promise.resolve(cryptoState.sentinelConfigured), + resolveProtectedWriteKey: () => { + if (cryptoState.activeKey) return Promise.resolve(cryptoState.activeKey); + if (cryptoState.sentinelConfigured) return Promise.reject(new actual.IdbStorageLockedError()); + return Promise.resolve(null); + }, + }; +}); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (cmd: string, args?: Record) => fsHolder.current.invoke(cmd, args), +})); +vi.mock('@tauri-apps/plugin-fs', () => ({ + readTextFile: (p: string) => fsHolder.current.readTextFile(p), + writeTextFile: (p: string, c: string) => fsHolder.current.writeTextFile(p, c), + readFile: (p: string) => fsHolder.current.readFile(p), + writeFile: (p: string, d: Uint8Array) => fsHolder.current.writeFile(p, d), + mkdir: (p: string, opts?: { recursive?: boolean }) => fsHolder.current.mkdir(p, opts), + exists: (p: string) => fsHolder.current.exists(p), + readDir: (p: string) => fsHolder.current.readDir(p), + remove: (p: string, opts?: { recursive?: boolean }) => fsHolder.current.remove(p, opts), + rename: (oldPath: string, newPath: string) => fsHolder.current.rename(oldPath, newPath), +})); +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: (opts?: Record) => fsHolder.current.open(opts), + save: (opts?: Record) => fsHolder.current.save(opts), +})); +vi.mock('@tauri-apps/api/path', () => ({ + appDataDir: () => fsHolder.current.appDataDir(), + join: (...parts: string[]) => fsHolder.current.join(...parts), +})); +vi.mock('../../../../services/logger', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() } }; +}); + +import { migrateAllProtectedFsData } from '../../../../services/fs/fsEncryptionMigration'; +import { fileSystemService } from '../../../../services/fs/index'; +import { StorageEncryptionService } from '../../../../services/storage/storageEncryptionService'; + +interface FakeFs { + apis: TauriApis; + text: Map; + bin: Map; +} + +function makeFakeFs(): FakeFs { + const text = new Map(); + const bin = new Map(); + const dirs = new Set(['/app']); + const under = (p: string): string[] => { + const names = new Set(); + for (const k of [...text.keys(), ...bin.keys()]) { + if (k.startsWith(`${p}/`)) names.add(k.slice(p.length + 1).split('/')[0] as string); + } + return [...names]; + }; + const apis: TauriApis = { + appDataDir: () => Promise.resolve('/app'), + join: (...parts: string[]) => Promise.resolve(parts.join('/')), + exists: (p: string) => + Promise.resolve(text.has(p) || bin.has(p) || dirs.has(p) || under(p).length > 0), + mkdir: (p: string) => { + dirs.add(p); + return Promise.resolve(); + }, + writeTextFile: (p: string, c: string) => { + text.set(p, c); + return Promise.resolve(); + }, + readTextFile: (p: string) => { + if (!text.has(p)) return Promise.reject(new Error(`ENOENT ${p}`)); + return Promise.resolve(text.get(p) as string); + }, + writeFile: (p: string, d: Uint8Array) => { + bin.set(p, d); + return Promise.resolve(); + }, + readFile: (p: string) => { + if (!bin.has(p)) return Promise.reject(new Error(`ENOENT ${p}`)); + return Promise.resolve(bin.get(p) as Uint8Array); + }, + remove: (p: string) => { + text.delete(p); + bin.delete(p); + dirs.delete(p); + for (const k of [...text.keys()]) if (k.startsWith(`${p}/`)) text.delete(k); + for (const k of [...bin.keys()]) if (k.startsWith(`${p}/`)) bin.delete(k); + return Promise.resolve(); + }, + rename: (oldPath: string, newPath: string) => { + if (text.has(oldPath)) { + text.set(newPath, text.get(oldPath) as string); + text.delete(oldPath); + } else if (bin.has(oldPath)) { + bin.set(newPath, bin.get(oldPath) as Uint8Array); + bin.delete(oldPath); + } else { + return Promise.reject(new Error(`ENOENT ${oldPath}`)); + } + return Promise.resolve(); + }, + readDir: (p: string) => Promise.resolve(under(p).map((name) => ({ name, isDirectory: false }))), + open: () => Promise.resolve(null), + save: () => Promise.resolve(null), + invoke: () => Promise.resolve(undefined), + }; + return { apis, text, bin }; +} + +let fake: FakeFs; + +async function deriveKey(passphrase: string): Promise { + return new StorageEncryptionService().deriveKey(passphrase, new Uint8Array(32).fill(7)); +} + +async function enableTestPassphrase(): Promise { + cryptoState.activeKey = await deriveKey('old-passphrase'); + cryptoState.sentinelConfigured = true; +} + +beforeEach(() => { + fake = makeFakeFs(); + fsHolder.current = fake.apis; + cryptoState.activeKey = null; + cryptoState.sentinelConfigured = false; +}); +afterEach(() => { + vi.clearAllMocks(); +}); + +const project = { + id: 'p1', + title: 'My Novel', + logline: 'A tale', + manuscript: [{ id: 's1', title: 'Ch1', content: 'hello world' }], + characters: [], + worlds: [], + outline: [], +}; + +describe('migrateAllProtectedFsData — disable (targetKey = null)', () => { + it('converts project.json, settings.json, an API key, a snapshot, and codex data to plaintext', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + await fileSystemService.saveSettings({ language: 'en' } as never); + await fileSystemService.saveApiKey('gemini', 'secret-key-123'); + const snapshotId = await fileSystemService.saveSnapshot('manual', project); + await fileSystemService.saveStoryCodex({ projectId: 'p1' } as never); + await fileSystemService.saveImage('char-1', 'data:image/png;base64,QUJD'); + + // Sanity: raw bytes on disk are actually wrapped as protected-v1 before migration. + expect(fake.text.get('/app/projects/p1/project.json')).toContain('"scheme":"protected-v1"'); + expect(fake.text.get('/app/config/gemini_key.enc.json')).toContain('"scheme":"protected-v1"'); + + await migrateAllProtectedFsData(null); + + expect(fake.text.get('/app/projects/p1/project.json')).not.toContain('protected-v1'); + expect(fake.text.get('/app/config/settings.json')).not.toContain('protected-v1'); + expect(fake.text.get('/app/config/gemini_key.enc.json')).toContain('"scheme":"plaintext-v1"'); + expect(fake.text.get('/app/projects/p1/codex/codex.snap')).not.toContain('protected-v1'); + expect(fake.text.get('/app/images/char-1.png')).not.toContain('protected-v1'); + + const snapshotRaw = fake.text.get(`/app/snapshots/${snapshotId}.json`); + expect(snapshotRaw).toBeDefined(); + expect(JSON.parse(snapshotRaw as string).data).not.toContain('protected-v1'); + + // Now that the sentinel is gone (simulating clearIdbPassphrase() running next), reads must + // still succeed without a key — proving the data is genuinely plaintext, not just re-labeled. + cryptoState.activeKey = null; + cryptoState.sentinelConfigured = false; + expect((await fileSystemService.loadProject('p1'))?.title).toBe('My Novel'); + expect(await fileSystemService.getApiKey('gemini')).toBe('secret-key-123'); + expect(await fileSystemService.getSnapshotData(snapshotId)).toEqual(project); + expect(await fileSystemService.getImage('char-1')).toContain('QUJD'); + }); + + it('leaves an already-plaintext project untouched (no key ever configured)', async () => { + await fileSystemService.saveProject(project as never); + const before = fake.text.get('/app/projects/p1/project.json'); + await migrateAllProtectedFsData(null); + expect(fake.text.get('/app/projects/p1/project.json')).toBe(before); + }); +}); + +describe('migrateAllProtectedFsData — rotate (targetKey = new key)', () => { + it('re-encrypts project data and an API key under the new key before the active key swaps', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + await fileSystemService.saveApiKey('openai', 'rotate-me'); + + const newKey = await deriveKey('new-passphrase'); + await migrateAllProtectedFsData(newKey); + + // Old key can no longer decrypt the on-disk bytes — proves re-encryption actually happened. + cryptoState.activeKey = await deriveKey('old-passphrase'); + await expect(fileSystemService.loadProject('p1')).resolves.toBeNull(); + + // New key (simulating rotateIdbPassphrase() having swapped the active session) reads fine. + cryptoState.activeKey = newKey; + expect((await fileSystemService.loadProject('p1'))?.title).toBe('My Novel'); + expect(await fileSystemService.getApiKey('openai')).toBe('rotate-me'); + }); +}); + +describe('migrateAllProtectedFsData — safety', () => { + it('throws and leaves data untouched when a protected file cannot be decrypted under the current key', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + + // Simulate a corrupted/mismatched-key file: the session reports a key, but it's not the one + // the file was actually encrypted under. + cryptoState.activeKey = await deriveKey('a-completely-different-passphrase'); + + await expect(migrateAllProtectedFsData(null)).rejects.toThrow(); + }); + + it('does not touch binder assets, which are intentionally out of scope', async () => { + await enableTestPassphrase(); + await fileSystemService.saveBinderAsset( + 'p1', + 'asset-1', + new TextEncoder().encode('raw bytes').buffer, + { originalFileName: 'note.txt', mimeType: 'text/plain', byteSize: 0 }, + ); + const before = fake.bin.get('/app/projects/p1/binder/asset-1.bin'); + await migrateAllProtectedFsData(null); + expect(fake.bin.get('/app/projects/p1/binder/asset-1.bin')).toEqual(before); + }); +}); diff --git a/tests/unit/storage/storageEncryptionService.test.ts b/tests/unit/storage/storageEncryptionService.test.ts index b588dafd..0c78039f 100644 --- a/tests/unit/storage/storageEncryptionService.test.ts +++ b/tests/unit/storage/storageEncryptionService.test.ts @@ -41,11 +41,13 @@ import { clearIdbEncryptionKey, clearIdbPassphrase, createIdbMigrationTargetVerifier, + deriveRotationTargetKey, hasPassphraseSentinel, IdbEncryptionMigrationRequiredError, IdbEncryptionSaltLostError, IdbStorageLockedError, idbDecrypt, + idbDecryptWithKey, idbEncrypt, idbEncryptWithKey, initIdbEncryption, @@ -612,6 +614,41 @@ describe('rotateIdbPassphrase', () => { }); }); +// QNBS-v3: covers the desktop fs-data migration bridge's key-derivation dependency — see +// services/fs/fsEncryptionMigration.ts, which must independently derive the SAME target key +// rotateIdbPassphrase() will activate, without running any migration itself. +describe('deriveRotationTargetKey', () => { + it('derives a key that decrypts data rotateIdbPassphrase() re-encrypts under the new passphrase', async () => { + await setupIdbEncryption('old'); + + // Derive the target key BEFORE rotation runs, exactly as the fs migration bridge does. + const targetKey = await deriveRotationTargetKey('new'); + const ciphertext = await idbEncryptWithKey(targetKey, { secret: 'fs-backed-value' }); + + await rotateIdbPassphrase('old', 'new'); + + // The now-active session key (post-rotation) must be able to decrypt the SAME bytes. + const activeKey = await resolveProtectedWriteKey(); + expect(activeKey).not.toBeNull(); + await expect( + idbDecryptWithKey<{ secret: string }>(activeKey as CryptoKey, ciphertext), + ).resolves.toEqual({ secret: 'fs-backed-value' }); + }); + + it('does not activate a session or touch the sentinel', async () => { + await setupIdbEncryption('old'); + clearIdbEncryptionKey(); + + await deriveRotationTargetKey('some-candidate-passphrase'); + + expect(isIdbEncryptionReady()).toBe(false); + expect(await hasPassphraseSentinel()).toBe(true); + // The real passphrase still unlocks normally — deriving a rotation target key for an + // unrelated candidate passphrase must not have mutated the sentinel or salt. + await expect(verifyAndInitIdbEncryption('old')).resolves.toBeUndefined(); + }); +}); + // QNBS-v3: a 'recovery-required' journal has no legal transition back to 'completed' via the // checked API (by design — it requires the dedicated recovery UX, not a normal migration retry), // so this suite plants and removes it with raw IDB access rather than the journal module's API. From 9dd151ca7e70d07e48f3db439b2cc93a5e0d5b6a Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:15:07 +0200 Subject: [PATCH 03/12] fix(desktop): migrate existing plaintext on first-time setup, detect interrupted migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-loop follow-up on PR #356, addressing an external assessment of the fs-data migration bridge: - First-time "Encrypt project data at rest" setup only protected future writes — every already-existing file (project.json, settings, API keys, snapshots, Codex, RAG, images) stayed plaintext until its next incidental save, despite the UI reporting success immediately. migrateAllProtectedFsData now runs on 'set' too, encrypting every existing file with the newly-active key. Its per-file helpers were generalized to converge a file to whatever state targetKey implies (encrypt-if-plaintext, re-key-if-protected, or decrypt-to-plaintext), rather than only handling the decrypt/re-key direction disable/rotate needed. - 'set' runs in non-strict mode: a file that can't be read (e.g. a stray leftover from a previously-disabled, unrelated encryption session) is logged and skipped rather than aborting the whole setup — nothing valuable is at risk by turning encryption on, unlike disable/rotate where a decrypt failure means an about-to-be-replaced key could be lost. - The bridge has no persistent per-file journal/checkpoint (unlike the IDB migration path), so a process kill mid-rotate could leave a mixed-key filesystem state with no way to detect it. Added a durable marker written before migration starts and cleared only on full success; App.tsx now checks for it once at startup and surfaces a notification if a previous migration didn't finish. This detects, but does not yet resolve, an interrupted migration — full resumable recovery is tracked in issue #359. - Documented that binder-asset `.meta.json` (not just `.bin`) also remains plaintext, including `originalFileName` — filenames can themselves carry sensitive project information. Co-Authored-By: Claude Sonnet 5 --- App.tsx | 21 ++- CHANGELOG.md | 8 +- README.md | 5 +- hooks/useSettingsView.ts | 15 +- services/fs/fsEncryptionMigration.ts | 162 ++++++++++++++---- services/fs/settingsFsStore.ts | 57 ++++-- tests/unit/hooks/useSettingsView.test.ts | 48 +++++- .../services/fs/fsEncryptionMigration.test.ts | 83 ++++++++- 8 files changed, 333 insertions(+), 66 deletions(-) diff --git a/App.tsx b/App.tsx index c5b8b992..0df1fa1e 100644 --- a/App.tsx +++ b/App.tsx @@ -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'; @@ -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'; @@ -375,6 +376,24 @@ const App: FC = ({ 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(); + 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.`, + }), + ); + })(); + }, [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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17858f6d..ba6999ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,9 +43,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + 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/*` diff --git a/README.md b/README.md index 66401b01..e81c3aa7 100644 --- a/README.md +++ b/README.md @@ -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. - 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 @@ -326,7 +326,7 @@ 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 | @@ -334,6 +334,7 @@ different data, with different key material: | **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. diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index 266256ec..33fac9f8 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -26,6 +26,7 @@ import { clearIdbPassphrase, deriveRotationTargetKey, isIdbEncryptionReady, + resolveProtectedWriteKey, rotateIdbPassphrase, setupIdbEncryption, verifyAndInitIdbEncryption, @@ -388,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'); + } 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 @@ -402,7 +413,7 @@ export const useSettingsView = () => { 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); + if (isTauriRuntime()) await migrateAllProtectedFsData(null, 'disable'); await clearIdbPassphrase((progress) => setMigrationProgress(progress)); } finally { setMigrationProgress(null); @@ -416,7 +427,7 @@ export const useSettingsView = () => { // 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); + await migrateAllProtectedFsData(targetKey, 'rotate'); } await rotateIdbPassphrase(_current, newPassphrase, (progress) => setMigrationProgress(progress), diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts index b1c5d882..c5a93a30 100644 --- a/services/fs/fsEncryptionMigration.ts +++ b/services/fs/fsEncryptionMigration.ts @@ -1,16 +1,20 @@ /** * Desktop fs-backed protected-data migration bridge. `services/storage/storageEncryptionService.ts`'s - * clearIdbPassphrase()/rotateIdbPassphrase() own the shared salt/sentinel/session key but have no - * awareness that services/fs/* (Tauri desktop project data + API keys) depends on that same key - * material via protectTextValue()/unprotectTextValue() (see fsCore.ts). Left uncoordinated, a - * disable would destroy the sentinel while fs-backed files stay encrypted under the now-unrecoverable - * old key; a rotate would swap the active key while fs-backed files stay under the old one — both - * permanently stranding desktop project data. This module must run to completion BEFORE either of - * those functions touches the sentinel/active key, using the still-valid OLD session key. + * setupIdbEncryption()/clearIdbPassphrase()/rotateIdbPassphrase() own the shared salt/sentinel/ + * session key but have no awareness that services/fs/* (Tauri desktop project data + API keys) + * depends on that same key material via protectTextValue()/unprotectTextValue() (see fsCore.ts). + * Left uncoordinated: a first-time setup would report "encryption active" while every + * already-existing file stays plaintext until its next incidental save; a disable would destroy + * the sentinel while fs-backed files stay encrypted under the now-unrecoverable old key; a rotate + * would swap the active key while fs-backed files stay under the old one. All three permanently + * strand or misrepresent desktop project data. This module converges every fs-backed file to the + * state implied by targetKey (encrypt under it, re-key to it, or decrypt to plaintext when null) + * and must run to completion BEFORE the sentinel/active key is set up, destroyed, or swapped. * QNBS-v3 (F-05/F-06 follow-up, 2026-08-13): callers must gate on isTauriRuntime() — a no-op cost * on web, since fs-backed files simply don't exist there. */ +import { logger } from '../logger'; import { idbEncryptWithKey } from '../storage/storageEncryptionService'; import { bytesToBase64, @@ -23,6 +27,18 @@ import { fileSystemService } from './index'; const PROTECTED_TEXT_SCHEME = 'protected-v1'; +interface MigrationOptions { + targetKey: CryptoKey | null; + // QNBS-v3: strict=true (disable/rotate) aborts the whole operation on any decrypt failure — the + // caller is about to destroy/replace the only key that could ever decrypt a stranded file, so a + // failure here must block the operation rather than complete with data silently left behind. + // strict=false (first-time setup) logs and skips the offending file instead — nothing valuable + // is being destroyed by turning encryption on, and a stray already-protected file (e.g. a + // leftover from a previous, since-forgotten encryption session) must not block the user from + // protecting everything else. + strict: boolean; +} + async function listDirEntries( apis: TauriApis, dir: string, @@ -34,28 +50,83 @@ async function listDirEntries( } } +// QNBS-v3: this bridge re-keys files directly with no persistent per-file journal/checkpoint (the +// IDB migration path has one; this doesn't yet — tracked in issue #359). A process kill mid-rotate +// can leave some files under the new key while the durable sentinel still reflects the old one. The +// marker below can't resume or fix that, but it converts a silent mixed-key state into a detected +// one: written before migration starts, cleared only on full success, checked at next startup. +const MIGRATION_MARKER_FILENAME = 'fs-migration-marker.json'; + +interface FsMigrationMarker { + operation: 'set' | 'disable' | 'rotate'; + startedAt: string; +} + +async function writeMigrationMarker( + apis: TauriApis, + appDataPath: string, + operation: FsMigrationMarker['operation'], +): Promise { + const configPath = await apis.join(appDataPath, 'config'); + if (!(await apis.exists(configPath))) await apis.mkdir(configPath, { recursive: true }); + const markerPath = await apis.join(configPath, MIGRATION_MARKER_FILENAME); + const marker: FsMigrationMarker = { operation, startedAt: new Date().toISOString() }; + await writeTextFileAtomic(apis, markerPath, JSON.stringify(marker)); +} + +async function clearMigrationMarker(apis: TauriApis, appDataPath: string): Promise { + const markerPath = await apis.join(appDataPath, 'config', MIGRATION_MARKER_FILENAME); + await apis.remove(markerPath).catch(() => {}); +} + /** - * Re-keys a single whole-file-protected text file (project.json / settings.json / codex.snap / - * vectors.snap / images/*.png) under targetKey, or unwraps it to plain text when targetKey is - * null (disable). No-ops when the file is absent or not currently protected — unprotectTextValue() - * returns its input completely unchanged in that case, detected here via reference equality, so no - * envelope-shape knowledge needs to be duplicated from fsCore.ts. + * Returns the marker left by an fs-data migration that never reached completion (crash, forced + * quit, power loss mid-operation), or null if none exists. Called once at startup + * (FsCore.initialize()) to surface an honest warning rather than silently proceeding as if + * nothing happened — see issue #359 for the real fix (a resumable, journaled migration). + */ +export async function checkForInterruptedFsMigration(): Promise { + try { + const apis = await loadTauriApis(); + const appDataPath = await apis.appDataDir(); + const markerPath = await apis.join(appDataPath, 'config', MIGRATION_MARKER_FILENAME); + if (!(await apis.exists(markerPath))) return null; + const content = await apis.readTextFile(markerPath); + return JSON.parse(content) as FsMigrationMarker; + } catch { + return null; + } +} + +/** + * Converges a single whole-file-protected text file (project.json / settings.json / codex.snap / + * vectors.snap / images/*.png) to the state implied by opts.targetKey: encrypts it under the key + * (covers both first-time setup, where every file starts plaintext, and rotate, where it may + * already be protected under a different key), or unwraps it to plain text when targetKey is + * null (disable). No-ops when the file is absent or already in the desired target state. */ async function reprotectWholeFile( apis: TauriApis, path: string, - targetKey: CryptoKey | null, + opts: MigrationOptions, ): Promise { const raw = await apis.readTextFile(path).catch(() => null); if (raw === null) return; - const plaintext = await unprotectTextValue(raw); - if (plaintext === raw) return; // not a protected envelope — nothing to migrate - const content = targetKey + let plaintext: string; + try { + plaintext = await unprotectTextValue(raw); + } catch (error) { + if (opts.strict) throw error; + logger.warn(`Skipping ${path} — could not read its current content:`, error); + return; + } + const content = opts.targetKey ? JSON.stringify({ scheme: PROTECTED_TEXT_SCHEME, - data: bytesToBase64(await idbEncryptWithKey(targetKey, plaintext)), + data: bytesToBase64(await idbEncryptWithKey(opts.targetKey, plaintext)), }) : plaintext; + if (content === raw) return; // already in the desired state await writeTextFileAtomic(apis, path, content); } @@ -68,7 +139,7 @@ interface SnapshotEnvelopeShape { async function reprotectSnapshotFile( apis: TauriApis, path: string, - targetKey: CryptoKey | null, + opts: MigrationOptions, ): Promise { const raw = await apis.readTextFile(path).catch(() => null); if (raw === null) return; @@ -79,28 +150,45 @@ async function reprotectSnapshotFile( return; // legacy raw-project-data snapshot format predates the envelope — never protected } if (typeof envelope.data !== 'string') return; - const plaintext = await unprotectTextValue(envelope.data); - if (plaintext === envelope.data) return; // data field wasn't protected - envelope.data = targetKey + const originalData = envelope.data; + let plaintext: string; + try { + plaintext = await unprotectTextValue(originalData); + } catch (error) { + if (opts.strict) throw error; + logger.warn(`Skipping ${path} — could not read its current data field:`, error); + return; + } + envelope.data = opts.targetKey ? JSON.stringify({ scheme: PROTECTED_TEXT_SCHEME, - data: bytesToBase64(await idbEncryptWithKey(targetKey, plaintext)), + data: bytesToBase64(await idbEncryptWithKey(opts.targetKey, plaintext)), }) : plaintext; + if (envelope.data === originalData) return; // already in the desired state await writeTextFileAtomic(apis, path, JSON.stringify(envelope)); } /** - * Converts every fs-backed protected file to targetKey (rotate) or to plaintext (targetKey=null, - * disable). Any positively-identified protected file that fails to decrypt under the current - * session key throws immediately rather than being skipped — a partial migration must never - * silently strand a file at a key that's about to become unrecoverable; the caller's disable/ - * rotate action fails safely (nothing changed) rather than completing with lost data. + * Converges every fs-backed protected file to targetKey (first-time setup or rotate) or to + * plaintext (targetKey=null, disable). For 'disable'/'rotate', any positively-identified protected + * file that fails to decrypt under the current session key throws immediately rather than being + * skipped, so a partial migration can never silently strand a file at a key that's about to become + * unrecoverable. For 'set', such a file is logged and left untouched instead, so an unrelated + * pre-existing oddity can't block the user from enabling encryption for everything else. Writes a + * durable marker before starting and clears it only on full success — see + * checkForInterruptedFsMigration() and issue #359. */ -export async function migrateAllProtectedFsData(targetKey: CryptoKey | null): Promise { +export async function migrateAllProtectedFsData( + targetKey: CryptoKey | null, + operation: FsMigrationMarker['operation'], +): Promise { + const opts: MigrationOptions = { targetKey, strict: operation !== 'set' }; const apis = await loadTauriApis(); const appDataPath = await apis.appDataDir(); + await writeMigrationMarker(apis, appDataPath, operation); + const configPath = await apis.join(appDataPath, 'config'); const configEntries = await listDirEntries(apis, configPath); await Promise.all( @@ -108,10 +196,10 @@ export async function migrateAllProtectedFsData(targetKey: CryptoKey | null): Pr if (!entry.name || entry.isDirectory) return; if (entry.name === 'settings.json') { const entryPath = await apis.join(configPath, entry.name); - await reprotectWholeFile(apis, entryPath, targetKey); + await reprotectWholeFile(apis, entryPath, opts); } else if (entry.name.endsWith('_key.enc.json')) { const provider = entry.name.slice(0, -'_key.enc.json'.length); - await fileSystemService.reprotectApiKeyFile(provider, targetKey); + await fileSystemService.reprotectApiKeyFile(provider, opts.targetKey, opts.strict); } }), ); @@ -122,7 +210,7 @@ export async function migrateAllProtectedFsData(targetKey: CryptoKey | null): Pr snapshotEntries.map(async (entry) => { if (!entry.name?.endsWith('.json')) return; const filePath = await apis.join(snapshotsPath, entry.name); - await reprotectSnapshotFile(apis, filePath, targetKey); + await reprotectSnapshotFile(apis, filePath, opts); }), ); @@ -132,7 +220,7 @@ export async function migrateAllProtectedFsData(targetKey: CryptoKey | null): Pr imageEntries.map(async (entry) => { if (!entry.name?.endsWith('.png')) return; const filePath = await apis.join(imagesPath, entry.name); - await reprotectWholeFile(apis, filePath, targetKey); + await reprotectWholeFile(apis, filePath, opts); }), ); @@ -140,10 +228,14 @@ export async function migrateAllProtectedFsData(targetKey: CryptoKey | null): Pr await Promise.all( projectIds.map(async (projectId) => { const projectDir = await apis.join(appDataPath, 'projects', projectId); - await reprotectWholeFile(apis, await apis.join(projectDir, 'project.json'), targetKey); + await reprotectWholeFile(apis, await apis.join(projectDir, 'project.json'), opts); const codexDir = await apis.join(projectDir, 'codex'); - await reprotectWholeFile(apis, await apis.join(codexDir, 'codex.snap'), targetKey); - await reprotectWholeFile(apis, await apis.join(codexDir, 'vectors.snap'), targetKey); + await reprotectWholeFile(apis, await apis.join(codexDir, 'codex.snap'), opts); + await reprotectWholeFile(apis, await apis.join(codexDir, 'vectors.snap'), opts); }), ); + + // QNBS-v3: only reached if every step above completed without throwing — a strict-mode abort or + // a process kill both leave the marker in place, which is the intended "interrupted" signal. + await clearMigrationMarker(apis, appDataPath); } diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index 11029847..a4de3a9a 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -211,31 +211,60 @@ export class FsSettingsStore extends FsCore { * fsEncryptionMigration.ts) BEFORE storageEncryptionService.ts swaps/discards the active key — * after that point the old key is unrecoverable and this file would be permanently stranded. */ - async reprotectApiKeyFile(provider: string, targetKey: CryptoKey | null): Promise { + async reprotectApiKeyFile( + provider: string, + targetKey: CryptoKey | null, + strict = true, + ): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); const keyFile = await apis.join(appDataPath, 'config', `${provider}_key.enc.json`); if (!(await apis.exists(keyFile))) return; const content = await retryFs(() => apis.readTextFile(keyFile)); const parsed = JSON.parse(content) as Record; - if (parsed['scheme'] !== PROTECTED_SCHEME || typeof parsed['data'] !== 'string') return; - const sourceKey = await resolveProtectedWriteKey(); - if (!sourceKey) { - throw new Error( - `Protected API key for provider "${provider}" exists but at-rest encryption is no longer configured`, - ); + + let apiKey: string; + if (parsed['scheme'] === PLAINTEXT_SCHEME && typeof parsed['value'] === 'string') { + // QNBS-v3: covers first-time setup, where every existing key file starts as plaintext-v1 — + // without this branch, migrateAllProtectedFsData(targetKey) during 'set' would silently + // leave already-saved keys unencrypted until their next incidental re-save. + apiKey = parsed['value']; + } else if (parsed['scheme'] === PROTECTED_SCHEME && typeof parsed['data'] === 'string') { + const sourceKey = await resolveProtectedWriteKey(); + if (!sourceKey) { + const error = new Error( + `Protected API key for provider "${provider}" exists but at-rest encryption is no longer configured`, + ); + if (strict) throw error; + logger.warn(error.message); + return; + } + try { + const decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( + sourceKey, + base64ToBytes(parsed['data']), + ); + apiKey = decrypted.apiKey; + } catch (error) { + if (strict) throw error; + logger.warn(`Skipping API key for provider "${provider}" — could not decrypt it:`, error); + return; + } + } else { + // Unrecognized/legacy shape — not this method's job to migrate; getApiKey()'s own + // positively-identified-legacy-only discard path handles that on next read. + return; } - const decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( - sourceKey, - base64ToBytes(parsed['data']), - ); + const payload: ProtectedApiKeyPayload | PlaintextApiKeyPayload = targetKey ? { scheme: PROTECTED_SCHEME, - data: bytesToBase64(await idbEncryptWithKey(targetKey, decrypted)), + data: bytesToBase64(await idbEncryptWithKey(targetKey, { provider, apiKey })), } - : { scheme: PLAINTEXT_SCHEME, value: decrypted.apiKey }; - await writeTextFileAtomic(apis, keyFile, JSON.stringify(payload)); + : { scheme: PLAINTEXT_SCHEME, value: apiKey }; + const newContent = JSON.stringify(payload); + if (newContent === content) return; // already in the desired state + await writeTextFileAtomic(apis, keyFile, newContent); } async clearApiKey(provider: string): Promise { diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index a6b77bb5..7e3cb694 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -40,6 +40,7 @@ const mockVerifyAndInitIdbEncryption = vi.fn().mockResolvedValue(undefined); const mockClearIdbPassphrase = vi.fn().mockResolvedValue(undefined); const mockRotateIdbPassphrase = vi.fn().mockResolvedValue(undefined); const mockDeriveRotationTargetKey = vi.fn().mockResolvedValue('mock-target-key'); +const mockResolveProtectedWriteKey = vi.fn().mockResolvedValue('mock-active-key'); const mockMigrateAllProtectedFsData = vi.fn().mockResolvedValue(undefined); const mockIsTauriRuntime = vi.fn(() => false); @@ -209,6 +210,7 @@ vi.mock('../../../services/storage/storageEncryptionService', () => ({ rotateIdbPassphrase: (oldPass: string, newPass: string, onProgress?: unknown) => mockRotateIdbPassphrase(oldPass, newPass, onProgress), deriveRotationTargetKey: (newPassphrase: string) => mockDeriveRotationTargetKey(newPassphrase), + resolveProtectedWriteKey: () => mockResolveProtectedWriteKey(), })); vi.mock('../../../services/storageService', () => ({ @@ -221,7 +223,8 @@ vi.mock('../../../services/storageService', () => ({ // QNBS-v3: services/fs/fsEncryptionMigration.ts transitively imports the real Tauri fs store chain (down to idbCodexStore.ts) — mocked here so this hook test stays isolated and doesn't need the full @tauri-apps/* + IDB mock surface fsStores.test.ts sets up. vi.mock('../../../services/fs/fsEncryptionMigration', () => ({ - migrateAllProtectedFsData: (targetKey: unknown) => mockMigrateAllProtectedFsData(targetKey), + migrateAllProtectedFsData: (targetKey: unknown, operation: unknown) => + mockMigrateAllProtectedFsData(targetKey, operation), })); vi.mock('../../../services/tauriRuntime', () => ({ @@ -626,6 +629,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { mockRotateIdbPassphrase.mockResolvedValue(undefined); mockMigrateAllProtectedFsData.mockClear().mockResolvedValue(undefined); mockDeriveRotationTargetKey.mockClear().mockResolvedValue('mock-target-key'); + mockResolveProtectedWriteKey.mockClear().mockResolvedValue('mock-active-key'); mockIsTauriRuntime.mockReturnValue(false); }); @@ -679,6 +683,44 @@ describe('handlePassphraseConfirm — disable/rotate', () => { expect(mockClearIdbPassphrase).not.toHaveBeenCalled(); expect(mockRotateIdbPassphrase).not.toHaveBeenCalled(); expect(mockSetupIdbEncryption).toHaveBeenCalledWith('newpass123'); + expect(mockMigrateAllProtectedFsData).not.toHaveBeenCalled(); + }); + + it('encrypts existing fs-backed desktop data with the newly-active key on first-time setup, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + mockResolveProtectedWriteKey.mockResolvedValue('newly-active-key'); + const callOrder: string[] = []; + mockSetupIdbEncryption.mockImplementation(async () => { + callOrder.push('setupIdbEncryption'); + }); + mockMigrateAllProtectedFsData.mockImplementation(async () => { + callOrder.push('migrateAllProtectedFsData'); + }); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('set'); + }); + await act(async () => { + await result.current.handlePassphraseConfirm('', 'newpass123'); + }); + + expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith('newly-active-key', 'set'); + expect(callOrder).toEqual(['setupIdbEncryption', 'migrateAllProtectedFsData']); + }); + + it('does not encrypt fs-backed desktop data on first-time setup outside the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(false); + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('set'); + }); + await act(async () => { + await result.current.handlePassphraseConfirm('', 'newpass123'); + }); + + expect(mockMigrateAllProtectedFsData).not.toHaveBeenCalled(); + expect(mockResolveProtectedWriteKey).not.toHaveBeenCalled(); }); it('surfaces migrationProgress updates from the onProgress callback while disable is pending, then clears it', async () => { @@ -784,7 +826,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { await result.current.handlePassphraseConfirm('', ''); }); - expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith(null); + expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith(null, 'disable'); expect(mockDeriveRotationTargetKey).not.toHaveBeenCalled(); expect(callOrder).toEqual(['migrateAllProtectedFsData', 'clearIdbPassphrase']); }); @@ -809,7 +851,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { }); expect(mockDeriveRotationTargetKey).toHaveBeenCalledWith('new-pass'); - expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith('derived-target-key'); + expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith('derived-target-key', 'rotate'); expect(callOrder).toEqual(['migrateAllProtectedFsData', 'rotateIdbPassphrase']); }); diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts index 8a65ce5e..1d6ebb66 100644 --- a/tests/unit/services/fs/fsEncryptionMigration.test.ts +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -54,7 +54,10 @@ vi.mock('../../../../services/logger', async (importOriginal) => { return { ...actual, logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() } }; }); -import { migrateAllProtectedFsData } from '../../../../services/fs/fsEncryptionMigration'; +import { + checkForInterruptedFsMigration, + migrateAllProtectedFsData, +} from '../../../../services/fs/fsEncryptionMigration'; import { fileSystemService } from '../../../../services/fs/index'; import { StorageEncryptionService } from '../../../../services/storage/storageEncryptionService'; @@ -173,7 +176,7 @@ describe('migrateAllProtectedFsData — disable (targetKey = null)', () => { expect(fake.text.get('/app/projects/p1/project.json')).toContain('"scheme":"protected-v1"'); expect(fake.text.get('/app/config/gemini_key.enc.json')).toContain('"scheme":"protected-v1"'); - await migrateAllProtectedFsData(null); + await migrateAllProtectedFsData(null, 'disable'); expect(fake.text.get('/app/projects/p1/project.json')).not.toContain('protected-v1'); expect(fake.text.get('/app/config/settings.json')).not.toContain('protected-v1'); @@ -198,11 +201,79 @@ describe('migrateAllProtectedFsData — disable (targetKey = null)', () => { it('leaves an already-plaintext project untouched (no key ever configured)', async () => { await fileSystemService.saveProject(project as never); const before = fake.text.get('/app/projects/p1/project.json'); - await migrateAllProtectedFsData(null); + await migrateAllProtectedFsData(null, 'disable'); expect(fake.text.get('/app/projects/p1/project.json')).toBe(before); }); }); +describe('migrateAllProtectedFsData — set (first-time setup)', () => { + it('encrypts every existing plaintext file, not just future saves', async () => { + // No passphrase configured yet — every save below lands as plaintext (lazy/opportunistic + // design), exactly like a real pre-existing desktop install turning encryption on for the + // first time. + await fileSystemService.saveProject(project as never); + await fileSystemService.saveApiKey('gemini', 'secret-key-123'); + const snapshotId = await fileSystemService.saveSnapshot('manual', project); + expect(fake.text.get('/app/projects/p1/project.json')).not.toContain('protected-v1'); + expect(fake.text.get('/app/config/gemini_key.enc.json')).toContain('"scheme":"plaintext-v1"'); + + const newKey = await deriveKey('first-passphrase'); + await migrateAllProtectedFsData(newKey, 'set'); + + expect(fake.text.get('/app/projects/p1/project.json')).toContain('"scheme":"protected-v1"'); + expect(fake.text.get('/app/config/gemini_key.enc.json')).toContain('"scheme":"protected-v1"'); + const snapshotRaw = fake.text.get(`/app/snapshots/${snapshotId}.json`); + expect(JSON.parse(snapshotRaw as string).data).toContain('protected-v1'); + + cryptoState.activeKey = newKey; + cryptoState.sentinelConfigured = true; + expect((await fileSystemService.loadProject('p1'))?.title).toBe('My Novel'); + expect(await fileSystemService.getApiKey('gemini')).toBe('secret-key-123'); + }); + + it('skips (does not throw or discard) a file it cannot decrypt, unlike disable/rotate', async () => { + // Simulate a stray already-protected file left over from a previous, unrelated encryption + // session — the exact edge case 'set' must tolerate rather than abort on. + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + cryptoState.activeKey = null; + cryptoState.sentinelConfigured = false; + + const before = fake.text.get('/app/projects/p1/project.json'); + const newKey = await deriveKey('first-passphrase'); + cryptoState.activeKey = newKey; + cryptoState.sentinelConfigured = true; + + await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); + // Left untouched — the new key can't decrypt it, and 'set' must not destroy or crash on that. + expect(fake.text.get('/app/projects/p1/project.json')).toBe(before); + }); +}); + +describe('migrateAllProtectedFsData — interrupted-migration marker', () => { + it('leaves no marker after a successful migration', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + await migrateAllProtectedFsData(null, 'disable'); + expect(await checkForInterruptedFsMigration()).toBeNull(); + }); + + it('leaves the marker in place when a strict-mode migration throws (simulated crash-equivalent)', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + cryptoState.activeKey = await deriveKey('a-completely-different-passphrase'); + + await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(); + + const marker = await checkForInterruptedFsMigration(); + expect(marker).toEqual(expect.objectContaining({ operation: 'disable' })); + }); + + it('reports no marker when none exists', async () => { + expect(await checkForInterruptedFsMigration()).toBeNull(); + }); +}); + describe('migrateAllProtectedFsData — rotate (targetKey = new key)', () => { it('re-encrypts project data and an API key under the new key before the active key swaps', async () => { await enableTestPassphrase(); @@ -210,7 +281,7 @@ describe('migrateAllProtectedFsData — rotate (targetKey = new key)', () => { await fileSystemService.saveApiKey('openai', 'rotate-me'); const newKey = await deriveKey('new-passphrase'); - await migrateAllProtectedFsData(newKey); + await migrateAllProtectedFsData(newKey, 'rotate'); // Old key can no longer decrypt the on-disk bytes — proves re-encryption actually happened. cryptoState.activeKey = await deriveKey('old-passphrase'); @@ -232,7 +303,7 @@ describe('migrateAllProtectedFsData — safety', () => { // the file was actually encrypted under. cryptoState.activeKey = await deriveKey('a-completely-different-passphrase'); - await expect(migrateAllProtectedFsData(null)).rejects.toThrow(); + await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(); }); it('does not touch binder assets, which are intentionally out of scope', async () => { @@ -244,7 +315,7 @@ describe('migrateAllProtectedFsData — safety', () => { { originalFileName: 'note.txt', mimeType: 'text/plain', byteSize: 0 }, ); const before = fake.bin.get('/app/projects/p1/binder/asset-1.bin'); - await migrateAllProtectedFsData(null); + await migrateAllProtectedFsData(null, 'disable'); expect(fake.bin.get('/app/projects/p1/binder/asset-1.bin')).toEqual(before); }); }); From aea6d690e3ec2edde5bcba34b1c94177f3c3b5a0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:52:53 +0200 Subject: [PATCH 04/12] =?UTF-8?q?fix(desktop):=20close=20remaining=20PR=20?= =?UTF-8?q?#356=20review=20findings=20=E2=80=94=20locked-session=20propaga?= =?UTF-8?q?tion,=20migration=20safety,=20and=20nuclear-reset=20fs=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-throw IdbStorageLockedError (instead of swallowing to null) from all 6 fs-store read methods (project, settings, codex, RAG vectors, snapshot, image) so a locked session surfaces the existing web-build unlock-modal-and-retry flow instead of silently hydrating as a brand-new user. - Verify the current passphrase against the durable sentinel BEFORE the fs migration bridge re-keys any file during rotate, closing a mixed-key bug where a mistyped current passphrase could re-key fs data to a key the IDB side never activates. - Replace exists-then-catch-swallow reads in reprotectWholeFile/reprotectSnapshotFile with exists-first + strict-respecting reads, so strict-mode (disable/rotate) no longer silently skips a genuinely-unreadable file before destroying the only key that could decrypt it. - Wrap reprotectApiKeyFile's body in try/catch so non-strict (set) mode also catches synchronous JSON.parse failures on malformed pre-existing key files, instead of leaving setupIdbEncryption() stranded mid-flow. - Reject (instead of laundering) an API-key ciphertext whose decrypted provider doesn't match its filename during migration, mirroring the existing readProtectedApiKey guard. - Add deleteAllFsData() and call it before IDB/localStorage deletion in both resetAllDatabases() and wipeAllAppData() — neither nuclear-reset flow previously touched Tauri filesystem data despite both destroying the KDF salt, which permanently orphaned any already-encrypted desktop file. - Surface an interrupted fs-migration marker as a startup notification via checkForInterruptedFsMigration(). - Condense all QNBS-v3 comments (including pre-existing ones in App.tsx and useSettingsView.ts) to single physical lines per the project's hard-wrap rule. Co-Authored-By: Claude Sonnet 5 --- App.tsx | 49 ++++++------- hooks/useSettingsView.ts | 19 ++--- locales/ar/settings.json | 5 ++ locales/de/settings.json | 5 ++ locales/el/settings.json | 5 ++ locales/en/settings.json | 5 ++ locales/es/settings.json | 5 ++ locales/eu/settings.json | 5 ++ locales/fa/settings.json | 5 ++ locales/fi/settings.json | 5 ++ locales/fr/settings.json | 5 ++ locales/he/settings.json | 5 ++ locales/hu/settings.json | 5 ++ locales/is/settings.json | 5 ++ locales/it/settings.json | 5 ++ locales/ja/settings.json | 5 ++ locales/ko/settings.json | 5 ++ locales/pt/settings.json | 5 ++ locales/ru/settings.json | 5 ++ locales/sv/settings.json | 5 ++ locales/zh/settings.json | 5 ++ public/locales/ar/bundle.json | 5 ++ public/locales/de/bundle.json | 5 ++ public/locales/el/bundle.json | 5 ++ public/locales/en/bundle.json | 5 ++ public/locales/es/bundle.json | 5 ++ public/locales/eu/bundle.json | 5 ++ public/locales/fa/bundle.json | 5 ++ public/locales/fi/bundle.json | 5 ++ public/locales/fr/bundle.json | 5 ++ public/locales/he/bundle.json | 5 ++ public/locales/hu/bundle.json | 5 ++ public/locales/is/bundle.json | 5 ++ public/locales/it/bundle.json | 5 ++ public/locales/ja/bundle.json | 5 ++ public/locales/ko/bundle.json | 5 ++ public/locales/pt/bundle.json | 5 ++ public/locales/ru/bundle.json | 5 ++ public/locales/sv/bundle.json | 5 ++ public/locales/zh/bundle.json | 5 ++ services/dbInitialization.ts | 12 ++++ services/factoryResetService.ts | 11 +++ services/fs/assetFsStore.ts | 8 ++- services/fs/codexFsStore.ts | 5 ++ services/fs/fsCore.ts | 5 +- services/fs/fsEncryptionMigration.ts | 72 +++++++++++++------ services/fs/projectFsStore.ts | 3 + services/fs/settingsFsStore.ts | 42 ++++++----- services/fs/snapshotFsStore.ts | 3 + tests/unit/dbInitialization.test.ts | 63 ++++++++++++++-- tests/unit/factoryResetService.test.ts | 53 ++++++++++++++ tests/unit/hooks/useSettingsView.test.ts | 30 ++++++++ .../services/fs/fsEncryptionMigration.test.ts | 58 +++++++++++++++ tests/unit/services/fs/fsStores.test.ts | 52 +++++++++++++- 54 files changed, 584 insertions(+), 91 deletions(-) diff --git a/App.tsx b/App.tsx index 0df1fa1e..dfa4ccf5 100644 --- a/App.tsx +++ b/App.tsx @@ -222,8 +222,7 @@ const App: FC = ({ isNewUser }) => { document.body.classList.add(isDark ? 'dark-theme' : 'light-theme'); const themeColorMeta = document.querySelector('meta[name="theme-color"]'); if (themeColorMeta) { - // QNBS-v3: Sepia has distinct dark/light surface colors; reflect them in the - // mobile browser chrome so the status bar matches the app shell. + // QNBS-v3: Sepia has distinct dark/light surface colors; reflect them in the mobile browser chrome so the status bar matches the app shell. const themeColor = settings.appearancePreset === 'sepia' ? isDark @@ -279,8 +278,7 @@ const App: FC = ({ isNewUser }) => { ); }, [settings.accessibility.highContrast]); - // QNBS-v3: Tag the body for desktop-scoped styling (is-desktop + data-os). Tauri-ness is constant - // for the session, so this runs once; no-op on the web. Pairs with the `.is-desktop` CSS layer. + // QNBS-v3: Tag the body for desktop-scoped styling (is-desktop + data-os) — Tauri-ness is constant for the session, so this runs once; no-op on the web. Pairs with the `.is-desktop` CSS layer. useEffect(() => { applyDesktopRuntimeFlags(); }, []); @@ -341,24 +339,19 @@ const App: FC = ({ isNewUser }) => { document.documentElement.dir = featureFlags.enableRtlLayout ? 'rtl' : localeDir; }, [language, featureFlags.enableRtlLayout]); - // QNBS-v3: Sync enablePluginSystem flag into pluginRegistry so execute/executeAsync/loadPlugin - // are properly gated without the registry needing direct Redux access. + // QNBS-v3: Sync enablePluginSystem flag into pluginRegistry so execute/executeAsync/loadPlugin are properly gated without the registry needing direct Redux access. useEffect(() => { pluginRegistry.setEnabled(featureFlags.enablePluginSystem); }, [featureFlags.enablePluginSystem]); - // QNBS-v3: Sync inference telemetry into telemetryService — the service cannot import the Redux - // store without a circular dep, so App.tsx acts as the bridge. SEC: telemetry now also honours the - // Settings → Privacy "Analytics" opt-out, mirroring the DuckDB persistence gate in listenerMiddleware - // (isAnalyticsPersistenceAllowed). Re-runs on either input change so toggling the opt-out is live. + // QNBS-v3: Sync inference telemetry into telemetryService (can't import the Redux store directly — circular dep) — also honours Settings → Privacy "Analytics" opt-out, mirroring listenerMiddleware's isAnalyticsPersistenceAllowed gate; re-runs on either input change so toggling is live. useEffect(() => { void import('./services/ai/telemetryService').then(({ setTelemetryEnabled }) => { setTelemetryEnabled(featureFlags.enableDuckDbAnalytics && settings.privacy.analyticsEnabled); }); }, [featureFlags.enableDuckDbAnalytics, settings.privacy.analyticsEnabled]); - // QNBS-v3: Issue 5 — set the window adaptive-AI gate on cold start if the flag is already on - // (listener only fires on OFF→ON transitions, not on initial true state from localStorage) + // QNBS-v3: Issue 5 — set the window adaptive-AI gate on cold start if the flag is already on (listener only fires on OFF→ON transitions, not on initial true state from localStorage). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-shot on mount only; flag changes handled by listenerMiddleware useEffect(() => { initAdaptiveAiOnStartup(featureFlags.enableAdaptiveAiEngine); @@ -376,23 +369,29 @@ const App: FC = ({ 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. + // QNBS-v3: the fs-data migration bridge has no resumable journal yet (issue #359) — surface an interrupted-migration marker honestly rather than silently proceeding as if the desktop file state is consistent. useEffect(() => { if (!isTauriRuntime()) return; void (async () => { const marker = await checkForInterruptedFsMigration(); if (!marker) return; + const operationKey = { + set: 'settings.privacy.encryptionOperationSet', + disable: 'settings.privacy.encryptionOperationDisable', + rotate: 'settings.privacy.encryptionOperationRotate', + } as const; 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.`, + title: t('settings.privacy.encryptionMigrationInterruptedTitle'), + description: t('settings.privacy.encryptionMigrationInterruptedBody', { + operation: t(operationKey[marker.operation]), + startedAt: new Date(marker.startedAt).toLocaleString(language), + }), }), ); })(); - }, [dispatch]); + }, [dispatch, t, language]); // 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(() => { @@ -447,8 +446,7 @@ const App: FC = ({ isNewUser }) => { } }, [dispatch, isPortalActive, t]); - // QNBS-v3: Translated view announcement instead of raw text (WCAG 4.1.3 status messages). - // requestAnimationFrame focus ensures the new view is mounted before focus moves (WCAG 2.4.3). + // QNBS-v3: Translated view announcement instead of raw text (WCAG 4.1.3) — requestAnimationFrame focus ensures the new view is mounted before focus moves (WCAG 2.4.3). useEffect(() => { if (isInitialLoad || isPortalActive) return; if (prevViewRef.current === currentView) return; @@ -486,15 +484,11 @@ const App: FC = ({ isNewUser }) => { } }, [project, isPortalActive, isI18nReady, dispatch, t]); - // QNBS-v3: PR3 — auto-launch the product tour once for first-run installs, after the welcome - // portal closes and the nav has rendered. Returning users (or anyone who already finished/closed - // it) are never interrupted; they can still start it manually from the Dashboard or Help. + // QNBS-v3: PR3 — auto-launch the product tour once for first-run installs, after the welcome portal closes and the nav has rendered; returning users are never interrupted and can start it manually from the Dashboard or Help. const tourStartedRef = useRef(false); useEffect(() => { if (!isNewUser || isInitialLoad || isPortalActive) return; - // QNBS-v3: never hijack an automated browser session — the tour's full-screen overlay intercepts - // pointer events and breaks E2E. navigator.webdriver is true only under automation, never for - // real users, so this is invisible in production. + // QNBS-v3: never hijack an automated browser session — the tour's overlay intercepts pointer events and breaks E2E; navigator.webdriver is true only under automation, invisible in production. if (typeof navigator !== 'undefined' && navigator.webdriver) return; if (tourStartedRef.current || hasCompletedSpotlightTour()) return; // QNBS-v3 (CodeAnt): set the once-guard when the timer actually fires, not before it. If a dep @@ -622,8 +616,7 @@ const App: FC = ({ isNewUser }) => { await exit(0); }, [store]); - // QNBS-v3: executeCommandRef synced in its own effect (never assigned during render) so the menu - // effect below can depend on [t, quitApp] only and skip rebuilding on every executeCommand identity change. + // QNBS-v3: executeCommandRef synced in its own effect (never assigned during render) so the menu effect below can depend on [t, quitApp] only, skipping rebuilds on every executeCommand identity change. const executeCommandRef = useRef(executeCommand); useEffect(() => { executeCommandRef.current = executeCommand; diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index 33fac9f8..c3ff81c1 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -24,6 +24,7 @@ import type { ProtectedStoreMigrationProgress } from '../services/storage/protec import { clearIdbEncryptionKey, clearIdbPassphrase, + deriveAndVerifySourceKeyFromSentinel, deriveRotationTargetKey, isIdbEncryptionReady, resolveProtectedWriteKey, @@ -123,8 +124,7 @@ export const useSettingsView = () => { case 'appearancePreset': dispatch(settingsActions.setAppearancePreset(value as AppearancePreset)); break; - // QNBS-v3: dispatch through the settings slice (not local component state) so the - // preference persists via the same save path as every other appearance setting. + // QNBS-v3: dispatch through the settings slice so the preference persists via the same save path as every other appearance setting. case 'writingSurfaceStyle': dispatch(settingsActions.setWritingSurfaceStyle(value as WritingSurfaceStyle)); break; @@ -230,8 +230,7 @@ export const useSettingsView = () => { case 'enablePluginSystem': dispatch(featureFlagsActions.setEnablePluginSystem(Boolean(value))); break; - // QNBS-v3: Three flags were wired into FeatureFlagsSection.tsx but missing here; - // toggles fell to default and logged a warning without updating Redux/localStorage. + // QNBS-v3: three flags were wired into FeatureFlagsSection.tsx but missing here; toggles fell to default and logged a warning without updating Redux/localStorage. case 'enableProForge': dispatch(featureFlagsActions.setEnableProForge(Boolean(value))); // QNBS-v3: guide user to the ProForge button — it is only in WriterView, not the sidebar @@ -274,8 +273,7 @@ export const useSettingsView = () => { case 'enableBrowserOllama': dispatch(featureFlagsActions.setEnableBrowserOllama(Boolean(value))); break; - // QNBS-v3: enableIdbAtRestEncryption intentionally absent — managed via handlePassphraseConfirm - // in Settings > Privacy, not the experimental flags UI toggle. + // QNBS-v3: enableIdbAtRestEncryption intentionally absent — managed via handlePassphraseConfirm in Settings > Privacy, not the experimental flags UI toggle. default: logger.warn(`Unknown setting key: ${key}`); break; @@ -389,12 +387,7 @@ 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. + // QNBS-v3: without this, "encryption active" would only mean future writes get protected — every already-existing fs-backed file would stay plaintext until its next incidental save; strict:false so a stray pre-existing oddity can't block setup for everything else. if (isTauriRuntime()) { const key = await resolveProtectedWriteKey(); if (key) await migrateAllProtectedFsData(key, 'set'); @@ -426,6 +419,8 @@ export const useSettingsView = () => { 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()) { + // QNBS-v3: verify _current against the durable sentinel BEFORE mutating any fs file — otherwise a mistyped current passphrase lets the bridge re-key everything to the new key while rotateIdbPassphrase() below then rejects (wrong _current) and never activates that key, stranding fs data under a key the active session never adopts. + await deriveAndVerifySourceKeyFromSentinel(_current); const targetKey = await deriveRotationTargetKey(newPassphrase); await migrateAllProtectedFsData(targetKey, 'rotate'); } diff --git a/locales/ar/settings.json b/locales/ar/settings.json index f3010196..2934830a 100644 --- a/locales/ar/settings.json +++ b/locales/ar/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "سيؤدي تعطيل التشفير إلى إزالة بوابة عبارة المرور. إذا كانت أي بيانات مُشفَّرة بعبارة مرورك فستصبح غير قابلة للقراءة — صدّر نسخة احتياطية أولًا إن كنت غير متأكد.", "settings.privacy.encryptionLockAction": "قفل الجلسة", "settings.privacy.encryptionLockedStatus": "التشفير مُفعّل لكنه مقفل — أدخل عبارة المرور للوصول إلى البيانات", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "عبارتا المرور غير متطابقتين", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "تفعيل التشفير أثناء السكون", "settings.privacy.encryptionModalUnlockTitle": "فتح التخزين المُشفَّر", "settings.privacy.encryptionNewPassphrase": "عبارة مرور جديدة", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "عبارة مرور التخزين", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/de/settings.json b/locales/de/settings.json index a860ab5f..2dfe297d 100644 --- a/locales/de/settings.json +++ b/locales/de/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Durch Deaktivierung der Verschlüsselung wird das Passwort-Gate entfernt. Falls Daten unter dem Passwort verschlüsselt wurden, sind sie danach nicht mehr lesbar — exportiere vorher ein Backup.", "settings.privacy.encryptionLockAction": "Sitzung sperren", "settings.privacy.encryptionLockedStatus": "Verschlüsselung aktiviert, aber gesperrt — Passwort eingeben, um auf Daten zuzugreifen", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migriere Speicher {{current}} von {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Fortschritt der Verschlüsselungsmigration", "settings.privacy.encryptionMismatch": "Passwörter stimmen nicht überein", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Ruheverschlüsselung aktivieren", "settings.privacy.encryptionModalUnlockTitle": "Verschlüsselten Speicher entsperren", "settings.privacy.encryptionNewPassphrase": "Neues Passwort", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Speicherpasswort", "settings.privacy.encryptionRecoveryBodyDisable": "Ein vorheriger Versuch, die Verschlüsselung zu deaktivieren, wurde unterbrochen. Gib dein Passwort ein, um den Vorgang sicher abzuschließen.", "settings.privacy.encryptionRecoveryBodyRotate": "Eine vorherige Passwortänderung wurde unterbrochen. Gib dein altes und neues Passwort ein, um den Vorgang sicher abzuschließen.", diff --git a/locales/el/settings.json b/locales/el/settings.json index 9663d3e8..722624bb 100644 --- a/locales/el/settings.json +++ b/locales/el/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Η απενεργοποίηση της κρυπτογράφησης θα καταργήσει την πύλη της φράσης πρόσβασης. Εάν κάποια δεδομένα ήταν κρυπτογραφημένα κάτω από τη φράση πρόσβασής σας, θα γίνουν δυσανάγνωστα — εξάγετε πρώτα ένα αντίγραφο ασφαλείας εάν έχετε αμφιβολίες.", "settings.privacy.encryptionLockAction": "Κλείδωμα συνεδρίας", "settings.privacy.encryptionLockedStatus": "Η κρυπτογράφηση είναι ενεργοποιημένη αλλά κλειδωμένη — εισαγάγετε τη φράση πρόσβασης για πρόσβαση στα δεδομένα", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Οι φράσεις πρόσβασης δεν ταιριάζουν", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Ενεργοποίηση κρυπτογράφησης σε κατάσταση ανάπαυσης", "settings.privacy.encryptionModalUnlockTitle": "Ξεκλειδώστε την κρυπτογραφημένη αποθήκευση", "settings.privacy.encryptionNewPassphrase": "Νέα φράση πρόσβασης", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Συνθηματική φράση αποθήκευσης", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/en/settings.json b/locales/en/settings.json index 0ae7d548..cee302bb 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -684,8 +684,13 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Disabling encryption will remove the passphrase gate. If any data was encrypted under your passphrase it will become unreadable — export a backup first if in doubt.", "settings.privacy.encryptionLockAction": "Lock Session", "settings.privacy.encryptionLockedStatus": "Encryption enabled but locked — enter your passphrase to access data", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionMismatch": "Passphrases do not match", "settings.privacy.encryptionModalChangeTitle": "Change Encryption Passphrase", "settings.privacy.encryptionModalDisableTitle": "Disable At-Rest Encryption", diff --git a/locales/es/settings.json b/locales/es/settings.json index 8d8ab36c..096de028 100644 --- a/locales/es/settings.json +++ b/locales/es/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Deshabilitar el cifrado eliminará la protección por contraseña. Si algunos datos fueron cifrados, ya no serán legibles — exporta una copia de seguridad antes si tienes dudas.", "settings.privacy.encryptionLockAction": "Bloquear sesión", "settings.privacy.encryptionLockedStatus": "Cifrado activado pero bloqueado — introduce tu contraseña para acceder a los datos", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrando almacén {{current}} de {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Progreso de la migración de cifrado", "settings.privacy.encryptionMismatch": "Las contraseñas no coinciden", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Activar cifrado en reposo", "settings.privacy.encryptionModalUnlockTitle": "Desbloquear almacenamiento cifrado", "settings.privacy.encryptionNewPassphrase": "Nueva contraseña", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Contraseña de almacenamiento", "settings.privacy.encryptionRecoveryBodyDisable": "Un intento anterior de desactivar el cifrado fue interrumpido. Introduce tu contraseña para completarlo de forma segura.", "settings.privacy.encryptionRecoveryBodyRotate": "Un cambio de contraseña anterior fue interrumpido. Introduce tu contraseña antigua y la nueva para completarlo de forma segura.", diff --git a/locales/eu/settings.json b/locales/eu/settings.json index 8fb4bf62..61bf347a 100644 --- a/locales/eu/settings.json +++ b/locales/eu/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Enkriptatzea desgaituz gero, pasaesaldiaren atea kenduko da. Zure pasaesaldiaren azpian daturen bat enkriptatu bazen, irakurri ezin izango da; lehenik, esportatu babeskopia bat zalantza izanez gero.", "settings.privacy.encryptionLockAction": "Blokeatu saioa", "settings.privacy.encryptionLockedStatus": "Enkriptatzea gaituta baina blokeatuta: idatzi pasaesaldia datuak atzitzeko", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Pasaesaldiak ez datoz bat", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Gaitu At-Rest enkriptatzea", "settings.privacy.encryptionModalUnlockTitle": "Desblokeatu biltegiratze zifratua", "settings.privacy.encryptionNewPassphrase": "Pasaesaldi berria", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Biltegiratze pasaesaldia", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/fa/settings.json b/locales/fa/settings.json index 846d017f..320f8b11 100644 --- a/locales/fa/settings.json +++ b/locales/fa/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "غیرفعال کردن رمزگذاری باعث حذف گیت عبارت عبور می شود. اگر هر داده ای تحت عبارت عبور شما رمزگذاری شده باشد، غیرقابل خواندن خواهد بود - در صورت شک، ابتدا یک نسخه پشتیبان صادر کنید.", "settings.privacy.encryptionLockAction": "قفل کردن جلسه", "settings.privacy.encryptionLockedStatus": "رمزگذاری فعال است اما قفل است — برای دسترسی به داده ها، عبارت عبور خود را وارد کنید", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "عبارات عبور مطابقت ندارند", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "رمزگذاری در حالت استراحت را فعال کنید", "settings.privacy.encryptionModalUnlockTitle": "قفل حافظه رمزگذاری شده را باز کنید", "settings.privacy.encryptionNewPassphrase": "عبارت عبور جدید", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "عبارت عبور ذخیره سازی", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/fi/settings.json b/locales/fi/settings.json index 5fb5be12..48f701b8 100644 --- a/locales/fi/settings.json +++ b/locales/fi/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Salauksen poistaminen käytöstä poistaa salalauseportin. Jos jokin tiedoista on salattu tunnuslauseesi alle, siitä tulee lukukelvoton – vie ensin varmuuskopio, jos olet epävarma.", "settings.privacy.encryptionLockAction": "Lukitse istunto", "settings.privacy.encryptionLockedStatus": "Salaus käytössä, mutta lukittu – syötä tunnuslause päästäksesi käsiksi tietoihin", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Tunnuslauseet eivät täsmää", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Ota leposalaus käyttöön", "settings.privacy.encryptionModalUnlockTitle": "Avaa salatun tallennustilan lukitus", "settings.privacy.encryptionNewPassphrase": "Uusi tunnuslause", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Tallennuksen tunnuslause", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/fr/settings.json b/locales/fr/settings.json index aaa8d51e..2446cf1b 100644 --- a/locales/fr/settings.json +++ b/locales/fr/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Désactiver le chiffrement supprimera la protection par mot de passe. Si des données ont été chiffrées, elles ne seront plus lisibles — exportez une sauvegarde avant si vous avez un doute.", "settings.privacy.encryptionLockAction": "Verrouiller la session", "settings.privacy.encryptionLockedStatus": "Chiffrement activé mais verrouillé — entrez votre phrase secrète pour accéder aux données", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migration du stockage {{current}} sur {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Progression de la migration de chiffrement", "settings.privacy.encryptionMismatch": "Les phrases secrètes ne correspondent pas", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Activer le chiffrement au repos", "settings.privacy.encryptionModalUnlockTitle": "Déverrouiller le stockage chiffré", "settings.privacy.encryptionNewPassphrase": "Nouvelle phrase secrète", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Phrase secrète de stockage", "settings.privacy.encryptionRecoveryBodyDisable": "Une tentative précédente de désactivation du chiffrement a été interrompue. Entrez votre phrase secrète pour la terminer en toute sécurité.", "settings.privacy.encryptionRecoveryBodyRotate": "Un changement de phrase secrète précédent a été interrompu. Entrez votre ancienne et votre nouvelle phrase secrète pour le terminer en toute sécurité.", diff --git a/locales/he/settings.json b/locales/he/settings.json index 4210d0ae..d3bb1a97 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "השבתת ההצפנה תסיר את שער ביטוי הסיסמה. אם נתונים כלשהם הוצפנו תחת ביטוי הסיסמה שלכם הם יהפכו לבלתי קריאים — ייצאו גיבוי תחילה אם יש ספק.", "settings.privacy.encryptionLockAction": "נעילת הפעלה", "settings.privacy.encryptionLockedStatus": "הצפנה מופעלת אך נעולה — הזינו את ביטוי הסיסמה כדי לגשת לנתונים", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "ביטויי הסיסמה אינם תואמים", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "הפעלת הצפנה במנוחה", "settings.privacy.encryptionModalUnlockTitle": "פתיחת אחסון מוצפן", "settings.privacy.encryptionNewPassphrase": "ביטוי סיסמה חדש", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "ביטוי סיסמת אחסון", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/hu/settings.json b/locales/hu/settings.json index 60b42f0f..924a7216 100644 --- a/locales/hu/settings.json +++ b/locales/hu/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "A titkosítás letiltása eltávolítja a jelmondat-kaput. Ha bármilyen adatot titkosítottak az Ön jelszavával, az olvashatatlanná válik – ha kétségei vannak, először exportáljon biztonsági másolatot.", "settings.privacy.encryptionLockAction": "Lock Session", "settings.privacy.encryptionLockedStatus": "A titkosítás engedélyezve van, de zárolva – adja meg jelszavát az adatok eléréséhez", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "A jelszavak nem egyeznek", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Engedélyezze a nyugalmi titkosítást", "settings.privacy.encryptionModalUnlockTitle": "Nyissa fel a titkosított tárhelyet", "settings.privacy.encryptionNewPassphrase": "Új összetett jelszó", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Tárolási jelszó", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/is/settings.json b/locales/is/settings.json index bae124d8..add141a1 100644 --- a/locales/is/settings.json +++ b/locales/is/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Slökkt er á dulkóðun mun lykilorðshliðið fjarlægja. Ef einhver gögn voru dulkóðuð undir lykilorðinu þínu verða þau ólæsileg - fluttu fyrst út öryggisafrit ef þú ert í vafa.", "settings.privacy.encryptionLockAction": "Læstu lotu", "settings.privacy.encryptionLockedStatus": "Dulkóðun virkjuð en læst — sláðu inn lykilorðið þitt til að fá aðgang að gögnum", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Aðgangsorð passa ekki saman", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Virkjaðu dulkóðun í hvíld", "settings.privacy.encryptionModalUnlockTitle": "Opnaðu dulkóðaða geymslu", "settings.privacy.encryptionNewPassphrase": "Nýtt lykilorð", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Geymsluaðgangsorð", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/it/settings.json b/locales/it/settings.json index 1a6da944..23481089 100644 --- a/locales/it/settings.json +++ b/locales/it/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Disabilitare la crittografia rimuoverà la protezione con passphrase. Se dei dati erano stati cifrati, non saranno più leggibili — esporta un backup prima se hai dubbi.", "settings.privacy.encryptionLockAction": "Blocca sessione", "settings.privacy.encryptionLockedStatus": "Cifratura attiva ma bloccata — inserisci la passphrase per accedere ai dati", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrazione archivio {{current}} di {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Avanzamento della migrazione della crittografia", "settings.privacy.encryptionMismatch": "Le passphrase non corrispondono", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Attiva cifratura a riposo", "settings.privacy.encryptionModalUnlockTitle": "Sblocca archivio cifrato", "settings.privacy.encryptionNewPassphrase": "Nuova passphrase", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Passphrase di archiviazione", "settings.privacy.encryptionRecoveryBodyDisable": "Un precedente tentativo di disattivare la crittografia è stato interrotto. Inserisci la tua passphrase per completarlo in sicurezza.", "settings.privacy.encryptionRecoveryBodyRotate": "Un precedente cambio di passphrase è stato interrotto. Inserisci la tua vecchia e nuova passphrase per completarlo in sicurezza.", diff --git a/locales/ja/settings.json b/locales/ja/settings.json index c8b40191..cbaaf37f 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "暗号化を無効にすると、パスフレーズ ゲートが削除されます。データがパスフレーズで暗号化されている場合、そのデータは読み取れなくなります。疑わしい場合は、まずバックアップをエクスポートしてください。", "settings.privacy.encryptionLockAction": "ロックセッション", "settings.privacy.encryptionLockedStatus": "暗号化は有効ですがロックされています - データにアクセスするにはパスフレーズを入力してください", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "パスフレーズが一致しません", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "保存時の暗号化を有効にする", "settings.privacy.encryptionModalUnlockTitle": "暗号化ストレージのロックを解除する", "settings.privacy.encryptionNewPassphrase": "新しいパスフレーズ", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "ストレージパスフレーズ", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/ko/settings.json b/locales/ko/settings.json index ee11c941..1b0d0167 100644 --- a/locales/ko/settings.json +++ b/locales/ko/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "암호화를 비활성화하면 암호 게이트가 제거됩니다. 귀하의 암호로 암호화된 데이터가 있으면 읽을 수 없게 됩니다. 의심스러운 경우 먼저 백업을 내보내십시오.", "settings.privacy.encryptionLockAction": "세션 잠금", "settings.privacy.encryptionLockedStatus": "암호화가 활성화되었지만 잠겨 있습니다. 데이터에 액세스하려면 암호를 입력하세요.", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "암호가 일치하지 않습니다.", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "저장 시 암호화 활성화", "settings.privacy.encryptionModalUnlockTitle": "암호화된 저장소 잠금 해제", "settings.privacy.encryptionNewPassphrase": "새 암호", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "저장소 암호", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/pt/settings.json b/locales/pt/settings.json index 28a69592..9fe209bb 100644 --- a/locales/pt/settings.json +++ b/locales/pt/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Desativar a criptografia removerá o portão da senha. Se algum dado tiver sido criptografado com sua senha, ele se tornará ilegível – exporte primeiro um backup em caso de dúvida.", "settings.privacy.encryptionLockAction": "Bloquear sessão", "settings.privacy.encryptionLockedStatus": "Criptografia ativada, mas bloqueada – digite sua senha para acessar os dados", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "As senhas não correspondem", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Habilitar criptografia em repouso", "settings.privacy.encryptionModalUnlockTitle": "Desbloquear armazenamento criptografado", "settings.privacy.encryptionNewPassphrase": "Nova senha", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Senha de armazenamento", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/ru/settings.json b/locales/ru/settings.json index 96616a21..c7794582 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Отключение шифрования приведет к удалению шлюза парольной фразы. Если какие-либо данные были зашифрованы под вашей парольной фразой, они станут нечитаемыми — в случае сомнений сначала экспортируйте резервную копию.", "settings.privacy.encryptionLockAction": "Блокировка сеанса", "settings.privacy.encryptionLockedStatus": "Шифрование включено, но заблокировано — введите пароль для доступа к данным", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Парольные фразы не совпадают", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Включить шифрование при хранении", "settings.privacy.encryptionModalUnlockTitle": "Разблокировать зашифрованное хранилище", "settings.privacy.encryptionNewPassphrase": "Новая парольная фраза", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Парольная фраза хранилища", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/sv/settings.json b/locales/sv/settings.json index 4799006d..69b67e8c 100644 --- a/locales/sv/settings.json +++ b/locales/sv/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Inaktivering av kryptering tar bort lösenfrasporten. Om någon data krypterades under din lösenfras blir den oläslig - exportera en säkerhetskopia först om du är osäker.", "settings.privacy.encryptionLockAction": "Lås session", "settings.privacy.encryptionLockedStatus": "Kryptering aktiverad men låst – ange din lösenordsfras för att komma åt data", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Lösenfraser matchar inte", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "Aktivera At-Rest Encryption", "settings.privacy.encryptionModalUnlockTitle": "Lås upp krypterad lagring", "settings.privacy.encryptionNewPassphrase": "Ny lösenfras", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Lösenfras för lagring", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/locales/zh/settings.json b/locales/zh/settings.json index 17e2c274..838d73c0 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -684,6 +684,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "禁用加密将删除密码门。如果任何数据在您的密码下加密,它将变得不可读 - 如果有疑问,请先导出备份。", "settings.privacy.encryptionLockAction": "锁定会话", "settings.privacy.encryptionLockedStatus": "加密已启用但已锁定 - 输入您的密码以访问数据", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "密码不匹配", @@ -692,6 +694,9 @@ "settings.privacy.encryptionModalSetTitle": "启用静态加密", "settings.privacy.encryptionModalUnlockTitle": "解锁加密存储", "settings.privacy.encryptionNewPassphrase": "新密码", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "存储密码", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/ar/bundle.json b/public/locales/ar/bundle.json index c14abb3a..0db85168 100644 --- a/public/locales/ar/bundle.json +++ b/public/locales/ar/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "سيؤدي تعطيل التشفير إلى إزالة بوابة عبارة المرور. إذا كانت أي بيانات مُشفَّرة بعبارة مرورك فستصبح غير قابلة للقراءة — صدّر نسخة احتياطية أولًا إن كنت غير متأكد.", "settings.privacy.encryptionLockAction": "قفل الجلسة", "settings.privacy.encryptionLockedStatus": "التشفير مُفعّل لكنه مقفل — أدخل عبارة المرور للوصول إلى البيانات", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "عبارتا المرور غير متطابقتين", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "تفعيل التشفير أثناء السكون", "settings.privacy.encryptionModalUnlockTitle": "فتح التخزين المُشفَّر", "settings.privacy.encryptionNewPassphrase": "عبارة مرور جديدة", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "عبارة مرور التخزين", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/de/bundle.json b/public/locales/de/bundle.json index abb356ef..c760764c 100644 --- a/public/locales/de/bundle.json +++ b/public/locales/de/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Durch Deaktivierung der Verschlüsselung wird das Passwort-Gate entfernt. Falls Daten unter dem Passwort verschlüsselt wurden, sind sie danach nicht mehr lesbar — exportiere vorher ein Backup.", "settings.privacy.encryptionLockAction": "Sitzung sperren", "settings.privacy.encryptionLockedStatus": "Verschlüsselung aktiviert, aber gesperrt — Passwort eingeben, um auf Daten zuzugreifen", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migriere Speicher {{current}} von {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Fortschritt der Verschlüsselungsmigration", "settings.privacy.encryptionMismatch": "Passwörter stimmen nicht überein", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Ruheverschlüsselung aktivieren", "settings.privacy.encryptionModalUnlockTitle": "Verschlüsselten Speicher entsperren", "settings.privacy.encryptionNewPassphrase": "Neues Passwort", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Speicherpasswort", "settings.privacy.encryptionRecoveryBodyDisable": "Ein vorheriger Versuch, die Verschlüsselung zu deaktivieren, wurde unterbrochen. Gib dein Passwort ein, um den Vorgang sicher abzuschließen.", "settings.privacy.encryptionRecoveryBodyRotate": "Eine vorherige Passwortänderung wurde unterbrochen. Gib dein altes und neues Passwort ein, um den Vorgang sicher abzuschließen.", diff --git a/public/locales/el/bundle.json b/public/locales/el/bundle.json index 90138806..9fc23bc6 100644 --- a/public/locales/el/bundle.json +++ b/public/locales/el/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Η απενεργοποίηση της κρυπτογράφησης θα καταργήσει την πύλη της φράσης πρόσβασης. Εάν κάποια δεδομένα ήταν κρυπτογραφημένα κάτω από τη φράση πρόσβασής σας, θα γίνουν δυσανάγνωστα — εξάγετε πρώτα ένα αντίγραφο ασφαλείας εάν έχετε αμφιβολίες.", "settings.privacy.encryptionLockAction": "Κλείδωμα συνεδρίας", "settings.privacy.encryptionLockedStatus": "Η κρυπτογράφηση είναι ενεργοποιημένη αλλά κλειδωμένη — εισαγάγετε τη φράση πρόσβασης για πρόσβαση στα δεδομένα", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Οι φράσεις πρόσβασης δεν ταιριάζουν", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Ενεργοποίηση κρυπτογράφησης σε κατάσταση ανάπαυσης", "settings.privacy.encryptionModalUnlockTitle": "Ξεκλειδώστε την κρυπτογραφημένη αποθήκευση", "settings.privacy.encryptionNewPassphrase": "Νέα φράση πρόσβασης", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Συνθηματική φράση αποθήκευσης", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index 4027d0f2..ad34ef4c 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -2330,8 +2330,13 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Disabling encryption will remove the passphrase gate. If any data was encrypted under your passphrase it will become unreadable — export a backup first if in doubt.", "settings.privacy.encryptionLockAction": "Lock Session", "settings.privacy.encryptionLockedStatus": "Encryption enabled but locked — enter your passphrase to access data", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionMismatch": "Passphrases do not match", "settings.privacy.encryptionModalChangeTitle": "Change Encryption Passphrase", "settings.privacy.encryptionModalDisableTitle": "Disable At-Rest Encryption", diff --git a/public/locales/es/bundle.json b/public/locales/es/bundle.json index 94124345..c8f0ff98 100644 --- a/public/locales/es/bundle.json +++ b/public/locales/es/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Deshabilitar el cifrado eliminará la protección por contraseña. Si algunos datos fueron cifrados, ya no serán legibles — exporta una copia de seguridad antes si tienes dudas.", "settings.privacy.encryptionLockAction": "Bloquear sesión", "settings.privacy.encryptionLockedStatus": "Cifrado activado pero bloqueado — introduce tu contraseña para acceder a los datos", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrando almacén {{current}} de {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Progreso de la migración de cifrado", "settings.privacy.encryptionMismatch": "Las contraseñas no coinciden", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Activar cifrado en reposo", "settings.privacy.encryptionModalUnlockTitle": "Desbloquear almacenamiento cifrado", "settings.privacy.encryptionNewPassphrase": "Nueva contraseña", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Contraseña de almacenamiento", "settings.privacy.encryptionRecoveryBodyDisable": "Un intento anterior de desactivar el cifrado fue interrumpido. Introduce tu contraseña para completarlo de forma segura.", "settings.privacy.encryptionRecoveryBodyRotate": "Un cambio de contraseña anterior fue interrumpido. Introduce tu contraseña antigua y la nueva para completarlo de forma segura.", diff --git a/public/locales/eu/bundle.json b/public/locales/eu/bundle.json index c1e8333a..1a246386 100644 --- a/public/locales/eu/bundle.json +++ b/public/locales/eu/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Enkriptatzea desgaituz gero, pasaesaldiaren atea kenduko da. Zure pasaesaldiaren azpian daturen bat enkriptatu bazen, irakurri ezin izango da; lehenik, esportatu babeskopia bat zalantza izanez gero.", "settings.privacy.encryptionLockAction": "Blokeatu saioa", "settings.privacy.encryptionLockedStatus": "Enkriptatzea gaituta baina blokeatuta: idatzi pasaesaldia datuak atzitzeko", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Pasaesaldiak ez datoz bat", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Gaitu At-Rest enkriptatzea", "settings.privacy.encryptionModalUnlockTitle": "Desblokeatu biltegiratze zifratua", "settings.privacy.encryptionNewPassphrase": "Pasaesaldi berria", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Biltegiratze pasaesaldia", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/fa/bundle.json b/public/locales/fa/bundle.json index f8946c16..3c7d1895 100644 --- a/public/locales/fa/bundle.json +++ b/public/locales/fa/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "غیرفعال کردن رمزگذاری باعث حذف گیت عبارت عبور می شود. اگر هر داده ای تحت عبارت عبور شما رمزگذاری شده باشد، غیرقابل خواندن خواهد بود - در صورت شک، ابتدا یک نسخه پشتیبان صادر کنید.", "settings.privacy.encryptionLockAction": "قفل کردن جلسه", "settings.privacy.encryptionLockedStatus": "رمزگذاری فعال است اما قفل است — برای دسترسی به داده ها، عبارت عبور خود را وارد کنید", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "عبارات عبور مطابقت ندارند", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "رمزگذاری در حالت استراحت را فعال کنید", "settings.privacy.encryptionModalUnlockTitle": "قفل حافظه رمزگذاری شده را باز کنید", "settings.privacy.encryptionNewPassphrase": "عبارت عبور جدید", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "عبارت عبور ذخیره سازی", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/fi/bundle.json b/public/locales/fi/bundle.json index ae4d5d82..28ed32ee 100644 --- a/public/locales/fi/bundle.json +++ b/public/locales/fi/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Salauksen poistaminen käytöstä poistaa salalauseportin. Jos jokin tiedoista on salattu tunnuslauseesi alle, siitä tulee lukukelvoton – vie ensin varmuuskopio, jos olet epävarma.", "settings.privacy.encryptionLockAction": "Lukitse istunto", "settings.privacy.encryptionLockedStatus": "Salaus käytössä, mutta lukittu – syötä tunnuslause päästäksesi käsiksi tietoihin", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Tunnuslauseet eivät täsmää", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Ota leposalaus käyttöön", "settings.privacy.encryptionModalUnlockTitle": "Avaa salatun tallennustilan lukitus", "settings.privacy.encryptionNewPassphrase": "Uusi tunnuslause", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Tallennuksen tunnuslause", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/fr/bundle.json b/public/locales/fr/bundle.json index d67a851c..6057f5fb 100644 --- a/public/locales/fr/bundle.json +++ b/public/locales/fr/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Désactiver le chiffrement supprimera la protection par mot de passe. Si des données ont été chiffrées, elles ne seront plus lisibles — exportez une sauvegarde avant si vous avez un doute.", "settings.privacy.encryptionLockAction": "Verrouiller la session", "settings.privacy.encryptionLockedStatus": "Chiffrement activé mais verrouillé — entrez votre phrase secrète pour accéder aux données", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migration du stockage {{current}} sur {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Progression de la migration de chiffrement", "settings.privacy.encryptionMismatch": "Les phrases secrètes ne correspondent pas", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Activer le chiffrement au repos", "settings.privacy.encryptionModalUnlockTitle": "Déverrouiller le stockage chiffré", "settings.privacy.encryptionNewPassphrase": "Nouvelle phrase secrète", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Phrase secrète de stockage", "settings.privacy.encryptionRecoveryBodyDisable": "Une tentative précédente de désactivation du chiffrement a été interrompue. Entrez votre phrase secrète pour la terminer en toute sécurité.", "settings.privacy.encryptionRecoveryBodyRotate": "Un changement de phrase secrète précédent a été interrompu. Entrez votre ancienne et votre nouvelle phrase secrète pour le terminer en toute sécurité.", diff --git a/public/locales/he/bundle.json b/public/locales/he/bundle.json index c3cfd886..efded629 100644 --- a/public/locales/he/bundle.json +++ b/public/locales/he/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "השבתת ההצפנה תסיר את שער ביטוי הסיסמה. אם נתונים כלשהם הוצפנו תחת ביטוי הסיסמה שלכם הם יהפכו לבלתי קריאים — ייצאו גיבוי תחילה אם יש ספק.", "settings.privacy.encryptionLockAction": "נעילת הפעלה", "settings.privacy.encryptionLockedStatus": "הצפנה מופעלת אך נעולה — הזינו את ביטוי הסיסמה כדי לגשת לנתונים", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "ביטויי הסיסמה אינם תואמים", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "הפעלת הצפנה במנוחה", "settings.privacy.encryptionModalUnlockTitle": "פתיחת אחסון מוצפן", "settings.privacy.encryptionNewPassphrase": "ביטוי סיסמה חדש", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "ביטוי סיסמת אחסון", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/hu/bundle.json b/public/locales/hu/bundle.json index 7537379f..6b874b24 100644 --- a/public/locales/hu/bundle.json +++ b/public/locales/hu/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "A titkosítás letiltása eltávolítja a jelmondat-kaput. Ha bármilyen adatot titkosítottak az Ön jelszavával, az olvashatatlanná válik – ha kétségei vannak, először exportáljon biztonsági másolatot.", "settings.privacy.encryptionLockAction": "Lock Session", "settings.privacy.encryptionLockedStatus": "A titkosítás engedélyezve van, de zárolva – adja meg jelszavát az adatok eléréséhez", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "A jelszavak nem egyeznek", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Engedélyezze a nyugalmi titkosítást", "settings.privacy.encryptionModalUnlockTitle": "Nyissa fel a titkosított tárhelyet", "settings.privacy.encryptionNewPassphrase": "Új összetett jelszó", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Tárolási jelszó", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/is/bundle.json b/public/locales/is/bundle.json index 503a9a6d..f858ca62 100644 --- a/public/locales/is/bundle.json +++ b/public/locales/is/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Slökkt er á dulkóðun mun lykilorðshliðið fjarlægja. Ef einhver gögn voru dulkóðuð undir lykilorðinu þínu verða þau ólæsileg - fluttu fyrst út öryggisafrit ef þú ert í vafa.", "settings.privacy.encryptionLockAction": "Læstu lotu", "settings.privacy.encryptionLockedStatus": "Dulkóðun virkjuð en læst — sláðu inn lykilorðið þitt til að fá aðgang að gögnum", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Aðgangsorð passa ekki saman", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Virkjaðu dulkóðun í hvíld", "settings.privacy.encryptionModalUnlockTitle": "Opnaðu dulkóðaða geymslu", "settings.privacy.encryptionNewPassphrase": "Nýtt lykilorð", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Geymsluaðgangsorð", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/it/bundle.json b/public/locales/it/bundle.json index 79057a91..45511396 100644 --- a/public/locales/it/bundle.json +++ b/public/locales/it/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Disabilitare la crittografia rimuoverà la protezione con passphrase. Se dei dati erano stati cifrati, non saranno più leggibili — esporta un backup prima se hai dubbi.", "settings.privacy.encryptionLockAction": "Blocca sessione", "settings.privacy.encryptionLockedStatus": "Cifratura attiva ma bloccata — inserisci la passphrase per accedere ai dati", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrazione archivio {{current}} di {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Avanzamento della migrazione della crittografia", "settings.privacy.encryptionMismatch": "Le passphrase non corrispondono", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Attiva cifratura a riposo", "settings.privacy.encryptionModalUnlockTitle": "Sblocca archivio cifrato", "settings.privacy.encryptionNewPassphrase": "Nuova passphrase", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Passphrase di archiviazione", "settings.privacy.encryptionRecoveryBodyDisable": "Un precedente tentativo di disattivare la crittografia è stato interrotto. Inserisci la tua passphrase per completarlo in sicurezza.", "settings.privacy.encryptionRecoveryBodyRotate": "Un precedente cambio di passphrase è stato interrotto. Inserisci la tua vecchia e nuova passphrase per completarlo in sicurezza.", diff --git a/public/locales/ja/bundle.json b/public/locales/ja/bundle.json index fdee42f6..f48f9a94 100644 --- a/public/locales/ja/bundle.json +++ b/public/locales/ja/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "暗号化を無効にすると、パスフレーズ ゲートが削除されます。データがパスフレーズで暗号化されている場合、そのデータは読み取れなくなります。疑わしい場合は、まずバックアップをエクスポートしてください。", "settings.privacy.encryptionLockAction": "ロックセッション", "settings.privacy.encryptionLockedStatus": "暗号化は有効ですがロックされています - データにアクセスするにはパスフレーズを入力してください", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "パスフレーズが一致しません", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "保存時の暗号化を有効にする", "settings.privacy.encryptionModalUnlockTitle": "暗号化ストレージのロックを解除する", "settings.privacy.encryptionNewPassphrase": "新しいパスフレーズ", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "ストレージパスフレーズ", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/ko/bundle.json b/public/locales/ko/bundle.json index f3ade9a1..b4e69dda 100644 --- a/public/locales/ko/bundle.json +++ b/public/locales/ko/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "암호화를 비활성화하면 암호 게이트가 제거됩니다. 귀하의 암호로 암호화된 데이터가 있으면 읽을 수 없게 됩니다. 의심스러운 경우 먼저 백업을 내보내십시오.", "settings.privacy.encryptionLockAction": "세션 잠금", "settings.privacy.encryptionLockedStatus": "암호화가 활성화되었지만 잠겨 있습니다. 데이터에 액세스하려면 암호를 입력하세요.", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "암호가 일치하지 않습니다.", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "저장 시 암호화 활성화", "settings.privacy.encryptionModalUnlockTitle": "암호화된 저장소 잠금 해제", "settings.privacy.encryptionNewPassphrase": "새 암호", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "저장소 암호", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/pt/bundle.json b/public/locales/pt/bundle.json index a4609957..d7d3d621 100644 --- a/public/locales/pt/bundle.json +++ b/public/locales/pt/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Desativar a criptografia removerá o portão da senha. Se algum dado tiver sido criptografado com sua senha, ele se tornará ilegível – exporte primeiro um backup em caso de dúvida.", "settings.privacy.encryptionLockAction": "Bloquear sessão", "settings.privacy.encryptionLockedStatus": "Criptografia ativada, mas bloqueada – digite sua senha para acessar os dados", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "As senhas não correspondem", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Habilitar criptografia em repouso", "settings.privacy.encryptionModalUnlockTitle": "Desbloquear armazenamento criptografado", "settings.privacy.encryptionNewPassphrase": "Nova senha", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Senha de armazenamento", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/ru/bundle.json b/public/locales/ru/bundle.json index 197ca31b..226a6285 100644 --- a/public/locales/ru/bundle.json +++ b/public/locales/ru/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Отключение шифрования приведет к удалению шлюза парольной фразы. Если какие-либо данные были зашифрованы под вашей парольной фразой, они станут нечитаемыми — в случае сомнений сначала экспортируйте резервную копию.", "settings.privacy.encryptionLockAction": "Блокировка сеанса", "settings.privacy.encryptionLockedStatus": "Шифрование включено, но заблокировано — введите пароль для доступа к данным", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Парольные фразы не совпадают", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Включить шифрование при хранении", "settings.privacy.encryptionModalUnlockTitle": "Разблокировать зашифрованное хранилище", "settings.privacy.encryptionNewPassphrase": "Новая парольная фраза", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Парольная фраза хранилища", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/sv/bundle.json b/public/locales/sv/bundle.json index 2bc648cc..377df30b 100644 --- a/public/locales/sv/bundle.json +++ b/public/locales/sv/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "Inaktivering av kryptering tar bort lösenfrasporten. Om någon data krypterades under din lösenfras blir den oläslig - exportera en säkerhetskopia först om du är osäker.", "settings.privacy.encryptionLockAction": "Lås session", "settings.privacy.encryptionLockedStatus": "Kryptering aktiverad men låst – ange din lösenordsfras för att komma åt data", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "Lösenfraser matchar inte", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "Aktivera At-Rest Encryption", "settings.privacy.encryptionModalUnlockTitle": "Lås upp krypterad lagring", "settings.privacy.encryptionNewPassphrase": "Ny lösenfras", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "Lösenfras för lagring", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/public/locales/zh/bundle.json b/public/locales/zh/bundle.json index 84c96608..5cbba7dd 100644 --- a/public/locales/zh/bundle.json +++ b/public/locales/zh/bundle.json @@ -2330,6 +2330,8 @@ "settings.privacy.encryptionForgotPassphraseWarning": "禁用加密将删除密码门。如果任何数据在您的密码下加密,它将变得不可读 - 如果有疑问,请先导出备份。", "settings.privacy.encryptionLockAction": "锁定会话", "settings.privacy.encryptionLockedStatus": "加密已启用但已锁定 - 输入您的密码以访问数据", + "settings.privacy.encryptionMigrationInterruptedBody": "A previous \"{{operation}}\" encryption change did not finish (started {{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.", + "settings.privacy.encryptionMigrationInterruptedTitle": "Encryption Migration Interrupted", "settings.privacy.encryptionMigrationProgress": "Migrating store {{current}} of {{total}}…", "settings.privacy.encryptionMigrationProgressLabel": "Encryption migration progress", "settings.privacy.encryptionMismatch": "密码不匹配", @@ -2338,6 +2340,9 @@ "settings.privacy.encryptionModalSetTitle": "启用静态加密", "settings.privacy.encryptionModalUnlockTitle": "解锁加密存储", "settings.privacy.encryptionNewPassphrase": "新密码", + "settings.privacy.encryptionOperationDisable": "disable", + "settings.privacy.encryptionOperationRotate": "rotate", + "settings.privacy.encryptionOperationSet": "set", "settings.privacy.encryptionPassphrase": "存储密码", "settings.privacy.encryptionRecoveryBodyDisable": "A previous attempt to disable encryption was interrupted. Enter your passphrase to safely finish removing it.", "settings.privacy.encryptionRecoveryBodyRotate": "A previous passphrase change was interrupted. Enter your old and new passphrases to safely finish it.", diff --git a/services/dbInitialization.ts b/services/dbInitialization.ts index 1226bb55..8d12a3c6 100644 --- a/services/dbInitialization.ts +++ b/services/dbInitialization.ts @@ -4,7 +4,9 @@ */ import { DATA_DB_NAME, STATE_DB_NAME } from './dbConstants'; import { dbService } from './dbService'; +import { deleteAllFsData } from './fs/fsEncryptionMigration'; import { logger } from './logger'; +import { isTauriRuntime } from './tauriRuntime'; export interface InitStorageResult { success: boolean; @@ -102,6 +104,16 @@ export async function checkStorageHealth(): Promise { * Use as a last-resort recovery option (user-confirmed). */ export async function resetAllDatabases(): Promise { + // QNBS-v3: fs-backed data deleted FIRST, before the KDF salt below — if this throws partway, the + // salt/sentinel are still intact and no protected file becomes permanently undecryptable; erasing + // the salt first would strand any already-protected file that this step hadn't reached yet. + if (isTauriRuntime()) { + await deleteAllFsData().catch((error) => { + logger.error('dbInitialization: resetAllDatabases — failed to delete filesystem data', error); + throw error; + }); + } + logger.warn('dbInitialization: resetAllDatabases — deleting both IDB databases'); await Promise.all([deleteIdb(STATE_DB_NAME), deleteIdb(DATA_DB_NAME)]); diff --git a/services/factoryResetService.ts b/services/factoryResetService.ts index 992dc858..5b9dbbfb 100644 --- a/services/factoryResetService.ts +++ b/services/factoryResetService.ts @@ -8,7 +8,9 @@ * IDB store is added. */ +import { deleteAllFsData } from './fs/fsEncryptionMigration'; import { logger } from './logger'; +import { isTauriRuntime } from './tauriRuntime'; /** All IDB databases the app may have created. */ const KNOWN_DB_NAMES = [ @@ -63,6 +65,15 @@ async function clearServiceWorkerCaches(): Promise { */ export async function wipeAllAppData(): Promise { logger.warn('[factoryReset] Wiping all app data…'); + // QNBS-v3: fs-backed data deleted FIRST, before localStorage.clear() below erases the KDF salt — + // if this throws partway, the salt/sentinel are still intact and no protected file becomes + // permanently undecryptable; a "factory reset" that leaves orphaned ciphertext on disk isn't one. + if (isTauriRuntime()) { + await deleteAllFsData().catch((error) => { + logger.error('[factoryReset] Failed to delete filesystem data', error); + throw error; + }); + } await deleteAllIndexedDBDatabases(); await clearServiceWorkerCaches(); try { diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts index c50dc310..6af24d9e 100644 --- a/services/fs/assetFsStore.ts +++ b/services/fs/assetFsStore.ts @@ -8,12 +8,13 @@ */ import { logger } from '../logger'; +import { IdbStorageLockedError } from '../storage/storageEncryptionService'; import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend'; import { protectTextValue, + readProtectedTextFile, retryFs, sanitizePathSegment, - unprotectTextValue, writeFileAtomic, writeTextFileAtomic, } from './fsCore'; @@ -50,10 +51,11 @@ export class FsAssetStore extends FsSnapshotStore { return null; } - const stored = await retryFs(() => apis.readTextFile(imageFile)); - const base64Data = await unprotectTextValue(stored); + const base64Data = await readProtectedTextFile(apis, imageFile); return `data:image/png;base64,${base64Data}`; } catch (error) { + // QNBS-v3: a locked session is not "no image" — never conflate the two. + if (error instanceof IdbStorageLockedError) throw error; logger.error('Failed to load image:', error); return null; } diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts index 940b2bf7..6a56b5b7 100644 --- a/services/fs/codexFsStore.ts +++ b/services/fs/codexFsStore.ts @@ -7,6 +7,7 @@ import type { StoryCodex } from '../../types'; import { logger } from '../logger'; +import { IdbStorageLockedError } from '../storage/storageEncryptionService'; import { compressData, decompressData, @@ -41,6 +42,8 @@ export class FsCodexStore extends FsSettingsStore { const content = await readProtectedTextFile(apis, codexFile); return decompressData(content); } catch (error) { + // QNBS-v3: a locked session is not "no codex" — never conflate the two. + if (error instanceof IdbStorageLockedError) throw error; logger.error('Failed to load story codex:', error); return null; } @@ -81,6 +84,8 @@ export class FsCodexStore extends FsSettingsStore { const content = await readProtectedTextFile(apis, vectorsFile); return decompressData(content); } catch (error) { + // QNBS-v3: a locked session is not "no vectors" — never conflate the two. + if (error instanceof IdbStorageLockedError) throw error; logger.error('Failed to load RAG vectors:', error); return []; } diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 6e17495a..04ac5f82 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -173,10 +173,7 @@ interface ProtectedTextEnvelope { } 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. + // QNBS-v3: plaintext here is compressData()'s output (plain JSON or LZ-compressed) — a JSON.parse failure or shape mismatch just means "not protected", not an error; this is a probe, not a strict parser. try { const parsed: unknown = JSON.parse(raw); if ( diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts index c5a93a30..abad83cc 100644 --- a/services/fs/fsEncryptionMigration.ts +++ b/services/fs/fsEncryptionMigration.ts @@ -29,32 +29,20 @@ const PROTECTED_TEXT_SCHEME = 'protected-v1'; interface MigrationOptions { targetKey: CryptoKey | null; - // QNBS-v3: strict=true (disable/rotate) aborts the whole operation on any decrypt failure — the - // caller is about to destroy/replace the only key that could ever decrypt a stranded file, so a - // failure here must block the operation rather than complete with data silently left behind. - // strict=false (first-time setup) logs and skips the offending file instead — nothing valuable - // is being destroyed by turning encryption on, and a stray already-protected file (e.g. a - // leftover from a previous, since-forgotten encryption session) must not block the user from - // protecting everything else. + // QNBS-v3: strict=true (disable/rotate) aborts on any decrypt failure — the caller is about to destroy/replace the only key that could decrypt a stranded file; strict=false (first-time setup) logs and skips instead, since nothing valuable is being destroyed. strict: boolean; } +// QNBS-v3: an exists() check first separates "genuinely absent" (always safe to skip — e.g. config/ not yet created on a fresh install) from "present but unreadable" (a real error that must propagate, not be silently treated as empty). async function listDirEntries( apis: TauriApis, dir: string, ): Promise<{ name?: string; isDirectory?: boolean }[]> { - try { - return await apis.readDir(dir); - } catch { - return []; // directory may not exist yet — nothing to migrate under it - } + if (!(await apis.exists(dir))) return []; + return apis.readDir(dir); } -// QNBS-v3: this bridge re-keys files directly with no persistent per-file journal/checkpoint (the -// IDB migration path has one; this doesn't yet — tracked in issue #359). A process kill mid-rotate -// can leave some files under the new key while the durable sentinel still reflects the old one. The -// marker below can't resume or fix that, but it converts a silent mixed-key state into a detected -// one: written before migration starts, cleared only on full success, checked at next startup. +// QNBS-v3: no persistent per-file journal/checkpoint yet (tracked in issue #359) — a process kill mid-rotate can leave a mixed-key state; this marker can't resume/fix that but converts it into a detected one. const MIGRATION_MARKER_FILENAME = 'fs-migration-marker.json'; interface FsMigrationMarker { @@ -110,8 +98,16 @@ async function reprotectWholeFile( path: string, opts: MigrationOptions, ): Promise { - const raw = await apis.readTextFile(path).catch(() => null); - if (raw === null) return; + // QNBS-v3: exists() first — a read failure on a file that DOES exist must propagate, not be conflated with "never existed". + if (!(await apis.exists(path))) return; + let raw: string; + try { + raw = await apis.readTextFile(path); + } catch (error) { + if (opts.strict) throw error; + logger.warn(`Skipping ${path} — could not read it:`, error); + return; + } let plaintext: string; try { plaintext = await unprotectTextValue(raw); @@ -141,8 +137,15 @@ async function reprotectSnapshotFile( path: string, opts: MigrationOptions, ): Promise { - const raw = await apis.readTextFile(path).catch(() => null); - if (raw === null) return; + if (!(await apis.exists(path))) return; + let raw: string; + try { + raw = await apis.readTextFile(path); + } catch (error) { + if (opts.strict) throw error; + logger.warn(`Skipping ${path} — could not read it:`, error); + return; + } let envelope: SnapshotEnvelopeShape; try { envelope = JSON.parse(raw) as SnapshotEnvelopeShape; @@ -235,7 +238,30 @@ export async function migrateAllProtectedFsData( }), ); - // QNBS-v3: only reached if every step above completed without throwing — a strict-mode abort or - // a process kill both leave the marker in place, which is the intended "interrupted" signal. + // QNBS-v3: only reached if every step above completed without throwing — a strict-mode abort or a process kill both leave the marker in place, the intended "interrupted" signal. await clearMigrationMarker(apis, appDataPath); } + +// QNBS-v3: both resetAllDatabases() (storage-init-failure recovery) and wipeAllAppData() (factory +// reset) previously deleted only IDB + localStorage (including the KDF salt) without touching this +// filesystem backend — any already-protected fs file became permanently undecryptable ciphertext +// orphaned on disk, since the salt is required to re-derive any key, even with the right passphrase. +const RESETTABLE_TOP_LEVEL_DIRS = ['projects', 'config', 'snapshots', 'images'] as const; + +/** + * Deletes every fs-backed store's data (projects, settings, API keys, snapshots, images). No-op + * outside the Tauri runtime — callers should still gate on isTauriRuntime() themselves so this + * import doesn't need to be reached at all on web. Deliberately called BEFORE the salt/sentinel is + * erased by the caller: if this throws partway through, the salt/sentinel are still intact, so no + * remaining file becomes stranded — only a full, unconditional erase of both sides together is safe. + */ +export async function deleteAllFsData(): Promise { + const apis = await loadTauriApis(); + const appDataPath = await apis.appDataDir(); + for (const dir of RESETTABLE_TOP_LEVEL_DIRS) { + const dirPath = await apis.join(appDataPath, dir); + if (await apis.exists(dirPath)) { + await apis.remove(dirPath, { recursive: true }); + } + } +} diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 16259b72..d801c08d 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -10,6 +10,7 @@ import type { EntityState } from '@reduxjs/toolkit'; import type { Character, StoryProject, World } from '../../types'; import { logger } from '../logger'; import { parseImportedProjectJson } from '../projectImportSchema'; +import { IdbStorageLockedError } from '../storage/storageEncryptionService'; import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend'; import { FsAssetStore } from './assetFsStore'; import { @@ -100,6 +101,8 @@ export class FsProjectStore extends FsAssetStore { const content = await readProtectedTextFile(apis, projectFile); return decompressData(content); } catch (error) { + // QNBS-v3: a locked session is not "no project" — appBootstrap.ts's Promise.all propagates this up to index.tsx's existing IdbStorageLockedError catch, which shows the unlock modal and retries boot, instead of silently hydrating as a brand-new user. + if (error instanceof IdbStorageLockedError) throw error; logger.error('Failed to load project:', error); return null; } diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index a4de3a9a..c61b12da 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -76,6 +76,8 @@ export class FsSettingsStore extends FsCore { // QNBS-v3: reuse the same normalizer as the IDB path — older desktop settings files can predate newer required Settings fields (e.g. writingSurfaceStyle); an unchecked `as Settings` cast would let those fall through as undefined at runtime. return normalizePersistedSettings(parsed); } catch (error) { + // QNBS-v3: a locked session is not "no settings" — propagate so appBootstrap.ts's Promise.all surfaces it to index.tsx's existing IdbStorageLockedError catch (unlock modal + retry) instead of silently hydrating defaults. + if (error instanceof IdbStorageLockedError) throw error; logger.error('Failed to load settings:', error); return null; } @@ -215,6 +217,19 @@ export class FsSettingsStore extends FsCore { provider: string, targetKey: CryptoKey | null, strict = true, + ): Promise { + try { + await this.reprotectApiKeyFileInner(provider, targetKey); + } catch (error) { + if (strict) throw error; + // QNBS-v3: non-strict (first-time setup) must never throw — any per-file error is logged and skipped so it can never strand setupIdbEncryption()'s already-activated sentinel/key. + logger.warn(`Skipping API key for provider "${provider}" during encryption setup:`, error); + } + } + + private async reprotectApiKeyFileInner( + provider: string, + targetKey: CryptoKey | null, ): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); @@ -225,31 +240,26 @@ export class FsSettingsStore extends FsCore { let apiKey: string; if (parsed['scheme'] === PLAINTEXT_SCHEME && typeof parsed['value'] === 'string') { - // QNBS-v3: covers first-time setup, where every existing key file starts as plaintext-v1 — - // without this branch, migrateAllProtectedFsData(targetKey) during 'set' would silently - // leave already-saved keys unencrypted until their next incidental re-save. + // QNBS-v3: covers first-time setup, where every existing key file starts as plaintext-v1 — without this, 'set' would leave already-saved keys unencrypted until their next incidental re-save. apiKey = parsed['value']; } else if (parsed['scheme'] === PROTECTED_SCHEME && typeof parsed['data'] === 'string') { const sourceKey = await resolveProtectedWriteKey(); if (!sourceKey) { - const error = new Error( + throw new Error( `Protected API key for provider "${provider}" exists but at-rest encryption is no longer configured`, ); - if (strict) throw error; - logger.warn(error.message); - return; } - try { - const decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( - sourceKey, - base64ToBytes(parsed['data']), + const decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( + sourceKey, + base64ToBytes(parsed['data']), + ); + // QNBS-v3: same provider-identity check readProtectedApiKey() enforces on ordinary reads — without it, a ciphertext swapped between two provider files gets "laundered" into a correctly-labeled new file by this migration, silently bypassing the cross-file substitution guard. + if (decrypted.provider !== provider) { + throw new Error( + `Decrypted payload belongs to provider "${decrypted.provider}", not "${provider}"`, ); - apiKey = decrypted.apiKey; - } catch (error) { - if (strict) throw error; - logger.warn(`Skipping API key for provider "${provider}" — could not decrypt it:`, error); - return; } + apiKey = decrypted.apiKey; } else { // Unrecognized/legacy shape — not this method's job to migrate; getApiKey()'s own // positively-identified-legacy-only discard path handles that on next read. diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts index 26eee824..dee8b3c0 100644 --- a/services/fs/snapshotFsStore.ts +++ b/services/fs/snapshotFsStore.ts @@ -5,6 +5,7 @@ import type { ProjectSnapshot } from '../../types'; import { logger } from '../logger'; +import { IdbStorageLockedError } from '../storage/storageEncryptionService'; import { FsCodexStore } from './codexFsStore'; import { compressData, @@ -71,6 +72,8 @@ export class FsSnapshotStore extends FsCodexStore { // Legacy format: raw project data stored directly return envelope; } catch (error) { + // QNBS-v3: a locked session is not "no snapshot" — never conflate the two. + if (error instanceof IdbStorageLockedError) throw error; logger.error('Failed to load snapshot:', error); return null; } diff --git a/tests/unit/dbInitialization.test.ts b/tests/unit/dbInitialization.test.ts index 2b7e8a39..77ebcd20 100644 --- a/tests/unit/dbInitialization.test.ts +++ b/tests/unit/dbInitialization.test.ts @@ -3,15 +3,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockInitDB, mockDeleteDatabase } = vi.hoisted(() => ({ - mockInitDB: vi.fn(), - mockDeleteDatabase: vi.fn(), -})); +const { mockInitDB, mockDeleteDatabase, mockIsTauriRuntime, mockDeleteAllFsData } = vi.hoisted( + () => ({ + mockInitDB: vi.fn(), + mockDeleteDatabase: vi.fn(), + mockIsTauriRuntime: vi.fn(() => false), + mockDeleteAllFsData: vi.fn().mockResolvedValue(undefined), + }), +); vi.mock('../../services/dbService', () => ({ dbService: { initDB: mockInitDB }, })); +vi.mock('../../services/tauriRuntime', () => ({ + isTauriRuntime: () => mockIsTauriRuntime(), +})); + +vi.mock('../../services/fs/fsEncryptionMigration', () => ({ + deleteAllFsData: () => mockDeleteAllFsData(), +})); + vi.mock('../../services/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); @@ -26,6 +38,8 @@ describe('dbInitialization', () => { vi.resetModules(); mockInitDB.mockReset(); mockDeleteDatabase.mockReset(); + mockIsTauriRuntime.mockReset().mockReturnValue(false); + mockDeleteAllFsData.mockReset().mockResolvedValue(undefined); // QNBS-v3: fresh req per call so parallel deletes each own their onsuccess slot. mockDeleteDatabase.mockImplementation(() => { @@ -136,6 +150,47 @@ describe('dbInitialization', () => { const { resetAllDatabases } = await import('../../services/dbInitialization'); await expect(resetAllDatabases()).resolves.toBeUndefined(); }); + + it('does not touch filesystem data outside the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(false); + const { resetAllDatabases } = await import('../../services/dbInitialization'); + + await resetAllDatabases(); + + expect(mockDeleteAllFsData).not.toHaveBeenCalled(); + }); + + it('deletes filesystem data BEFORE the IDB databases (which hold the KDF salt-adjacent state), in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + const callOrder: string[] = []; + mockDeleteAllFsData.mockImplementation(async () => { + callOrder.push('deleteAllFsData'); + }); + mockDeleteDatabase.mockImplementation((name: string) => { + callOrder.push(`deleteDatabase:${name}`); + const req: Record = { onsuccess: null, onerror: null, onblocked: null }; + Promise.resolve().then(() => { + if (typeof req['onsuccess'] === 'function') (req['onsuccess'] as () => void)(); + }); + return req; + }); + + const { resetAllDatabases } = await import('../../services/dbInitialization'); + await resetAllDatabases(); + + expect(mockDeleteAllFsData).toHaveBeenCalled(); + expect(callOrder[0]).toBe('deleteAllFsData'); + }); + + it('aborts before deleting any IDB database when filesystem cleanup fails, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + mockDeleteAllFsData.mockRejectedValueOnce(new Error('fs delete failed')); + + const { resetAllDatabases } = await import('../../services/dbInitialization'); + await expect(resetAllDatabases()).rejects.toThrow('fs delete failed'); + + expect(mockDeleteDatabase).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/factoryResetService.test.ts b/tests/unit/factoryResetService.test.ts index 38d1a9ff..8acd1d3f 100644 --- a/tests/unit/factoryResetService.test.ts +++ b/tests/unit/factoryResetService.test.ts @@ -7,10 +7,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wipeAllAppData } from '../../services/factoryResetService'; import { logger } from '../../services/logger'; +const { mockIsTauriRuntime, mockDeleteAllFsData } = vi.hoisted(() => ({ + mockIsTauriRuntime: vi.fn(() => false), + mockDeleteAllFsData: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../services/logger', () => ({ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, })); +vi.mock('../../services/tauriRuntime', () => ({ + isTauriRuntime: () => mockIsTauriRuntime(), +})); + +vi.mock('../../services/fs/fsEncryptionMigration', () => ({ + deleteAllFsData: () => mockDeleteAllFsData(), +})); + function createDb(name: string): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(name, 1); @@ -42,6 +55,8 @@ let originalLocation: Location; beforeEach(() => { vi.clearAllMocks(); + mockIsTauriRuntime.mockReturnValue(false); + mockDeleteAllFsData.mockResolvedValue(undefined); reloadMock = vi.fn(); originalLocation = window.location; Object.defineProperty(window, 'location', { @@ -100,4 +115,42 @@ describe('wipeAllAppData', () => { expect(del).toHaveBeenCalledWith('dynamic-v1'); expect(reloadMock).toHaveBeenCalledTimes(1); }); + + it('does not touch filesystem data outside the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(false); + + await runWipe(); + + expect(mockDeleteAllFsData).not.toHaveBeenCalled(); + }); + + it('deletes filesystem data BEFORE clearing IDB/web storage, in the Tauri runtime', async () => { + await createDb('worldscript-data-db'); + mockIsTauriRuntime.mockReturnValue(true); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + + await runWipe(); + + expect(mockDeleteAllFsData).toHaveBeenCalled(); + expect(delSpy).toHaveBeenCalled(); + const fsDataCallOrder = mockDeleteAllFsData.mock.invocationCallOrder[0] as number; + const idbDeleteCallOrder = delSpy.mock.invocationCallOrder[0] as number; + expect(fsDataCallOrder).toBeLessThan(idbDeleteCallOrder); + delSpy.mockRestore(); + }); + + it('aborts before deleting any IDB database or clearing storage when filesystem cleanup fails, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + mockDeleteAllFsData.mockRejectedValueOnce(new Error('fs delete failed')); + localStorage.setItem('foo', 'bar'); + const delSpy = vi.spyOn(indexedDB, 'deleteDatabase'); + + // QNBS-v3: fs-delete rejects synchronously before the 300ms fake-timer delay is ever scheduled — plain await, no runWipe()/fake timers needed for this negative path. + await expect(wipeAllAppData()).rejects.toThrow('fs delete failed'); + + expect(delSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem('foo')).toBe('bar'); + expect(reloadMock).not.toHaveBeenCalled(); + delSpy.mockRestore(); + }); }); diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 7e3cb694..2e7ea08f 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -41,6 +41,7 @@ const mockClearIdbPassphrase = vi.fn().mockResolvedValue(undefined); const mockRotateIdbPassphrase = vi.fn().mockResolvedValue(undefined); const mockDeriveRotationTargetKey = vi.fn().mockResolvedValue('mock-target-key'); const mockResolveProtectedWriteKey = vi.fn().mockResolvedValue('mock-active-key'); +const mockDeriveAndVerifySourceKeyFromSentinel = vi.fn().mockResolvedValue('mock-source-key'); const mockMigrateAllProtectedFsData = vi.fn().mockResolvedValue(undefined); const mockIsTauriRuntime = vi.fn(() => false); @@ -211,6 +212,8 @@ vi.mock('../../../services/storage/storageEncryptionService', () => ({ mockRotateIdbPassphrase(oldPass, newPass, onProgress), deriveRotationTargetKey: (newPassphrase: string) => mockDeriveRotationTargetKey(newPassphrase), resolveProtectedWriteKey: () => mockResolveProtectedWriteKey(), + deriveAndVerifySourceKeyFromSentinel: (passphrase: string) => + mockDeriveAndVerifySourceKeyFromSentinel(passphrase), })); vi.mock('../../../services/storageService', () => ({ @@ -630,6 +633,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { mockMigrateAllProtectedFsData.mockClear().mockResolvedValue(undefined); mockDeriveRotationTargetKey.mockClear().mockResolvedValue('mock-target-key'); mockResolveProtectedWriteKey.mockClear().mockResolvedValue('mock-active-key'); + mockDeriveAndVerifySourceKeyFromSentinel.mockClear().mockResolvedValue('mock-source-key'); mockIsTauriRuntime.mockReturnValue(false); }); @@ -850,11 +854,37 @@ describe('handlePassphraseConfirm — disable/rotate', () => { await result.current.handlePassphraseConfirm('old-pass', 'new-pass'); }); + expect(mockDeriveAndVerifySourceKeyFromSentinel).toHaveBeenCalledWith('old-pass'); expect(mockDeriveRotationTargetKey).toHaveBeenCalledWith('new-pass'); expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith('derived-target-key', 'rotate'); expect(callOrder).toEqual(['migrateAllProtectedFsData', 'rotateIdbPassphrase']); }); + // QNBS-v3: a mistyped current passphrase must abort BEFORE any fs file is re-keyed — otherwise the + // bridge (which uses the still-active old key, independent of _current) would already have rewritten + // everything under the new key by the time rotateIdbPassphrase() rejects the wrong _current, leaving + // fs data under a key the active session never actually adopts. + it('verifies the current passphrase against the sentinel BEFORE re-keying any fs file, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + mockDeriveAndVerifySourceKeyFromSentinel.mockRejectedValueOnce(new Error('wrong passphrase')); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('rotate'); + }); + await act(async () => { + await expect(result.current.handlePassphraseConfirm('typo-pass', 'new-pass')).rejects.toThrow( + 'wrong passphrase', + ); + }); + + expect(mockDeriveAndVerifySourceKeyFromSentinel).toHaveBeenCalledWith('typo-pass'); + expect(mockDeriveRotationTargetKey).not.toHaveBeenCalled(); + expect(mockMigrateAllProtectedFsData).not.toHaveBeenCalled(); + expect(mockRotateIdbPassphrase).not.toHaveBeenCalled(); + expect(result.current.passphraseModal).toBe('rotate'); + }); + it('aborts before clearIdbPassphrase and leaves the modal open when the fs migration bridge fails', async () => { mockIsTauriRuntime.mockReturnValue(true); mockMigrateAllProtectedFsData.mockRejectedValueOnce(new Error('fs decrypt failed')); diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts index 1d6ebb66..9e7dd728 100644 --- a/tests/unit/services/fs/fsEncryptionMigration.test.ts +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -318,4 +318,62 @@ describe('migrateAllProtectedFsData — safety', () => { await migrateAllProtectedFsData(null, 'disable'); expect(fake.bin.get('/app/projects/p1/binder/asset-1.bin')).toEqual(before); }); + + // QNBS-v3: a permission/IO error reading a file that DOES exist must never be conflated with "file + // never existed" — the prior `.catch(() => null)` pattern silently skipped such files, letting + // disable/rotate complete and destroy/swap the key while stranding the unreadable file's ciphertext. + it('propagates (does not silently skip) a read failure on a file that exists, in strict mode', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + const originalReadTextFile = fake.apis.readTextFile; + fake.apis.readTextFile = (p: string) => { + if (p === '/app/projects/p1/project.json') return Promise.reject(new Error('EACCES')); + return originalReadTextFile(p); + }; + + await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(/EACCES/); + }); + + it('logs and skips (does not throw) a read failure on a file that exists, in non-strict (set) mode', async () => { + await fileSystemService.saveProject(project as never); + const originalReadTextFile = fake.apis.readTextFile; + fake.apis.readTextFile = (p: string) => { + if (p === '/app/projects/p1/project.json') return Promise.reject(new Error('EACCES')); + return originalReadTextFile(p); + }; + + const newKey = await deriveKey('first-passphrase'); + await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); + }); + + // QNBS-v3: a ciphertext swapped between two provider files must not be "laundered" into a + // correctly-labeled new file by re-keying — the same provider-identity check normal reads enforce. + it('rejects (does not launder) an API key ciphertext whose decrypted provider does not match its filename', async () => { + await enableTestPassphrase(); + await fileSystemService.saveApiKey('openai', 'openai-secret'); + // Swap the ciphertext into a differently-named file, simulating a cross-file substitution. + const openaiRaw = fake.text.get('/app/config/openai_key.enc.json') as string; + fake.text.set('/app/config/anthropic_key.enc.json', openaiRaw); + + const newKey = await deriveKey('new-passphrase'); + await expect(migrateAllProtectedFsData(newKey, 'rotate')).rejects.toThrow(/anthropic/); + }); + + // QNBS-v3: a malformed API-key file must never strand setupIdbEncryption()'s already-activated + // sentinel/key — non-strict (first-time setup) must swallow even a synchronous JSON.parse throw. + it('does not throw when an API key file contains malformed JSON, in non-strict (set) mode', async () => { + await fake.apis.mkdir('/app/config'); + fake.text.set('/app/config/openai_key.enc.json', '{not valid json'); + + const newKey = await deriveKey('first-passphrase'); + await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); + }); + + it('throws when an API key file contains malformed JSON, in strict (disable/rotate) mode', async () => { + await enableTestPassphrase(); + await fake.apis.mkdir('/app/config'); + fake.text.set('/app/config/openai_key.enc.json', '{not valid json'); + + await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(); + }); }); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index dedc2a9a..b26d3f23 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -60,7 +60,10 @@ vi.mock('../../../../services/logger', async (importOriginal) => { import { appStoreRef } from '../../../../app/storeRef'; import { FsProjectStore } from '../../../../services/fs/projectFsStore'; import { logger } from '../../../../services/logger'; -import { StorageEncryptionService } from '../../../../services/storage/storageEncryptionService'; +import { + IdbStorageLockedError, + StorageEncryptionService, +} from '../../../../services/storage/storageEncryptionService'; interface FakeFs { apis: TauriApis; @@ -197,6 +200,15 @@ describe('FsProjectStore — projects', () => { expect((await store.loadProject('p1'))?.title).toBe('My Novel'); }); + // QNBS-v3: a locked session is not "no project" — loadProject() must propagate IdbStorageLockedError so appBootstrap.ts's Promise.all surfaces it to index.tsx's unlock-modal-and-retry catch, instead of silently hydrating as a brand-new user. + it('throws IdbStorageLockedError (not null) when loading a project while the session is locked', async () => { + await enableTestPassphrase(); + await store.saveProject(project as never); + cryptoState.activeKey = null; // simulate session lock; sentinelConfigured stays true + + await expect(store.loadProject('p1')).rejects.toBeInstanceOf(IdbStorageLockedError); + }); + it('leaves project.json as plaintext when no at-rest passphrase is configured (unchanged default)', async () => { await store.saveProject(project as never); const onDisk = fake.text.get('/app/projects/p1/project.json') as string; @@ -279,6 +291,15 @@ describe('FsSettingsStore — settings + encrypted API keys', () => { expect((await store.loadSettings())?.appearancePreset).toBe('sepia'); }); + // QNBS-v3: a locked session is not "no settings" — loadSettings() must propagate IdbStorageLockedError so appBootstrap.ts's Promise.all surfaces it to index.tsx's unlock-modal-and-retry catch, instead of silently hydrating defaults. + it('throws IdbStorageLockedError (not null) when loading settings while the session is locked', async () => { + await enableTestPassphrase(); + await store.saveSettings({ appearancePreset: 'sepia' } as never); + cryptoState.activeKey = null; // simulate session lock; sentinelConfigured stays true + + await expect(store.loadSettings()).rejects.toBeInstanceOf(IdbStorageLockedError); + }); + // QNBS-v3 (2026-08-13, F-05/F-06 follow-up): no passphrase configured — honest plaintext, not a fake-secret derivation. it('round-trips an API key as plaintext when no at-rest passphrase is configured', async () => { await store.saveApiKey('openai', 'sk-secret-123'); @@ -505,6 +526,15 @@ describe('FsSnapshotStore — snapshots', () => { await enableTestPassphrase(); expect(await store.getSnapshotData(id)).toEqual({ manuscript: [{ content: 'secret prose' }] }); }); + + // QNBS-v3: a locked session is not "no snapshot" — getSnapshotData() must propagate IdbStorageLockedError, not swallow it. + it('throws IdbStorageLockedError (not null) when reading a protected snapshot while the session is locked', async () => { + await enableTestPassphrase(); + const id = await store.saveSnapshot('My Snapshot', { manuscript: [] }); + cryptoState.activeKey = null; // simulate session lock; sentinelConfigured stays true + + await expect(store.getSnapshotData(id)).rejects.toBeInstanceOf(IdbStorageLockedError); + }); }); describe('FsCodexStore — codex + RAG vectors', () => { @@ -540,6 +570,17 @@ describe('FsCodexStore — codex + RAG vectors', () => { ); expect(await store.getRagVectors('p1')).toEqual([{ id: 1 }]); }); + + // QNBS-v3: a locked session is not "no codex/vectors" — both getters must propagate IdbStorageLockedError, not swallow it. + it('throws IdbStorageLockedError (not null/[]) when reading protected codex/RAG data while the session is locked', async () => { + await enableTestPassphrase(); + await store.saveStoryCodex({ projectId: 'p1', entries: [] } as never); + await store.saveRagVectors('p1', [{ id: 1 }]); + cryptoState.activeKey = null; // simulate session lock; sentinelConfigured stays true + + await expect(store.getStoryCodex('p1')).rejects.toBeInstanceOf(IdbStorageLockedError); + await expect(store.getRagVectors('p1')).rejects.toBeInstanceOf(IdbStorageLockedError); + }); }); describe('FsAssetStore — images + binder assets', () => { @@ -561,6 +602,15 @@ describe('FsAssetStore — images + binder assets', () => { expect(await store.getImage('char-1')).toBe('data:image/png;base64,QUJD'); }); + // QNBS-v3: a locked session is not "no image" — getImage() must propagate IdbStorageLockedError, not swallow it. + it('throws IdbStorageLockedError (not null) when reading a protected image while the session is locked', async () => { + await enableTestPassphrase(); + await store.saveImage('char-1', 'data:image/png;base64,QUJD'); + cryptoState.activeKey = null; // simulate session lock; sentinelConfigured stays true + + await expect(store.getImage('char-1')).rejects.toBeInstanceOf(IdbStorageLockedError); + }); + it('round-trips a binder binary asset with metadata', async () => { const data = new Uint8Array([1, 2, 3, 4]).buffer; await store.saveBinderAsset('p1', 'a1', data, { From c93a92f0a9f38ff5794d8a91bbc293375679b69d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:11:30 +0200 Subject: [PATCH 05/12] =?UTF-8?q?fix(desktop):=20close=20remaining=20PR=20?= =?UTF-8?q?#356=20external-review=20findings=20=E2=80=94=20write-ordering?= =?UTF-8?q?=20race,=20set-mode=20rollback,=20docs=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix a write-ordering race: writeProtectedTextFileAtomic() used to await protectTextValue() BEFORE entering the per-path write queue, so two overlapping saves could have the OLDER save's slower encryption land in the queue after the newer one and silently overwrite it. Encryption now happens inside the same queue slot that serializes the atomic write (fsCore.ts's new enqueueTextFileWrite), closing the same gap in assetFsStore.ts's saveImage() by routing it through the shared helper instead of duplicating the pattern. - Harden migrateAllProtectedFsData's non-strict ('set') mode to also catch write failures (reprotectWholeFile/reprotectSnapshotFile) and readDir failures (listDirEntries) — previously only read/decrypt failures were caught, so a routine per-file I/O error during first-time setup could still crash the whole migration after setupIdbEncryption() had already activated the sentinel. - Add a rollback in useSettingsView.ts's 'set' branch: if the fs migration still fails after the hardening above (e.g. the migration marker itself can't be written, before any file is touched), the just-created sentinel is undone via clearIdbPassphrase() instead of being left active with the feature flag off. - Correct stale desktop-encryption claims in docs/IDB-ENCRYPTION.md (still described Tauri as sharing the IDB migration path wholesale) and locales/en/help.json (still claimed passphrase rotation/disable were unavailable, predating the B-1 passphrase UX). - Add a snapshot test for reading a protected snapshot after encryption has been disabled entirely (sourcery-ai suggestion), and regression tests for the write-ordering fix and the set-mode rollback. Opened #360 (fs reads/writes don't participate in the migration admission lock) and #361 (fs ciphertext isn't bound to its record identity) to track the two remaining architectural gaps external review surfaced that need their own design work rather than a inline fix. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 27 ++++++++++++++++ docs/IDB-ENCRYPTION.md | 2 +- hooks/useSettingsView.ts | 11 +++++-- locales/en/help.json | 2 +- public/locales/en/bundle.json | 2 +- services/fs/assetFsStore.ts | 4 +-- services/fs/fsCore.ts | 20 +++++++++--- services/fs/fsEncryptionMigration.ts | 31 ++++++++++++++---- tests/unit/hooks/useSettingsView.test.ts | 26 +++++++++++++++ tests/unit/services/fs/fsCore.test.ts | 32 ++++++++++++++++--- .../services/fs/fsEncryptionMigration.test.ts | 29 +++++++++++++++++ tests/unit/services/fs/fsStores.test.ts | 18 +++++++++++ 12 files changed, 180 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6999ff..4a667c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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). + **Second review-loop follow-up to the same change:** a locked session previously read as "no + project"/"no settings" on every fs-backed store (project, settings, Codex, RAG vectors, + snapshot, image) — the catch-all handlers swallowed `IdbStorageLockedError` into `null`, so + desktop could silently boot as a brand-new user instead of showing the unlock modal; all six now + re-throw it, reusing the web build's already-proven unlock-and-retry flow with no boot-sequence + changes needed. Rotating the passphrase now verifies the *current* passphrase against the + durable sentinel before re-keying any filesystem file, closing a mixed-key bug where a mistyped + current passphrase let the bridge re-key everything to a new key that `rotateIdbPassphrase()` + then never actually activates. The migration bridge's non-strict ('set') mode now also survives + a write failure or an unreadable directory (previously only read/decrypt failures on individual + files were caught) — a routine per-file I/O error can no longer crash first-time setup after the + sentinel is already active; when it still does (e.g. the migration marker itself can't be + written), `useSettingsView.ts` now rolls the just-created sentinel back via `clearIdbPassphrase()` + rather than leaving it active with the feature flag off. Fixed a write-ordering gap where an + older, slower-to-encrypt save's write could land in the per-path queue after a newer save and + silently overwrite it — encryption now happens *inside* the same queue slot that serializes the + atomic write, not before it. An API-key ciphertext swapped between two provider files is now + rejected (not laundered into a correctly-labeled file) by the migration path, mirroring the + existing ordinary-read guard. Neither of the app's two "nuclear reset" flows + (`resetAllDatabases()`, storage-init-failure recovery; `wipeAllAppData()`, factory reset) ever + touched Tauri filesystem data, while both destroy the KDF salt required to derive any key — any + already-protected fs file became permanently orphaned ciphertext after either reset. Both now + call a new `deleteAllFsData()` first, with failure propagating rather than being swallowed, so a + partial fs-delete failure never proceeds to destroy the salt. An interrupted migration (crash, + forced quit, power loss mid-operation) now leaves a durable marker detected at next startup and + surfaced as a status notification — not a full resumable migration yet, see + [issue #359](https://github.com/qnbs/WorldScript-Studio/issues/359) for that tracked gap. ### Fixed diff --git a/docs/IDB-ENCRYPTION.md b/docs/IDB-ENCRYPTION.md index d83cda49..a6e44040 100644 --- a/docs/IDB-ENCRYPTION.md +++ b/docs/IDB-ENCRYPTION.md @@ -161,7 +161,7 @@ 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. +Desktop shares this lifecycle's passphrase, PBKDF2 derivation, sentinel, and unlock/lock UX — but its actual project data lives in the filesystem (`services/fs/*Store.ts`), not the browser's IndexedDB, so it cannot reuse the IDB migration orchestrator above directly. `services/fs/fsEncryptionMigration.ts` is a parallel bridge that converges every fs-backed file (project, settings, snapshot, Codex, RAG-vector, and image data) to whatever the IDB-side operation implies — first-time setup encrypts existing plaintext files, disable decrypts back to plaintext, rotate re-keys — and must run to completion before the shared sentinel/active key changes underneath it. It has no journal/checkpoint of its own yet (see [issue #359](https://github.com/qnbs/WorldScript-Studio/issues/359) for the tracked gap: an interrupted rotation is detected via a marker file on next launch, but not automatically resumed). Binder research-asset files (`.bin`/`.meta.json`) are the one fs-backed store this bridge does not cover and remain plaintext. The repository does **not** use `tauri-plugin-stronghold`, an OS keychain, or a transparent desktop-only passphrase store. See [README § Encryption — which mechanism protects what](../README.md#-encryption--which-mechanism-protects-what) for the full per-store breakdown. --- diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index c3ff81c1..32d428c2 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -389,8 +389,15 @@ export const useSettingsView = () => { await setupIdbEncryption(newPassphrase); // QNBS-v3: without this, "encryption active" would only mean future writes get protected — every already-existing fs-backed file would stay plaintext until its next incidental save; strict:false so a stray pre-existing oddity can't block setup for everything else. if (isTauriRuntime()) { - const key = await resolveProtectedWriteKey(); - if (key) await migrateAllProtectedFsData(key, 'set'); + try { + const key = await resolveProtectedWriteKey(); + if (key) await migrateAllProtectedFsData(key, 'set'); + } catch (error) { + // QNBS-v3: migrateAllProtectedFsData('set') is non-strict and already skips per-file failures — reaching here means a pre-flight failure (e.g. the migration marker itself couldn't be written) before any file was touched, so undoing the sentinel just-created above is safe, not just cosmetic. + await clearIdbPassphrase(); + setEncryptionReady(false); + throw error; + } } dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(true)); setEncryptionReady(true); diff --git a/locales/en/help.json b/locales/en/help.json index 22e0a9f9..59709415 100644 --- a/locales/en/help.json +++ b/locales/en/help.json @@ -3,7 +3,7 @@ "help.advanced.adaptiveAi.title": "Adaptive AI, GPU & Eco Mode", "help.advanced.cloudSync.content": "Optional end-to-end encrypted sync keeps your library in step across devices using a Cloudflare R2 backend. Enable “Cloud sync” under Settings → Early Access Features and configure it under Settings → Connections. Project data is encrypted locally with AES-256-GCM before upload, and your AI API keys are never sent to the cloud. Sync is entirely opt-in; with it off, WorldScript remains a fully offline-first app.", "help.advanced.cloudSync.title": "Cloud Sync", - "help.advanced.encryption.content": "Protect primary project data, snapshots, and supported settings stored on your device with AES-256-GCM encryption derived from a passphrase (PBKDF2, 600,000 iterations). Enable it under Settings → Privacy & Security → “Encrypt project data at rest”. On the next launch an unlock dialog asks for your passphrase; protected reads and writes remain blocked while locked rather than falling back to plaintext. While the cross-store migration protocol is being completed, changing or disabling encryption is unavailable so existing ciphertext remains recoverable. Your passphrase never leaves the device and cannot be recovered — export an encrypted library backup before you experiment.", + "help.advanced.encryption.content": "Protect primary project data, snapshots, and supported settings stored on your device with AES-256-GCM encryption derived from a passphrase (PBKDF2, 600,000 iterations). Enable it under Settings → Privacy & Security → “Encrypt project data at rest”. On the next launch an unlock dialog asks for your passphrase; protected reads and writes remain blocked while locked rather than falling back to plaintext. You can change your passphrase or turn encryption back off at any time from the same Settings panel. In the desktop app, this now also protects the on-disk project, settings, snapshot, Codex, and RAG-vector files (not just the browser build's IndexedDB store) — the one exception is Binder research-asset files, which remain plaintext. Your passphrase never leaves the device and cannot be recovered — export an encrypted library backup before you experiment.", "help.advanced.encryption.title": "At-Rest Encryption", "help.advanced.languages.content": "WorldScript Studio ships 19 interface languages. Five are Production tier (German, English, Spanish, French, Italian) — fully reviewed. Others are Near-Production (Japanese, Chinese, Portuguese, Greek) or Beta (Finnish, Swedish, Hungarian, Icelandic, Basque, Korean, Russian, plus the right-to-left languages Arabic, Hebrew and Persian). The status tier appears next to each language in Settings → General and the Welcome Portal language picker, and a quality dashboard summarizes per-locale coverage. Switch language there or via the Command Palette. Selecting Arabic, Hebrew or Persian flips the whole interface to RTL and loads self-hosted Noto Sans Arabic/Hebrew fonts (with Noto Naskh Arabic for the manuscript editor). Your manuscript text always follows its own script direction, so you can mix Latin and RTL passages freely. Beta and RTL translations are community-improvable; help articles fall back to English where a locale has not yet translated them.", "help.advanced.languages.title": "Languages, status tiers & RTL", diff --git a/public/locales/en/bundle.json b/public/locales/en/bundle.json index ad34ef4c..edc10822 100644 --- a/public/locales/en/bundle.json +++ b/public/locales/en/bundle.json @@ -1110,7 +1110,7 @@ "help.advanced.adaptiveAi.title": "Adaptive AI, GPU & Eco Mode", "help.advanced.cloudSync.content": "Optional end-to-end encrypted sync keeps your library in step across devices using a Cloudflare R2 backend. Enable “Cloud sync” under Settings → Early Access Features and configure it under Settings → Connections. Project data is encrypted locally with AES-256-GCM before upload, and your AI API keys are never sent to the cloud. Sync is entirely opt-in; with it off, WorldScript remains a fully offline-first app.", "help.advanced.cloudSync.title": "Cloud Sync", - "help.advanced.encryption.content": "Protect primary project data, snapshots, and supported settings stored on your device with AES-256-GCM encryption derived from a passphrase (PBKDF2, 600,000 iterations). Enable it under Settings → Privacy & Security → “Encrypt project data at rest”. On the next launch an unlock dialog asks for your passphrase; protected reads and writes remain blocked while locked rather than falling back to plaintext. While the cross-store migration protocol is being completed, changing or disabling encryption is unavailable so existing ciphertext remains recoverable. Your passphrase never leaves the device and cannot be recovered — export an encrypted library backup before you experiment.", + "help.advanced.encryption.content": "Protect primary project data, snapshots, and supported settings stored on your device with AES-256-GCM encryption derived from a passphrase (PBKDF2, 600,000 iterations). Enable it under Settings → Privacy & Security → “Encrypt project data at rest”. On the next launch an unlock dialog asks for your passphrase; protected reads and writes remain blocked while locked rather than falling back to plaintext. You can change your passphrase or turn encryption back off at any time from the same Settings panel. In the desktop app, this now also protects the on-disk project, settings, snapshot, Codex, and RAG-vector files (not just the browser build's IndexedDB store) — the one exception is Binder research-asset files, which remain plaintext. Your passphrase never leaves the device and cannot be recovered — export an encrypted library backup before you experiment.", "help.advanced.encryption.title": "At-Rest Encryption", "help.advanced.languages.content": "WorldScript Studio ships 19 interface languages. Five are Production tier (German, English, Spanish, French, Italian) — fully reviewed. Others are Near-Production (Japanese, Chinese, Portuguese, Greek) or Beta (Finnish, Swedish, Hungarian, Icelandic, Basque, Korean, Russian, plus the right-to-left languages Arabic, Hebrew and Persian). The status tier appears next to each language in Settings → General and the Welcome Portal language picker, and a quality dashboard summarizes per-locale coverage. Switch language there or via the Command Palette. Selecting Arabic, Hebrew or Persian flips the whole interface to RTL and loads self-hosted Noto Sans Arabic/Hebrew fonts (with Noto Naskh Arabic for the manuscript editor). Your manuscript text always follows its own script direction, so you can mix Latin and RTL passages freely. Beta and RTL translations are community-improvable; help articles fall back to English where a locale has not yet translated them.", "help.advanced.languages.title": "Languages, status tiers & RTL", diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts index 6af24d9e..b756479b 100644 --- a/services/fs/assetFsStore.ts +++ b/services/fs/assetFsStore.ts @@ -11,11 +11,11 @@ import { logger } from '../logger'; import { IdbStorageLockedError } from '../storage/storageEncryptionService'; import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend'; import { - protectTextValue, readProtectedTextFile, retryFs, sanitizePathSegment, writeFileAtomic, + writeProtectedTextFileAtomic, writeTextFileAtomic, } from './fsCore'; import { FsSnapshotStore } from './snapshotFsStore'; @@ -34,7 +34,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, await protectTextValue(cleanBase64)); + await writeProtectedTextFileAtomic(apis, imageFile, cleanBase64); } async getImage(id: string): Promise { diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 04ac5f82..ff29608c 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -138,15 +138,25 @@ async function writeThenRename( await atomicRename(apis, tmpPath, finalPath); } -export function writeTextFileAtomic(apis: TauriApis, path: string, content: string): Promise { - return enqueueWrite(path, () => { +// QNBS-v3: takes a content-producer, not a value, so writeProtectedTextFileAtomic can enqueue BEFORE encrypting — otherwise two overlapping saves race on which one finishes encrypting first, letting an older save's slower encryption land last in the queue and overwrite a newer save's plaintext write. +function enqueueTextFileWrite( + apis: TauriApis, + path: string, + getContent: () => Promise, +): Promise { + return enqueueWrite(path, async () => { + const content = await getContent(); const tmpPath = `${path}.tmp-${createTempSuffix()}`; - return writeThenRename(apis, tmpPath, path, () => + await writeThenRename(apis, tmpPath, path, () => retryFs(() => apis.writeTextFile(tmpPath, content)), ); }); } +export function writeTextFileAtomic(apis: TauriApis, path: string, content: string): Promise { + return enqueueTextFileWrite(apis, path, () => Promise.resolve(content)); +} + export function writeFileAtomic(apis: TauriApis, path: string, data: Uint8Array): Promise { return enqueueWrite(path, () => { const tmpPath = `${path}.tmp-${createTempSuffix()}`; @@ -223,12 +233,12 @@ export async function unprotectTextValue(stored: string): Promise { } /** Whole-file variant of protectTextValue, for stores with no separate plaintext metadata to preserve. */ -export async function writeProtectedTextFileAtomic( +export function writeProtectedTextFileAtomic( apis: TauriApis, path: string, plaintext: string, ): Promise { - await writeTextFileAtomic(apis, path, await protectTextValue(plaintext)); + return enqueueTextFileWrite(apis, path, () => protectTextValue(plaintext)); } /** Whole-file variant of unprotectTextValue, for stores with no separate plaintext metadata to preserve. */ diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts index abad83cc..21b6ddb6 100644 --- a/services/fs/fsEncryptionMigration.ts +++ b/services/fs/fsEncryptionMigration.ts @@ -33,13 +33,20 @@ interface MigrationOptions { strict: boolean; } -// QNBS-v3: an exists() check first separates "genuinely absent" (always safe to skip — e.g. config/ not yet created on a fresh install) from "present but unreadable" (a real error that must propagate, not be silently treated as empty). +// QNBS-v3: an exists() check first separates "genuinely absent" (always safe to skip — e.g. config/ not yet created on a fresh install) from "present but unreadable" (a real error that must propagate in strict mode, or be logged-and-skipped in non-strict mode — never silently treated as empty). async function listDirEntries( apis: TauriApis, dir: string, + strict: boolean, ): Promise<{ name?: string; isDirectory?: boolean }[]> { if (!(await apis.exists(dir))) return []; - return apis.readDir(dir); + try { + return await apis.readDir(dir); + } catch (error) { + if (strict) throw error; + logger.warn(`Skipping directory ${dir} — could not list its contents:`, error); + return []; + } } // QNBS-v3: no persistent per-file journal/checkpoint yet (tracked in issue #359) — a process kill mid-rotate can leave a mixed-key state; this marker can't resume/fix that but converts it into a detected one. @@ -123,7 +130,12 @@ async function reprotectWholeFile( }) : plaintext; if (content === raw) return; // already in the desired state - await writeTextFileAtomic(apis, path, content); + try { + await writeTextFileAtomic(apis, path, content); + } catch (error) { + if (opts.strict) throw error; + logger.warn(`Skipping ${path} — could not write its new protected state:`, error); + } } interface SnapshotEnvelopeShape { @@ -169,7 +181,12 @@ async function reprotectSnapshotFile( }) : plaintext; if (envelope.data === originalData) return; // already in the desired state - await writeTextFileAtomic(apis, path, JSON.stringify(envelope)); + try { + await writeTextFileAtomic(apis, path, JSON.stringify(envelope)); + } catch (error) { + if (opts.strict) throw error; + logger.warn(`Skipping ${path} — could not write its new protected state:`, error); + } } /** @@ -193,7 +210,7 @@ export async function migrateAllProtectedFsData( await writeMigrationMarker(apis, appDataPath, operation); const configPath = await apis.join(appDataPath, 'config'); - const configEntries = await listDirEntries(apis, configPath); + const configEntries = await listDirEntries(apis, configPath, opts.strict); await Promise.all( configEntries.map(async (entry) => { if (!entry.name || entry.isDirectory) return; @@ -208,7 +225,7 @@ export async function migrateAllProtectedFsData( ); const snapshotsPath = await apis.join(appDataPath, 'snapshots'); - const snapshotEntries = await listDirEntries(apis, snapshotsPath); + const snapshotEntries = await listDirEntries(apis, snapshotsPath, opts.strict); await Promise.all( snapshotEntries.map(async (entry) => { if (!entry.name?.endsWith('.json')) return; @@ -218,7 +235,7 @@ export async function migrateAllProtectedFsData( ); const imagesPath = await apis.join(appDataPath, 'images'); - const imageEntries = await listDirEntries(apis, imagesPath); + const imageEntries = await listDirEntries(apis, imagesPath, opts.strict); await Promise.all( imageEntries.map(async (entry) => { if (!entry.name?.endsWith('.png')) return; diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 2e7ea08f..9df9f04e 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -713,6 +713,32 @@ describe('handlePassphraseConfirm — disable/rotate', () => { expect(callOrder).toEqual(['setupIdbEncryption', 'migrateAllProtectedFsData']); }); + it('rolls back the just-created sentinel when the first-time-setup fs migration fails, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + mockResolveProtectedWriteKey.mockResolvedValue('newly-active-key'); + mockMigrateAllProtectedFsData.mockRejectedValueOnce(new Error('marker write failed')); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('set'); + }); + await act(async () => { + await expect(result.current.handlePassphraseConfirm('', 'newpass123')).rejects.toThrow( + 'marker write failed', + ); + }); + + // QNBS-v3: no progress callback here — this is a rollback of setupIdbEncryption(), not the disable branch's user-facing migration. + expect(mockClearIdbPassphrase).toHaveBeenCalledWith(undefined); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: 'featureFlags/setEnableIdbAtRestEncryption', + payload: true, + }), + ); + expect(result.current.passphraseModal).toBe('set'); + }); + it('does not encrypt fs-backed desktop data on first-time setup outside the Tauri runtime', async () => { mockIsTauriRuntime.mockReturnValue(false); const { result } = renderHook(() => useSettingsView()); diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts index 747d076d..f9c85538 100644 --- a/tests/unit/services/fs/fsCore.test.ts +++ b/tests/unit/services/fs/fsCore.test.ts @@ -24,8 +24,13 @@ import { } from '../../../../services/fs/fsCore'; // QNBS-v3: controllable fake for storageEncryptionService's IDB-backed sentinel/session state — see tests/unit/services/fs/fsStores.test.ts for the full rationale (same pattern, scoped here to the pure protectTextValue/unprotectTextValue helpers rather than a whole store). +// QNBS-v3: keyResolutionDelaysMs lets a test make one call's resolveProtectedWriteKey() (the first async step inside protectTextValue) resolve slower than another's, to test write-ordering under encryption. const { cryptoState } = vi.hoisted(() => ({ - cryptoState: { activeKey: null as CryptoKey | null, sentinelConfigured: false }, + cryptoState: { + activeKey: null as CryptoKey | null, + sentinelConfigured: false, + keyResolutionDelaysMs: [] as number[], + }, })); vi.mock('../../../../services/storage/storageEncryptionService', async (importOriginal) => { const actual = @@ -33,10 +38,12 @@ vi.mock('../../../../services/storage/storageEncryptionService', async (importOr return { ...actual, hasPassphraseSentinel: () => Promise.resolve(cryptoState.sentinelConfigured), - resolveProtectedWriteKey: () => { - if (cryptoState.activeKey) return Promise.resolve(cryptoState.activeKey); - if (cryptoState.sentinelConfigured) return Promise.reject(new actual.IdbStorageLockedError()); - return Promise.resolve(null); + resolveProtectedWriteKey: async () => { + const delay = cryptoState.keyResolutionDelaysMs.shift(); + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); + if (cryptoState.activeKey) return cryptoState.activeKey; + if (cryptoState.sentinelConfigured) throw new actual.IdbStorageLockedError(); + return null; }, }; }); @@ -46,6 +53,7 @@ import { StorageEncryptionService } from '../../../../services/storage/storageEn beforeEach(() => { cryptoState.activeKey = null; cryptoState.sentinelConfigured = false; + cryptoState.keyResolutionDelaysMs = []; }); // QNBS-v3: entries are plain names, directories suffixed with '/' — just enough to model a nested tree for cleanupOrphanedTempFiles' recursive walk, independent of makeAtomicWriteFake above. @@ -381,6 +389,20 @@ describe('protectTextValue / unprotectTextValue / writeProtectedTextFileAtomic / await writeProtectedTextFileAtomic(apis, '/app/project.json', '{"title":"My Novel"}'); expect(text.get('/app/project.json')).toBe('{"title":"My Novel"}'); }); + + // QNBS-v3: an older call's key-resolution step used to run OUTSIDE the per-path write queue, so a slower-to-encrypt older save could land in the queue after a faster-to-encrypt newer save and overwrite it — regression test for that ordering gap. + it('serializes concurrent protected writes to the same path in call order, even when the OLDER call resolves its key slower', async () => { + await enableTestPassphrase(); + const { apis, text } = makeAtomicWriteFake(); + cryptoState.keyResolutionDelaysMs = [20, 0]; // first (older) call's key resolution is slower + + const first = writeProtectedTextFileAtomic(apis, '/app/project.json', 'first-plaintext'); + const second = writeProtectedTextFileAtomic(apis, '/app/project.json', 'second-plaintext'); + await Promise.all([first, second]); + + const onDisk = text.get('/app/project.json') as string; + expect(await unprotectTextValue(onDisk)).toBe('second-plaintext'); + }); }); describe('compressData / decompressData', () => { diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts index 9e7dd728..5204c8d2 100644 --- a/tests/unit/services/fs/fsEncryptionMigration.test.ts +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -376,4 +376,33 @@ describe('migrateAllProtectedFsData — safety', () => { await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(); }); + + // QNBS-v3: a 'set' migration running right after setupIdbEncryption() has already activated the sentinel must never throw from a routine per-file write failure — the caller has no safe partial-success state to leave the sentinel in. + it('logs and skips (does not throw) a write failure on an otherwise-migratable file, in non-strict (set) mode', async () => { + await fileSystemService.saveProject(project as never); + const originalWriteTextFile = fake.apis.writeTextFile; + fake.apis.writeTextFile = (p: string, c: string) => { + if (p.includes('project.json') && p.includes('.tmp-')) + return Promise.reject(new Error('EIO')); + return originalWriteTextFile(p, c); + }; + + const newKey = await deriveKey('first-passphrase'); + await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); + // The file is left exactly as it was before the failed write attempt — not corrupted, not stranded. + expect(fake.text.get('/app/projects/p1/project.json')).toBeDefined(); + }); + + it('propagates (does not silently skip) a write failure on an otherwise-migratable file, in strict (disable/rotate) mode', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + const originalWriteTextFile = fake.apis.writeTextFile; + fake.apis.writeTextFile = (p: string, c: string) => { + if (p.includes('project.json') && p.includes('.tmp-')) + return Promise.reject(new Error('EIO')); + return originalWriteTextFile(p, c); + }; + + await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(/EIO/); + }); }); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index b26d3f23..85e4760a 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -527,6 +527,24 @@ describe('FsSnapshotStore — snapshots', () => { expect(await store.getSnapshotData(id)).toEqual({ manuscript: [{ content: 'secret prose' }] }); }); + // QNBS-v3: distinct from the locked-session test below — this is "encryption disabled entirely" (sentinel cleared, not just the key), the state a snapshot is left in if it was created before a disable/rotate migration that (by design) never touches already-created snapshots outside the fs bridge's own reprotectSnapshotFile path. + it('returns null (fails closed, does not throw or discard the file) for a protected snapshot once at-rest encryption has been disabled', async () => { + await enableTestPassphrase(); + const id = await store.saveSnapshot('Encrypted Snapshot', { + manuscript: [{ content: 'secret prose' }], + }); + + cryptoState.activeKey = null; + cryptoState.sentinelConfigured = false; + + await expect(store.getSnapshotData(id)).resolves.toBeNull(); + // The on-disk ciphertext itself must survive this — nothing here should have deleted the file. + const onDiskFile = [...fake.text.keys()].find((k) => k.endsWith(`${id}.json`)) as string; + expect(JSON.parse(JSON.parse(fake.text.get(onDiskFile) as string).data).scheme).toBe( + 'protected-v1', + ); + }); + // QNBS-v3: a locked session is not "no snapshot" — getSnapshotData() must propagate IdbStorageLockedError, not swallow it. it('throws IdbStorageLockedError (not null) when reading a protected snapshot while the session is locked', async () => { await enableTestPassphrase(); From bbd38209cb0290762a6f2d16822fed1c7ae65d71 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:27:43 +0200 Subject: [PATCH 06/12] =?UTF-8?q?fix(desktop):=20close=20fresh=20review=20?= =?UTF-8?q?wave=20on=20PR=20#356=20=E2=80=94=20marker-clear=20timing,=20pr?= =?UTF-8?q?oject-enumeration=20bypass,=20malformed-envelope=20classificati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject a protected-v1 envelope whose data field is missing or the wrong type instead of returning the envelope shell as plaintext — a truncated/corrupted write that still parses as JSON was previously deserialized by the caller as if it were real domain data, silently corrupting in-memory state instead of surfacing the corruption. - Replace migrateAllProtectedFsData's use of the best-effort listProjects() (which swallows every readDir failure to []) with the same failure-propagating listDirEntries() helper every other directory scan in the bridge already uses — a transient permission/I/O error enumerating projects/ could otherwise skip every project/Codex/vector file while the migration still reported success. - Stop clearing the fs migration marker inside migrateAllProtectedFsData itself. For disable/rotate, an IDB-side commit (clearIdbPassphrase()/rotateIdbPassphrase()) still runs after the bridge succeeds — clearing the marker before that commit erased the only "mid-flight" signal a crash in that remaining window would leave behind. New clearFsMigrationMarker() is now called by useSettingsView.ts only once the whole operation, including that later IDB commit, has succeeded. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 15 +++++++ hooks/useSettingsView.ts | 14 ++++++- services/fs/fsCore.ts | 23 ++++++----- services/fs/fsEncryptionMigration.ts | 38 +++++++++++------- tests/unit/hooks/useSettingsView.test.ts | 39 ++++++++++++++++--- tests/unit/services/fs/fsCore.test.ts | 18 +++++++++ .../services/fs/fsEncryptionMigration.test.ts | 36 ++++++++++++++++- 7 files changed, 149 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a667c27..4e7799e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 forced quit, power loss mid-operation) now leaves a durable marker detected at next startup and surfaced as a status notification — not a full resumable migration yet, see [issue #359](https://github.com/qnbs/WorldScript-Studio/issues/359) for that tracked gap. + **Third review-loop follow-up to the same change:** a truncated or bit-corrupted write that still + parses as JSON and claims `scheme: 'protected-v1'` but has a missing/invalid `data` field + previously fell through as "not protected" and was returned as plaintext — the corresponding + store would then deserialize the envelope shell itself as domain data, silently corrupting + in-memory state instead of surfacing the corruption. Any value claiming the protected scheme now + throws unless its envelope fully validates. `migrateAllProtectedFsData` used the ordinary + best-effort `listProjects()` API (which swallows every `readDir` failure to `[]`) to enumerate + the `projects/` directory — a transient permission/I/O error there would silently skip every + project/Codex/vector file while the migration still reported success; it now uses the same + failure-propagating helper every other directory scan in the bridge already uses. The fs + migration marker is no longer cleared by the bridge itself — for disable/rotate, an IDB-side + commit (`clearIdbPassphrase()`/`rotateIdbPassphrase()`) still has to run *after* the bridge + succeeds, and clearing the marker before that commit erased the only "an operation is mid-flight" + signal a crash in that remaining window would leave behind; the caller now clears it only once + the whole operation, including that later IDB commit, has actually succeeded. ### Fixed diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index 32d428c2..32b4ce84 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -18,7 +18,10 @@ 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 { + clearFsMigrationMarker, + migrateAllProtectedFsData, +} from '../services/fs/fsEncryptionMigration'; import { logger } from '../services/logger'; import type { ProtectedStoreMigrationProgress } from '../services/storage/protectedStoreMigration'; import { @@ -391,7 +394,10 @@ export const useSettingsView = () => { if (isTauriRuntime()) { try { const key = await resolveProtectedWriteKey(); - if (key) await migrateAllProtectedFsData(key, 'set'); + if (key) { + await migrateAllProtectedFsData(key, 'set'); + await clearFsMigrationMarker(); + } } catch (error) { // QNBS-v3: migrateAllProtectedFsData('set') is non-strict and already skips per-file failures — reaching here means a pre-flight failure (e.g. the migration marker itself couldn't be written) before any file was touched, so undoing the sentinel just-created above is safe, not just cosmetic. await clearIdbPassphrase(); @@ -415,6 +421,8 @@ export const useSettingsView = () => { // 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)); + // QNBS-v3: cleared only now, not inside the bridge — a crash between the bridge succeeding and this IDB commit must still leave the marker in place to detect, since fs files are already plaintext but the sentinel isn't cleared yet. + if (isTauriRuntime()) await clearFsMigrationMarker(); } finally { setMigrationProgress(null); } @@ -434,6 +442,8 @@ export const useSettingsView = () => { await rotateIdbPassphrase(_current, newPassphrase, (progress) => setMigrationProgress(progress), ); + // QNBS-v3: cleared only now, not inside the bridge — a crash between the bridge re-keying every fs file to the new key and this IDB commit updating the sentinel must still leave the marker in place; clearing it earlier would make that exact window (new-key fs files, old-key sentinel) undetectable at next startup. + if (isTauriRuntime()) await clearFsMigrationMarker(); } finally { setMigrationProgress(null); } diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index ff29608c..65f9253d 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -182,22 +182,21 @@ interface ProtectedTextEnvelope { data: string; } +// QNBS-v3: a value that claims scheme==='protected-v1' but has an invalid/missing `data` field is corrupted ciphertext, not plaintext that happens to mention the scheme — throwing here (instead of falling through as "not protected") stops callers from deserializing the envelope shell itself as real domain data. function parseProtectedTextEnvelope(raw: string): ProtectedTextEnvelope | null { - // QNBS-v3: plaintext here is compressData()'s output (plain JSON or LZ-compressed) — a JSON.parse failure or shape mismatch just means "not protected", not an error; this is a probe, not a strict parser. + let parsed: unknown; try { - const parsed: unknown = JSON.parse(raw); - if ( - parsed !== null && - typeof parsed === 'object' && - (parsed as Record)['scheme'] === PROTECTED_TEXT_SCHEME && - typeof (parsed as Record)['data'] === 'string' - ) { - return parsed as ProtectedTextEnvelope; - } + parsed = JSON.parse(raw); } catch { - /* not JSON at all — definitely plaintext (or LZ-compressed plaintext) */ + return null; // not JSON at all — definitely plaintext (or LZ-compressed plaintext) + } + if (parsed === null || typeof parsed !== 'object') return null; + const obj = parsed as Record; + if (obj['scheme'] !== PROTECTED_TEXT_SCHEME) return null; // plain JSON content, not a protected envelope + if (typeof obj['data'] !== 'string') { + throw new Error('Malformed protected-v1 envelope: missing or invalid "data" field'); } - return null; + return obj as unknown as ProtectedTextEnvelope; } /** diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts index 21b6ddb6..851ddb2f 100644 --- a/services/fs/fsEncryptionMigration.ts +++ b/services/fs/fsEncryptionMigration.ts @@ -69,7 +69,17 @@ async function writeMigrationMarker( await writeTextFileAtomic(apis, markerPath, JSON.stringify(marker)); } -async function clearMigrationMarker(apis: TauriApis, appDataPath: string): Promise { +/** + * Clears the marker written by migrateAllProtectedFsData(). Deliberately NOT called automatically + * at the end of migrateAllProtectedFsData itself — for disable/rotate, an IDB-side commit + * (clearIdbPassphrase()/rotateIdbPassphrase()) still has to run after the fs bridge succeeds, and + * clearing this marker before that commit would erase the only "an operation is mid-flight" + * signal a process kill in that remaining window would leave behind. Callers (useSettingsView.ts) + * call this only once the ENTIRE operation — fs bridge AND any subsequent IDB commit — succeeds. + */ +export async function clearFsMigrationMarker(): Promise { + const apis = await loadTauriApis(); + const appDataPath = await apis.appDataDir(); const markerPath = await apis.join(appDataPath, 'config', MIGRATION_MARKER_FILENAME); await apis.remove(markerPath).catch(() => {}); } @@ -196,7 +206,8 @@ async function reprotectSnapshotFile( * skipped, so a partial migration can never silently strand a file at a key that's about to become * unrecoverable. For 'set', such a file is logged and left untouched instead, so an unrelated * pre-existing oddity can't block the user from enabling encryption for everything else. Writes a - * durable marker before starting and clears it only on full success — see + * durable marker before starting; the caller must call clearFsMigrationMarker() once the whole + * operation (this call AND any subsequent IDB-side commit) succeeds — see * checkForInterruptedFsMigration() and issue #359. */ export async function migrateAllProtectedFsData( @@ -244,19 +255,20 @@ export async function migrateAllProtectedFsData( }), ); - const projectIds = await fileSystemService.listProjects(); + // QNBS-v3: uses listDirEntries (not fileSystemService.listProjects(), which swallows every readDir failure to []) — a transient permission/I/O error enumerating projects/ must abort strict-mode migrations, not silently skip every project/Codex/vector file while still reporting success. + const projectsPath = await apis.join(appDataPath, 'projects'); + const projectEntries = await listDirEntries(apis, projectsPath, opts.strict); await Promise.all( - projectIds.map(async (projectId) => { - const projectDir = await apis.join(appDataPath, 'projects', projectId); - await reprotectWholeFile(apis, await apis.join(projectDir, 'project.json'), opts); - const codexDir = await apis.join(projectDir, 'codex'); - await reprotectWholeFile(apis, await apis.join(codexDir, 'codex.snap'), opts); - await reprotectWholeFile(apis, await apis.join(codexDir, 'vectors.snap'), opts); - }), + projectEntries + .filter((entry) => entry.name) + .map(async (entry) => { + const projectDir = await apis.join(projectsPath, entry.name as string); + await reprotectWholeFile(apis, await apis.join(projectDir, 'project.json'), opts); + const codexDir = await apis.join(projectDir, 'codex'); + await reprotectWholeFile(apis, await apis.join(codexDir, 'codex.snap'), opts); + await reprotectWholeFile(apis, await apis.join(codexDir, 'vectors.snap'), opts); + }), ); - - // QNBS-v3: only reached if every step above completed without throwing — a strict-mode abort or a process kill both leave the marker in place, the intended "interrupted" signal. - await clearMigrationMarker(apis, appDataPath); } // QNBS-v3: both resetAllDatabases() (storage-init-failure recovery) and wipeAllAppData() (factory diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index 9df9f04e..ebb1fa3f 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -43,6 +43,7 @@ const mockDeriveRotationTargetKey = vi.fn().mockResolvedValue('mock-target-key') const mockResolveProtectedWriteKey = vi.fn().mockResolvedValue('mock-active-key'); const mockDeriveAndVerifySourceKeyFromSentinel = vi.fn().mockResolvedValue('mock-source-key'); const mockMigrateAllProtectedFsData = vi.fn().mockResolvedValue(undefined); +const mockClearFsMigrationMarker = vi.fn().mockResolvedValue(undefined); const mockIsTauriRuntime = vi.fn(() => false); const mockSettings = { @@ -228,6 +229,7 @@ vi.mock('../../../services/storageService', () => ({ vi.mock('../../../services/fs/fsEncryptionMigration', () => ({ migrateAllProtectedFsData: (targetKey: unknown, operation: unknown) => mockMigrateAllProtectedFsData(targetKey, operation), + clearFsMigrationMarker: () => mockClearFsMigrationMarker(), })); vi.mock('../../../services/tauriRuntime', () => ({ @@ -631,6 +633,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { mockClearIdbPassphrase.mockResolvedValue(undefined); mockRotateIdbPassphrase.mockResolvedValue(undefined); mockMigrateAllProtectedFsData.mockClear().mockResolvedValue(undefined); + mockClearFsMigrationMarker.mockClear().mockResolvedValue(undefined); mockDeriveRotationTargetKey.mockClear().mockResolvedValue('mock-target-key'); mockResolveProtectedWriteKey.mockClear().mockResolvedValue('mock-active-key'); mockDeriveAndVerifySourceKeyFromSentinel.mockClear().mockResolvedValue('mock-source-key'); @@ -690,7 +693,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { expect(mockMigrateAllProtectedFsData).not.toHaveBeenCalled(); }); - it('encrypts existing fs-backed desktop data with the newly-active key on first-time setup, in the Tauri runtime', async () => { + it('encrypts existing fs-backed desktop data with the newly-active key on first-time setup, and clears the fs migration marker on success, in the Tauri runtime', async () => { mockIsTauriRuntime.mockReturnValue(true); mockResolveProtectedWriteKey.mockResolvedValue('newly-active-key'); const callOrder: string[] = []; @@ -700,6 +703,9 @@ describe('handlePassphraseConfirm — disable/rotate', () => { mockMigrateAllProtectedFsData.mockImplementation(async () => { callOrder.push('migrateAllProtectedFsData'); }); + mockClearFsMigrationMarker.mockImplementation(async () => { + callOrder.push('clearFsMigrationMarker'); + }); const { result } = renderHook(() => useSettingsView()); act(() => { @@ -710,7 +716,11 @@ describe('handlePassphraseConfirm — disable/rotate', () => { }); expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith('newly-active-key', 'set'); - expect(callOrder).toEqual(['setupIdbEncryption', 'migrateAllProtectedFsData']); + expect(callOrder).toEqual([ + 'setupIdbEncryption', + 'migrateAllProtectedFsData', + 'clearFsMigrationMarker', + ]); }); it('rolls back the just-created sentinel when the first-time-setup fs migration fails, in the Tauri runtime', async () => { @@ -838,7 +848,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { expect(mockDeriveRotationTargetKey).not.toHaveBeenCalled(); }); - it('migrates fs-backed desktop data to plaintext BEFORE clearIdbPassphrase runs, in the Tauri runtime', async () => { + it('migrates fs-backed desktop data to plaintext BEFORE clearIdbPassphrase runs, and clears the fs migration marker only AFTER it, in the Tauri runtime', async () => { mockIsTauriRuntime.mockReturnValue(true); const callOrder: string[] = []; mockMigrateAllProtectedFsData.mockImplementation(async () => { @@ -847,6 +857,9 @@ describe('handlePassphraseConfirm — disable/rotate', () => { mockClearIdbPassphrase.mockImplementation(async () => { callOrder.push('clearIdbPassphrase'); }); + mockClearFsMigrationMarker.mockImplementation(async () => { + callOrder.push('clearFsMigrationMarker'); + }); const { result } = renderHook(() => useSettingsView()); act(() => { @@ -858,10 +871,15 @@ describe('handlePassphraseConfirm — disable/rotate', () => { expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith(null, 'disable'); expect(mockDeriveRotationTargetKey).not.toHaveBeenCalled(); - expect(callOrder).toEqual(['migrateAllProtectedFsData', 'clearIdbPassphrase']); + // QNBS-v3: clearFsMigrationMarker must run LAST — clearing it any earlier would erase the only "mid-flight" signal a crash between the fs bridge and the IDB commit would leave behind. + expect(callOrder).toEqual([ + 'migrateAllProtectedFsData', + 'clearIdbPassphrase', + 'clearFsMigrationMarker', + ]); }); - it('derives the rotation target key and re-keys fs-backed desktop data BEFORE rotateIdbPassphrase runs, in the Tauri runtime', async () => { + it('derives the rotation target key and re-keys fs-backed desktop data BEFORE rotateIdbPassphrase runs, and clears the fs migration marker only AFTER it, in the Tauri runtime', async () => { mockIsTauriRuntime.mockReturnValue(true); mockDeriveRotationTargetKey.mockResolvedValue('derived-target-key'); const callOrder: string[] = []; @@ -871,6 +889,9 @@ describe('handlePassphraseConfirm — disable/rotate', () => { mockRotateIdbPassphrase.mockImplementation(async () => { callOrder.push('rotateIdbPassphrase'); }); + mockClearFsMigrationMarker.mockImplementation(async () => { + callOrder.push('clearFsMigrationMarker'); + }); const { result } = renderHook(() => useSettingsView()); act(() => { @@ -883,7 +904,12 @@ describe('handlePassphraseConfirm — disable/rotate', () => { expect(mockDeriveAndVerifySourceKeyFromSentinel).toHaveBeenCalledWith('old-pass'); expect(mockDeriveRotationTargetKey).toHaveBeenCalledWith('new-pass'); expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith('derived-target-key', 'rotate'); - expect(callOrder).toEqual(['migrateAllProtectedFsData', 'rotateIdbPassphrase']); + // QNBS-v3: clearFsMigrationMarker must run LAST — a crash between the bridge re-keying every fs file and rotateIdbPassphrase() updating the sentinel must still be detectable at next startup. + expect(callOrder).toEqual([ + 'migrateAllProtectedFsData', + 'rotateIdbPassphrase', + 'clearFsMigrationMarker', + ]); }); // QNBS-v3: a mistyped current passphrase must abort BEFORE any fs file is re-keyed — otherwise the @@ -926,6 +952,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { }); expect(mockClearIdbPassphrase).not.toHaveBeenCalled(); + expect(mockClearFsMigrationMarker).not.toHaveBeenCalled(); expect(result.current.passphraseModal).toBe('disable'); }); }); diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts index f9c85538..4a16b939 100644 --- a/tests/unit/services/fs/fsCore.test.ts +++ b/tests/unit/services/fs/fsCore.test.ts @@ -360,6 +360,24 @@ describe('protectTextValue / unprotectTextValue / writeProtectedTextFileAtomic / expect(await unprotectTextValue(lzLike)).toBe(lzLike); }); + // QNBS-v3: a truncated/corrupted write that still parses as JSON and claims scheme==='protected-v1' must never be silently loaded as if it were real domain data — throwing surfaces it as corruption instead. + it('throws on a protected-v1 envelope missing its data field, instead of returning the envelope shell as plaintext', async () => { + await expect(unprotectTextValue('{"scheme":"protected-v1"}')).rejects.toThrow( + /malformed protected-v1 envelope/i, + ); + }); + + it('throws on a protected-v1 envelope whose data field has the wrong type, instead of returning the envelope shell as plaintext', async () => { + await expect(unprotectTextValue('{"scheme":"protected-v1","data":123}')).rejects.toThrow( + /malformed protected-v1 envelope/i, + ); + }); + + it('treats ordinary domain JSON without a protected-v1 scheme as plaintext, unaffected by the malformed-envelope check', async () => { + const domainJson = '{"title":"My Novel","scheme":"not-a-real-scheme"}'; + expect(await unprotectTextValue(domainJson)).toBe(domainJson); + }); + it('throws when a protected value exists but at-rest encryption is no longer configured', async () => { await enableTestPassphrase(); const protectedValue = await protectTextValue('secret'); diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts index 5204c8d2..dc9ecd24 100644 --- a/tests/unit/services/fs/fsEncryptionMigration.test.ts +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -56,6 +56,7 @@ vi.mock('../../../../services/logger', async (importOriginal) => { import { checkForInterruptedFsMigration, + clearFsMigrationMarker, migrateAllProtectedFsData, } from '../../../../services/fs/fsEncryptionMigration'; import { fileSystemService } from '../../../../services/fs/index'; @@ -251,10 +252,16 @@ describe('migrateAllProtectedFsData — set (first-time setup)', () => { }); describe('migrateAllProtectedFsData — interrupted-migration marker', () => { - it('leaves no marker after a successful migration', async () => { + // QNBS-v3: migrateAllProtectedFsData no longer clears its own marker — for disable/rotate, an IDB-side commit still has to run after it succeeds, and clearing here would erase the only "mid-flight" signal a crash in that remaining window would leave behind (see #356's follow-up review comments); the caller clears it explicitly once the whole operation, including that later IDB commit, succeeds. + it('leaves the marker in place after a successful migration, until the caller explicitly clears it', async () => { await enableTestPassphrase(); await fileSystemService.saveProject(project as never); await migrateAllProtectedFsData(null, 'disable'); + expect(await checkForInterruptedFsMigration()).toEqual( + expect.objectContaining({ operation: 'disable' }), + ); + + await clearFsMigrationMarker(); expect(await checkForInterruptedFsMigration()).toBeNull(); }); @@ -334,6 +341,33 @@ describe('migrateAllProtectedFsData — safety', () => { await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(/EACCES/); }); + // QNBS-v3: fileSystemService.listProjects() (the ordinary, best-effort read API) swallows every + // readDir failure to [] — using it here would let a transient permission/IO error enumerating + // projects/ silently skip EVERY project/Codex/vector file while still reporting migration success. + it('propagates (does not silently skip) a failure enumerating the projects directory itself, in strict mode', async () => { + await enableTestPassphrase(); + await fileSystemService.saveProject(project as never); + const originalReadDir = fake.apis.readDir; + fake.apis.readDir = (p: string) => { + if (p === '/app/projects') return Promise.reject(new Error('EACCES')); + return originalReadDir(p); + }; + + await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(/EACCES/); + }); + + it('logs and skips (does not throw) a failure enumerating the projects directory itself, in non-strict (set) mode', async () => { + await fileSystemService.saveProject(project as never); + const originalReadDir = fake.apis.readDir; + fake.apis.readDir = (p: string) => { + if (p === '/app/projects') return Promise.reject(new Error('EACCES')); + return originalReadDir(p); + }; + + const newKey = await deriveKey('first-passphrase'); + await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); + }); + it('logs and skips (does not throw) a read failure on a file that exists, in non-strict (set) mode', async () => { await fileSystemService.saveProject(project as never); const originalReadTextFile = fake.apis.readTextFile; From b2245b603acda0cd85e7412e02f96650098d01fd Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:14:18 +0200 Subject: [PATCH 07/12] fix(settings): refresh stored keys after encryption unlock --- components/settings/AiProviderCard.tsx | 29 +++++-- components/settings/OpenRouterSection.tsx | 18 +++- hooks/useEncryptionReady.ts | 14 ++++ services/storage/storageEncryptionService.ts | 28 +++++-- tests/unit/settings/AiProviderCard.test.tsx | 84 +++++++++++++++++++ .../unit/settings/OpenRouterSection.test.tsx | 73 ++++++++++++++++ tests/unit/storageEncryptionService.test.ts | 44 ++++++++++ 7 files changed, 272 insertions(+), 18 deletions(-) create mode 100644 hooks/useEncryptionReady.ts diff --git a/components/settings/AiProviderCard.tsx b/components/settings/AiProviderCard.tsx index d62e2c64..3e8e4075 100644 --- a/components/settings/AiProviderCard.tsx +++ b/components/settings/AiProviderCard.tsx @@ -1,6 +1,7 @@ import { ONNX_SUPPORTED_MODELS, WEBLLM_SUPPORTED_MODELS } from '@domain/ai-core'; import type { FC } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEncryptionReady } from '../../hooks/useEncryptionReady'; import { useTranslation } from '../../hooks/useTranslation'; import { LOCAL_BACKEND_PRESET_DEFAULT_URL } from '../../services/ai/localBackendPresets'; import type { WebGpuAdapterInfo } from '../../services/ai/webGpuDetectorService'; @@ -85,9 +86,7 @@ interface AiProviderCardProps { onAdvancedAiPatch: (patch: Partial) => void; onProviderChange: (p: AIProvider) => void; onModelSelect?: (model: string) => void; - // QNBS-v3 (ADR-0017): opt-in feature flag — the Ollama section attempts a direct browser fetch - // instead of requiring desktop when the user has separately configured OLLAMA_ORIGINS. Default - // false so callers that don't pass it (e.g. older tests) keep today's desktop-only behavior. + // QNBS-v3 (ADR-0017): opt-in flag — Ollama section attempts a direct browser fetch instead of requiring desktop when OLLAMA_ORIGINS is configured; default false so callers omitting it keep desktop-only behavior. browserOllamaEnabled?: boolean; } @@ -212,6 +211,8 @@ export const AiProviderCard: FC = ({ isDesktop, browserOllamaEnabled, ); + // QNBS-v3: reactive to lock/unlock happening elsewhere (App.tsx's global unlock modal) — without this, keys loaded while locked stayed showing as missing until this component remounted. + const encryptionReady = useEncryptionReady(); const [openaiKey, setOpenaiKey] = useState(''); // QNBS-v3: Grok's own key input state, mirroring OpenAI's pattern above. const [grokKey, setGrokKey] = useState(''); @@ -296,20 +297,34 @@ export const AiProviderCard: FC = ({ if (provider === 'webllm') probeWebGpu(testRequestIdRef.current); }, [provider, probeWebGpu]); + // QNBS-v3: sequence guard — a lock immediately followed by an unlock starts two overlapping loads per provider; without this, the older (locked, resolves to null) load can resolve after the newer one and clear an already-reloaded key back to empty. + const keyLoadSeqRef = useRef(0); + const keyLoadReadinessRef = useRef(encryptionReady); useEffect(() => { + const seq = ++keyLoadSeqRef.current; + const readinessAtStart = encryptionReady; + keyLoadReadinessRef.current = readinessAtStart; + const isLatest = () => keyLoadSeqRef.current === seq; storageService .getApiKey('openai') - .then((k) => setOpenaiKey(k ?? '')) + .then((k) => { + if (isLatest() && keyLoadReadinessRef.current === readinessAtStart) setOpenaiKey(k ?? ''); + }) .catch(() => {}); storageService .getApiKey('grok') - .then((k) => setGrokKey(k ?? '')) + .then((k) => { + if (isLatest() && keyLoadReadinessRef.current === readinessAtStart) setGrokKey(k ?? ''); + }) .catch(() => {}); storageService .getApiKey('anthropic') - .then((k) => setAnthropicKey(k ?? '')) + .then((k) => { + if (isLatest() && keyLoadReadinessRef.current === readinessAtStart) + setAnthropicKey(k ?? ''); + }) .catch(() => {}); - }, []); + }, [encryptionReady]); // QNBS-v3: save/clear via storageService, matching every other provider's key persistence. const handleSaveGrokKey = useCallback(async () => { diff --git a/components/settings/OpenRouterSection.tsx b/components/settings/OpenRouterSection.tsx index 7677cccf..51636d52 100644 --- a/components/settings/OpenRouterSection.tsx +++ b/components/settings/OpenRouterSection.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; import { settingsActions } from '../../features/settings/settingsSlice'; import { statusActions } from '../../features/status/statusSlice'; +import { useEncryptionReady } from '../../hooks/useEncryptionReady'; import { useTranslation } from '../../hooks/useTranslation'; import { clearOpenRouterModelCache, @@ -144,6 +145,7 @@ export const OpenRouterSection: FC = () => { // local default before Redux settings load, not a primary model source. const preferredModel = openRouterSettings?.preferredModel ?? OPENROUTER_FREE_MODEL_FALLBACK[0]; + const encryptionReady = useEncryptionReady(); const [apiKeyInput, setApiKeyInput] = useState(''); const [storedKey, setStoredKey] = useState(null); const hasStoredKey = Boolean(storedKey); @@ -165,15 +167,25 @@ export const OpenRouterSection: FC = () => { // QNBS-v3: Set once the user saves/clears a key, so a slower initial getApiKey load can't resolve // afterwards and overwrite the user's newer key state with a stale value. const keyOverriddenRef = useRef(false); + const keyLoadReadinessRef = useRef(encryptionReady); - // Load stored key status on mount. + // QNBS-v3: reload stored-key status when app-wide encryption readiness changes, so global unlock does not leave a mounted section showing a saved key as missing. useEffect(() => { let cancelled = false; + const readinessAtStart = encryptionReady; + keyLoadReadinessRef.current = readinessAtStart; + // QNBS-v3: reset this run's override guard so a previous run cannot suppress a fresh readiness reload; its cancelled closure still protects stale results. + keyOverriddenRef.current = false; storageService .getApiKey('openrouter') .then((k) => { // QNBS-v3: Drop the result if the user already saved/cleared a key while this was in flight. - if (!cancelled && !keyOverriddenRef.current) setStoredKey(k); + if ( + !cancelled && + !keyOverriddenRef.current && + keyLoadReadinessRef.current === readinessAtStart + ) + setStoredKey(k); }) .catch((err) => { logger.error('OpenRouter: failed to read stored API key status', { error: String(err) }); @@ -184,7 +196,7 @@ export const OpenRouterSection: FC = () => { return () => { cancelled = true; }; - }, []); + }, [encryptionReady]); // QNBS-v3: Monotonic guard so concurrent catalog fetches are last-wins — a slower or failing // response can never overwrite the result of a newer request, and a response that resolves after diff --git a/hooks/useEncryptionReady.ts b/hooks/useEncryptionReady.ts new file mode 100644 index 00000000..54f73c7e --- /dev/null +++ b/hooks/useEncryptionReady.ts @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from 'react'; +import { + isIdbEncryptionReady, + subscribeToEncryptionReadyChanges, +} from '../services/storage/storageEncryptionService'; + +// QNBS-v3: useSyncExternalStore safely reflects the mutable active-key state across App.tsx global unlocks and Settings lock/unlock handlers without render tearing. +export function useEncryptionReady(): boolean { + return useSyncExternalStore( + subscribeToEncryptionReadyChanges, + isIdbEncryptionReady, + isIdbEncryptionReady, + ); +} diff --git a/services/storage/storageEncryptionService.ts b/services/storage/storageEncryptionService.ts index 7687326d..aff82fcd 100644 --- a/services/storage/storageEncryptionService.ts +++ b/services/storage/storageEncryptionService.ts @@ -259,6 +259,18 @@ export class StorageEncryptionService { const _svc = new StorageEncryptionService(); let _activeKey: CryptoKey | null = null; +// QNBS-v3: notifies subscribers (useEncryptionReady()) whenever _activeKey changes, so an already-mounted component (e.g. AiProviderCard's stored-key display) can react to a lock/unlock/rotate that happens elsewhere — App.tsx's global unlock modal previously had no way to signal this. +const _activeKeyListeners = new Set<() => void>(); +function setActiveKey(key: CryptoKey | null): void { + _activeKey = key; + for (const listener of _activeKeyListeners) listener(); +} +export function subscribeToEncryptionReadyChanges(listener: () => void): () => void { + _activeKeyListeners.add(listener); + return () => { + _activeKeyListeners.delete(listener); + }; +} // QNBS-v3: Caches a known-true sentinel so hot read/write paths skip an IDB round trip on every // call; safe because disable/rotate (the only ops that could make it false again) both // unconditionally throw IdbEncryptionMigrationRequiredError today (see below). @@ -321,7 +333,7 @@ export async function initIdbEncryption(passphrase: string): Promise { if (!passphrase) throw new Error('Passphrase must not be empty'); await assertNoActiveEncryptionMigration(); const salt = (await hasPassphraseSentinel()) ? getExistingSalt() : getOrCreateSalt(); - _activeKey = await _svc.deriveKey(passphrase, salt); + setActiveKey(await _svc.deriveKey(passphrase, salt)); } /** Encrypt plaintext data with the active session key. */ @@ -384,7 +396,7 @@ export async function idbDecryptWithKey(key: CryptoKey, bytes: Uint8Array): P /** Clear the in-memory key (call on tab-hide / session end). */ export function clearIdbEncryptionKey(): void { - _activeKey = null; + setActiveKey(null); // QNBS-v3: also drop the sentinel-presence cache — tests (and any future out-of-band sentinel // deletion) rely on this call to force the next hasPassphraseSentinel() back to a fresh IDB read. _sentinelPresenceCache = null; @@ -610,7 +622,7 @@ export async function setupIdbEncryption(passphrase: string): Promise { const key = await _svc.deriveKey(passphrase, salt); const blob = await _svc.encrypt(key, { v: 1 }); await savePassphraseSentinel(blob.bytes); - _activeKey = key; + setActiveKey(key); // QNBS-v3: sentinel now durably exists — update the cache immediately instead of waiting for // the next hasPassphraseSentinel() call to re-derive it from an IDB read. _sentinelPresenceCache = true; @@ -638,7 +650,7 @@ export async function verifyAndInitIdbEncryption(passphrase: string): Promise ({ useTranslation: () => ({ t: (k: string) => k, language: 'en' }), })); +const mockUseEncryptionReady = vi.fn(() => false); +vi.mock('../../../hooks/useEncryptionReady', () => ({ + useEncryptionReady: () => mockUseEncryptionReady(), +})); + vi.mock('../../../services/storageService', () => ({ storageService: { getApiKey: vi.fn().mockResolvedValue(null), @@ -83,6 +88,7 @@ function setDesktopRuntime(enabled: boolean): void { afterEach(() => { setDesktopRuntime(false); + mockUseEncryptionReady.mockReturnValue(false); vi.clearAllMocks(); }); @@ -1015,3 +1021,81 @@ describe('AiProviderCard — WebGPU status badge', () => { await waitFor(() => expect(screen.getByText('settings.ai.webllm.gpuAvailable')).toBeTruthy()); }); }); + +// ─── #355 follow-up: reactive key reload across encryption lock/unlock ────── + +describe('AiProviderCard — reactive key reload on encryption lock/unlock', () => { + const openaiAdvancedAi = { ...mockAdvancedAi, provider: 'openai' as const }; + + it('reloads provider keys from storageService when encryptionReady flips from false to true', async () => { + mockUseEncryptionReady.mockReturnValue(false); + vi.mocked(storageService.getApiKey).mockResolvedValue(null); + const { rerender } = render( + , + ); + await waitFor(() => expect(storageService.getApiKey).toHaveBeenCalledWith('openai')); + vi.mocked(storageService.getApiKey).mockClear(); + + // Simulate App.tsx's global unlock modal succeeding while Settings stays mounted. + mockUseEncryptionReady.mockReturnValue(true); + rerender( + , + ); + + await waitFor(() => { + expect(storageService.getApiKey).toHaveBeenCalledWith('openai'); + expect(storageService.getApiKey).toHaveBeenCalledWith('grok'); + expect(storageService.getApiKey).toHaveBeenCalledWith('anthropic'); + }); + }); + + it('ignores a stale locked-session key-load result that resolves after a newer unlocked reload already applied its result', async () => { + const resolvers: Array<(key: string | null) => void> = []; + vi.mocked(storageService.getApiKey).mockImplementation( + (provider: string) => + new Promise((resolve) => { + if (provider === 'openai') resolvers.push(resolve); + else resolve(null); + }), + ); + mockUseEncryptionReady.mockReturnValue(false); + const { rerender } = render( + , + ); + await waitFor(() => expect(resolvers.length).toBe(1)); + + // A newer (unlocked) load starts before the older (locked-session) one resolves. + mockUseEncryptionReady.mockReturnValue(true); + rerender( + , + ); + await waitFor(() => expect(resolvers.length).toBe(2)); + + // The newer request resolves with a real key... + resolvers[1]?.('sk-real-key'); + await waitFor(() => + expect(screen.getByLabelText('settings.ai.openaiKey')).toHaveValue('sk-real-key'), + ); + + // ...then the STALE older (locked) request resolves with null — must not clear the input. + resolvers[0]?.(null); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.getByLabelText('settings.ai.openaiKey')).toHaveValue('sk-real-key'); + }); +}); diff --git a/tests/unit/settings/OpenRouterSection.test.tsx b/tests/unit/settings/OpenRouterSection.test.tsx index ce6fca79..c959d720 100644 --- a/tests/unit/settings/OpenRouterSection.test.tsx +++ b/tests/unit/settings/OpenRouterSection.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ saveApiKey: vi.fn().mockResolvedValue(undefined), clearApiKey: vi.fn().mockResolvedValue(undefined), getApiKey: vi.fn().mockResolvedValue(null), + encryptionReady: vi.fn().mockReturnValue(false), // QNBS-v3: mutable Redux settings the useAppSelector mock reads from — the policy block now derives // from live aiMode + privacy, so tests drive it by mutating this (reset in beforeEach). settingsState: { @@ -39,6 +40,10 @@ vi.mock('../../../hooks/useTranslation', () => ({ useTranslation: () => ({ t: (k: string) => k, language: 'en' }), })); +vi.mock('../../../hooks/useEncryptionReady', () => ({ + useEncryptionReady: () => mocks.encryptionReady(), +})); + vi.mock('../../../app/hooks', () => ({ useAppDispatch: () => mocks.dispatch, useAppSelector: (selector: (state: unknown) => unknown) => @@ -196,6 +201,7 @@ describe('OpenRouterSection', () => { mocks.saveApiKey.mockResolvedValue(undefined); mocks.clearApiKey.mockResolvedValue(undefined); mocks.getApiKey.mockResolvedValue(null); + mocks.encryptionReady.mockReturnValue(false); mocks.clearCache.mockImplementation(() => undefined); mocks.resetCircuit.mockImplementation(() => undefined); mocks.isCircuitOpen.mockReturnValue(false); @@ -482,3 +488,70 @@ describe('OpenRouterSection', () => { }); }); }); + +// ─── #355 follow-up: reactive key reload across encryption lock/unlock ────── + +describe('OpenRouterSection — reactive key reload on encryption lock/unlock', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.dispatch.mockImplementation(() => undefined); + mocks.fetchModels.mockResolvedValue([]); + mocks.validateKey.mockResolvedValue({ ok: true }); + mocks.settingsState.aiMode = 'cloud'; + mocks.settingsState.privacy = { localStorageOnly: false }; + mocks.settingsState.openRouter = { + enabled: false, + preferredModel: 'deepseek/deepseek-r1:free', + }; + mocks.saveApiKey.mockResolvedValue(undefined); + mocks.clearApiKey.mockResolvedValue(undefined); + mocks.getApiKey.mockResolvedValue(null); + mocks.encryptionReady.mockReturnValue(false); + mocks.clearCache.mockImplementation(() => undefined); + mocks.resetCircuit.mockImplementation(() => undefined); + mocks.isCircuitOpen.mockReturnValue(false); + }); + + it('reloads the stored-key status when encryptionReady flips from false to true', async () => { + mocks.getApiKey.mockResolvedValue(null); + const { rerender } = render(); + await waitFor(() => expect(mocks.getApiKey).toHaveBeenCalledWith('openrouter')); + expect(screen.queryByText('settings.openRouter.clearKey')).toBeNull(); + + // Simulate App.tsx's global unlock modal succeeding while Settings stays mounted. + mocks.getApiKey.mockClear().mockResolvedValue('stored-key'); + mocks.encryptionReady.mockReturnValue(true); + rerender(); + + await waitFor(() => + expect(screen.getByText('settings.openRouter.clearKey')).toBeInTheDocument(), + ); + }); + + it('ignores a stale locked-session key-load result that resolves after a newer unlocked reload already applied its result', async () => { + const resolvers: Array<(key: string | null) => void> = []; + mocks.getApiKey.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const { rerender } = render(); + await waitFor(() => expect(resolvers.length).toBe(1)); + + mocks.encryptionReady.mockReturnValue(true); + rerender(); + await waitFor(() => expect(resolvers.length).toBe(2)); + + // The newer request resolves with a real key... + resolvers[1]?.('stored-key'); + await waitFor(() => + expect(screen.getByText('settings.openRouter.clearKey')).toBeInTheDocument(), + ); + + // ...then the STALE older (locked-session) request resolves with null — must not hide it again. + resolvers[0]?.(null); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.getByText('settings.openRouter.clearKey')).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/storageEncryptionService.test.ts b/tests/unit/storageEncryptionService.test.ts index ce69a6a4..9cfbbe0e 100644 --- a/tests/unit/storageEncryptionService.test.ts +++ b/tests/unit/storageEncryptionService.test.ts @@ -39,6 +39,7 @@ import { rotateIdbPassphrase, StorageEncryptionService, setupIdbEncryption, + subscribeToEncryptionReadyChanges, verifyAndInitIdbEncryption, } from '../../services/storage/storageEncryptionService'; @@ -138,6 +139,49 @@ describe('Module-level singleton API', () => { expect(isIdbEncryptionReady()).toBe(false); }); + // QNBS-v3: mounted provider cards subscribe so App.tsx global lock/unlock updates their stored-key state without remounting Settings. + describe('subscribeToEncryptionReadyChanges', () => { + it('notifies subscribers when the active key is set and when it is cleared', async () => { + const listener = vi.fn(); + const unsubscribe = subscribeToEncryptionReadyChanges(listener); + + await initIdbEncryption('my-passphrase'); + expect(listener).toHaveBeenCalledTimes(1); + + clearIdbEncryptionKey(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('stops notifying after unsubscribe', async () => { + const listener = vi.fn(); + const unsubscribe = subscribeToEncryptionReadyChanges(listener); + unsubscribe(); + + await initIdbEncryption('my-passphrase'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('supports multiple independent subscribers', async () => { + const listenerA = vi.fn(); + const listenerB = vi.fn(); + const unsubscribeA = subscribeToEncryptionReadyChanges(listenerA); + const unsubscribeB = subscribeToEncryptionReadyChanges(listenerB); + + await initIdbEncryption('my-passphrase'); + expect(listenerA).toHaveBeenCalledTimes(1); + expect(listenerB).toHaveBeenCalledTimes(1); + + unsubscribeA(); + clearIdbEncryptionKey(); + expect(listenerA).toHaveBeenCalledTimes(1); // unsubscribed — no second call + expect(listenerB).toHaveBeenCalledTimes(2); + + unsubscribeB(); + }); + }); + it('initIdbEncryption throws on empty passphrase', async () => { await expect(initIdbEncryption('')).rejects.toThrow('Passphrase must not be empty'); }); From 54ec14daadab3d73dc8d5f83f66c8755c6406a26 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:18:54 +0200 Subject: [PATCH 08/12] fix(storage): fail closed for filesystem read errors --- services/fs/assetFsStore.ts | 4 +- services/fs/codexFsStore.ts | 6 +-- services/fs/fsCore.ts | 38 ++++++++++++------- services/fs/projectFsStore.ts | 4 +- services/fs/settingsFsStore.ts | 3 +- services/fs/snapshotFsStore.ts | 4 +- services/storage/storageEncryptionService.ts | 9 +++++ tests/unit/services/fs/fsCore.test.ts | 28 +++++++++++++- .../services/fs/fsEncryptionMigration.test.ts | 6 +++ tests/unit/services/fs/fsStores.test.ts | 19 ++++++++++ 10 files changed, 97 insertions(+), 24 deletions(-) diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts index b756479b..81ffb844 100644 --- a/services/fs/assetFsStore.ts +++ b/services/fs/assetFsStore.ts @@ -8,7 +8,7 @@ */ import { logger } from '../logger'; -import { IdbStorageLockedError } from '../storage/storageEncryptionService'; +import { isStorageAccessError } from '../storage/storageEncryptionService'; import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend'; import { readProtectedTextFile, @@ -55,7 +55,7 @@ export class FsAssetStore extends FsSnapshotStore { return `data:image/png;base64,${base64Data}`; } catch (error) { // QNBS-v3: a locked session is not "no image" — never conflate the two. - if (error instanceof IdbStorageLockedError) throw error; + if (isStorageAccessError(error)) throw error; logger.error('Failed to load image:', error); return null; } diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts index 6a56b5b7..a5b0c9fb 100644 --- a/services/fs/codexFsStore.ts +++ b/services/fs/codexFsStore.ts @@ -7,7 +7,7 @@ import type { StoryCodex } from '../../types'; import { logger } from '../logger'; -import { IdbStorageLockedError } from '../storage/storageEncryptionService'; +import { isStorageAccessError } from '../storage/storageEncryptionService'; import { compressData, decompressData, @@ -43,7 +43,7 @@ export class FsCodexStore extends FsSettingsStore { return decompressData(content); } catch (error) { // QNBS-v3: a locked session is not "no codex" — never conflate the two. - if (error instanceof IdbStorageLockedError) throw error; + if (isStorageAccessError(error)) throw error; logger.error('Failed to load story codex:', error); return null; } @@ -85,7 +85,7 @@ export class FsCodexStore extends FsSettingsStore { return decompressData(content); } catch (error) { // QNBS-v3: a locked session is not "no vectors" — never conflate the two. - if (error instanceof IdbStorageLockedError) throw error; + if (isStorageAccessError(error)) throw error; logger.error('Failed to load RAG vectors:', error); return []; } diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 65f9253d..493ae56c 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -5,10 +5,13 @@ import LZString from 'lz-string'; import { logger } from '../logger'; +import { withProtectedWriteAdmission } from '../storage/protectedWriteAdmission'; import { + assertSecureStorageReadable, idbDecryptWithKey, idbEncryptWithKey, resolveProtectedWriteKey, + SecureRecordCorruptError, } from '../storage/storageEncryptionService'; // Dynamic imports for Tauri v2 plugin APIs — fail gracefully in browser @@ -206,11 +209,13 @@ function parseProtectedTextEnvelope(raw: string): ProtectedTextEnvelope | null { * never need to decrypt (mirrors the IDB path's own "encryption applied at the value level" design). */ export async function protectTextValue(plaintext: string): Promise { - const key = await resolveProtectedWriteKey(); - if (!key) return plaintext; - return JSON.stringify({ - scheme: PROTECTED_TEXT_SCHEME, - data: bytesToBase64(await idbEncryptWithKey(key, plaintext)), + return withProtectedWriteAdmission(async () => { + const key = await resolveProtectedWriteKey(); + if (!key) return plaintext; + return JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(key, plaintext)), + }); }); } @@ -221,14 +226,21 @@ export async function protectTextValue(plaintext: string): Promise { * back to treating it as absent. */ export async function unprotectTextValue(stored: string): Promise { - const envelope = parseProtectedTextEnvelope(stored); - if (!envelope) return stored; - const key = await resolveProtectedWriteKey(); - 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(key, base64ToBytes(envelope.data)); + return withProtectedWriteAdmission(async () => { + // QNBS-v3: gate every fs value before parsing so a configured-but-locked library never exposes legacy plaintext while a filesystem migration is active. + await assertSecureStorageReadable(); + const envelope = parseProtectedTextEnvelope(stored); + if (!envelope) return stored; + const key = await resolveProtectedWriteKey(); + if (!key) { + throw new Error('Protected value exists but at-rest encryption is no longer configured'); + } + try { + return await idbDecryptWithKey(key, base64ToBytes(envelope.data)); + } catch { + throw new SecureRecordCorruptError(); + } + }); } /** Whole-file variant of protectTextValue, for stores with no separate plaintext metadata to preserve. */ diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index d801c08d..658c82fa 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -10,7 +10,7 @@ import type { EntityState } from '@reduxjs/toolkit'; import type { Character, StoryProject, World } from '../../types'; import { logger } from '../logger'; import { parseImportedProjectJson } from '../projectImportSchema'; -import { IdbStorageLockedError } from '../storage/storageEncryptionService'; +import { isStorageAccessError } from '../storage/storageEncryptionService'; import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend'; import { FsAssetStore } from './assetFsStore'; import { @@ -102,7 +102,7 @@ export class FsProjectStore extends FsAssetStore { return decompressData(content); } catch (error) { // QNBS-v3: a locked session is not "no project" — appBootstrap.ts's Promise.all propagates this up to index.tsx's existing IdbStorageLockedError catch, which shows the unlock modal and retries boot, instead of silently hydrating as a brand-new user. - if (error instanceof IdbStorageLockedError) throw error; + if (isStorageAccessError(error)) throw error; logger.error('Failed to load project:', error); return null; } diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index c61b12da..bfa029b1 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -19,6 +19,7 @@ import { IdbStorageLockedError, idbDecryptWithKey, idbEncryptWithKey, + isStorageAccessError, resolveProtectedWriteKey, } from '../storage/storageEncryptionService'; import type { TauriApis } from './fsCore'; @@ -77,7 +78,7 @@ export class FsSettingsStore extends FsCore { return normalizePersistedSettings(parsed); } catch (error) { // QNBS-v3: a locked session is not "no settings" — propagate so appBootstrap.ts's Promise.all surfaces it to index.tsx's existing IdbStorageLockedError catch (unlock modal + retry) instead of silently hydrating defaults. - if (error instanceof IdbStorageLockedError) throw error; + if (isStorageAccessError(error)) throw error; logger.error('Failed to load settings:', error); return null; } diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts index dee8b3c0..0d0533de 100644 --- a/services/fs/snapshotFsStore.ts +++ b/services/fs/snapshotFsStore.ts @@ -5,7 +5,7 @@ import type { ProjectSnapshot } from '../../types'; import { logger } from '../logger'; -import { IdbStorageLockedError } from '../storage/storageEncryptionService'; +import { isStorageAccessError } from '../storage/storageEncryptionService'; import { FsCodexStore } from './codexFsStore'; import { compressData, @@ -73,7 +73,7 @@ export class FsSnapshotStore extends FsCodexStore { return envelope; } catch (error) { // QNBS-v3: a locked session is not "no snapshot" — never conflate the two. - if (error instanceof IdbStorageLockedError) throw error; + if (isStorageAccessError(error)) throw error; logger.error('Failed to load snapshot:', error); return null; } diff --git a/services/storage/storageEncryptionService.ts b/services/storage/storageEncryptionService.ts index aff82fcd..21769b6b 100644 --- a/services/storage/storageEncryptionService.ts +++ b/services/storage/storageEncryptionService.ts @@ -102,6 +102,15 @@ export class SecureRecordCorruptError extends Error { } } +/** True only for storage states callers must propagate instead of treating as absent data. */ +export function isStorageAccessError(error: unknown): boolean { + if (error instanceof IdbStorageLockedError || error instanceof SecureRecordCorruptError) + return true; + if (!(error instanceof Error) || !('code' in error)) return false; + const code = (error as { code?: unknown }).code; + return code === 'ENCRYPTION_MIGRATION_IN_PROGRESS' || code === 'ENCRYPTION_RECOVERY_REQUIRED'; +} + /** Raised instead of risking an incomplete cross-database disable or passphrase rotation. */ export class IdbEncryptionMigrationRequiredError extends Error { readonly code = 'ENCRYPTION_MIGRATION_REQUIRED' as const; diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts index 4a16b939..c75fc248 100644 --- a/tests/unit/services/fs/fsCore.test.ts +++ b/tests/unit/services/fs/fsCore.test.ts @@ -38,6 +38,12 @@ vi.mock('../../../../services/storage/storageEncryptionService', async (importOr return { ...actual, hasPassphraseSentinel: () => Promise.resolve(cryptoState.sentinelConfigured), + assertSecureStorageReadable: () => { + if (cryptoState.sentinelConfigured && !cryptoState.activeKey) { + return Promise.reject(new actual.IdbStorageLockedError()); + } + return Promise.resolve(cryptoState.sentinelConfigured); + }, resolveProtectedWriteKey: async () => { const delay = cryptoState.keyResolutionDelaysMs.shift(); if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); @@ -48,7 +54,10 @@ vi.mock('../../../../services/storage/storageEncryptionService', async (importOr }; }); -import { StorageEncryptionService } from '../../../../services/storage/storageEncryptionService'; +import { + SecureRecordCorruptError, + StorageEncryptionService, +} from '../../../../services/storage/storageEncryptionService'; beforeEach(() => { cryptoState.activeKey = null; @@ -393,6 +402,23 @@ describe('protectTextValue / unprotectTextValue / writeProtectedTextFileAtomic / await expect(unprotectTextValue(protectedValue)).rejects.toThrow(/storage is locked/i); }); + it('does not expose legacy plaintext while a configured library is locked', async () => { + cryptoState.sentinelConfigured = true; + + await expect(unprotectTextValue('legacy plaintext')).rejects.toThrow(/storage is locked/i); + }); + + it('reports structurally valid ciphertext with a bad authentication tag as corruption', async () => { + await enableTestPassphrase(); + const protectedValue = JSON.parse(await protectTextValue('secret')) as { data: string }; + const finalByte = protectedValue.data.endsWith('A') ? 'B' : 'A'; + protectedValue.data = `${protectedValue.data.slice(0, -1)}${finalByte}`; + + await expect(unprotectTextValue(JSON.stringify(protectedValue))).rejects.toBeInstanceOf( + SecureRecordCorruptError, + ); + }); + it('writeProtectedTextFileAtomic + readProtectedTextFile round-trip through the filesystem, encrypted', async () => { await enableTestPassphrase(); const { apis, text } = makeAtomicWriteFake(); diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts index dc9ecd24..c851ff6d 100644 --- a/tests/unit/services/fs/fsEncryptionMigration.test.ts +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -19,6 +19,12 @@ vi.mock('../../../../services/storage/storageEncryptionService', async (importOr return { ...actual, hasPassphraseSentinel: () => Promise.resolve(cryptoState.sentinelConfigured), + assertSecureStorageReadable: () => { + if (cryptoState.sentinelConfigured && !cryptoState.activeKey) { + return Promise.reject(new actual.IdbStorageLockedError()); + } + return Promise.resolve(cryptoState.sentinelConfigured); + }, resolveProtectedWriteKey: () => { if (cryptoState.activeKey) return Promise.resolve(cryptoState.activeKey); if (cryptoState.sentinelConfigured) return Promise.reject(new actual.IdbStorageLockedError()); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index 85e4760a..f9fb6f00 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -21,6 +21,12 @@ vi.mock('../../../../services/storage/storageEncryptionService', async (importOr return { ...actual, hasPassphraseSentinel: () => Promise.resolve(cryptoState.sentinelConfigured), + assertSecureStorageReadable: () => { + if (cryptoState.sentinelConfigured && !cryptoState.activeKey) { + return Promise.reject(new actual.IdbStorageLockedError()); + } + return Promise.resolve(cryptoState.sentinelConfigured); + }, resolveProtectedWriteKey: () => { if (cryptoState.activeKey) return Promise.resolve(cryptoState.activeKey); if (cryptoState.sentinelConfigured) return Promise.reject(new actual.IdbStorageLockedError()); @@ -62,6 +68,7 @@ import { FsProjectStore } from '../../../../services/fs/projectFsStore'; import { logger } from '../../../../services/logger'; import { IdbStorageLockedError, + SecureRecordCorruptError, StorageEncryptionService, } from '../../../../services/storage/storageEncryptionService'; @@ -209,6 +216,18 @@ describe('FsProjectStore — projects', () => { await expect(store.loadProject('p1')).rejects.toBeInstanceOf(IdbStorageLockedError); }); + it('propagates authenticated-ciphertext corruption instead of returning a missing project', async () => { + await enableTestPassphrase(); + await store.saveProject(project as never); + const filePath = '/app/projects/p1/project.json'; + const envelope = JSON.parse(fake.text.get(filePath) as string) as { data: string }; + const finalByte = envelope.data.endsWith('A') ? 'B' : 'A'; + envelope.data = `${envelope.data.slice(0, -1)}${finalByte}`; + fake.text.set(filePath, JSON.stringify(envelope)); + + await expect(store.loadProject('p1')).rejects.toBeInstanceOf(SecureRecordCorruptError); + }); + it('leaves project.json as plaintext when no at-rest passphrase is configured (unchanged default)', async () => { await store.saveProject(project as never); const onDisk = fake.text.get('/app/projects/p1/project.json') as string; From 21914e39b6c03d491364f304fdf1f87b6b4d81ef Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:26:14 +0200 Subject: [PATCH 09/12] fix(storage): block boot on interrupted fs migrations --- App.tsx | 27 +--- hooks/useSettingsView.ts | 21 +-- index.tsx | 4 + services/fs/fsEncryptionMigration.ts | 134 ++++++++++-------- tests/unit/hooks/useSettingsView.test.ts | 7 +- .../services/fs/fsEncryptionMigration.test.ts | 45 ++++-- 6 files changed, 119 insertions(+), 119 deletions(-) diff --git a/App.tsx b/App.tsx index dfa4ccf5..2cf2af7f 100644 --- a/App.tsx +++ b/App.tsx @@ -67,7 +67,6 @@ 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'; @@ -81,7 +80,7 @@ import { isIdbEncryptionReady, } from './services/storage/storageEncryptionService'; import { initTauriDeepLink } from './services/tauriDeepLink'; -import { applyDesktopRuntimeFlags, isTauriRuntime } from './services/tauriRuntime'; +import { applyDesktopRuntimeFlags } from './services/tauriRuntime'; import { viewNavigationLabelKey } from './services/viewNavigationLabels'; import type { View } from './types'; @@ -369,30 +368,6 @@ const App: FC = ({ isNewUser }) => { })(); }, []); - // QNBS-v3: the fs-data migration bridge has no resumable journal yet (issue #359) — surface an interrupted-migration marker honestly rather than silently proceeding as if the desktop file state is consistent. - useEffect(() => { - if (!isTauriRuntime()) return; - void (async () => { - const marker = await checkForInterruptedFsMigration(); - if (!marker) return; - const operationKey = { - set: 'settings.privacy.encryptionOperationSet', - disable: 'settings.privacy.encryptionOperationDisable', - rotate: 'settings.privacy.encryptionOperationRotate', - } as const; - dispatch( - statusActions.addNotification({ - type: 'error', - title: t('settings.privacy.encryptionMigrationInterruptedTitle'), - description: t('settings.privacy.encryptionMigrationInterruptedBody', { - operation: t(operationKey[marker.operation]), - startedAt: new Date(marker.startedAt).toLocaleString(language), - }), - }), - ); - })(); - }, [dispatch, t, language]); - // 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) diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index 32b4ce84..66beb99a 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -390,23 +390,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 would stay plaintext until its next incidental save; strict:false so a stray pre-existing oddity can't block setup for everything else. + // QNBS-v3: persist configured state before FS migration so an interrupted strict setup retains the key material required for recovery instead of orphaning already-protected files. + dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(true)); + setEncryptionReady(true); if (isTauriRuntime()) { - try { - const key = await resolveProtectedWriteKey(); - if (key) { - await migrateAllProtectedFsData(key, 'set'); - await clearFsMigrationMarker(); - } - } catch (error) { - // QNBS-v3: migrateAllProtectedFsData('set') is non-strict and already skips per-file failures — reaching here means a pre-flight failure (e.g. the migration marker itself couldn't be written) before any file was touched, so undoing the sentinel just-created above is safe, not just cosmetic. - await clearIdbPassphrase(); - setEncryptionReady(false); - throw error; + const key = await resolveProtectedWriteKey(); + if (key) { + await migrateAllProtectedFsData(key, 'set'); + await clearFsMigrationMarker(); } } - 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 toast.success(t('settings.privacy.encryptionActiveStatus')); } else if (passphraseModal === 'unlock') { diff --git a/index.tsx b/index.tsx index abdd5d72..07a2633b 100644 --- a/index.tsx +++ b/index.tsx @@ -9,8 +9,10 @@ import { I18nProvider } from './contexts/I18nContext'; import { versionControlActions } from './features/versionControl/versionControlSlice'; import { loadPersistedRootState } from './services/appBootstrap'; import { initializeStorage, resetAllDatabases } from './services/dbInitialization'; +import { assertNoInterruptedFsMigration } from './services/fs/fsEncryptionMigration'; import { logger } from './services/logger'; import { IdbStorageLockedError } from './services/storage/storageEncryptionService'; +import { isTauriRuntime } from './services/tauriRuntime'; /* ── Self-hosted fonts (@fontsource) ── */ import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; @@ -199,6 +201,8 @@ async function bootApp(): Promise { } try { + // QNBS-v3: a marker means prior FS rekey/disable/setup may be mixed-key, so hydration and autosave must remain blocked instead of treating decrypt failures as a fresh library. + if (isTauriRuntime()) await assertNoInterruptedFsMigration(); const preloadedState = await loadPersistedRootState(); const isNewUser = !preloadedState; diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts index 851ddb2f..6a8ea39f 100644 --- a/services/fs/fsEncryptionMigration.ts +++ b/services/fs/fsEncryptionMigration.ts @@ -29,8 +29,8 @@ const PROTECTED_TEXT_SCHEME = 'protected-v1'; interface MigrationOptions { targetKey: CryptoKey | null; - // QNBS-v3: strict=true (disable/rotate) aborts on any decrypt failure — the caller is about to destroy/replace the only key that could decrypt a stranded file; strict=false (first-time setup) logs and skips instead, since nothing valuable is being destroyed. - strict: boolean; + // QNBS-v3: every lifecycle operation is strict, so setup never reports complete encryption while a pre-existing file remains plaintext or unreadable. + strict: true; } // QNBS-v3: an exists() check first separates "genuinely absent" (always safe to skip — e.g. config/ not yet created on a fresh install) from "present but unreadable" (a real error that must propagate in strict mode, or be logged-and-skipped in non-strict mode — never silently treated as empty). @@ -52,11 +52,20 @@ async function listDirEntries( // QNBS-v3: no persistent per-file journal/checkpoint yet (tracked in issue #359) — a process kill mid-rotate can leave a mixed-key state; this marker can't resume/fix that but converts it into a detected one. const MIGRATION_MARKER_FILENAME = 'fs-migration-marker.json'; -interface FsMigrationMarker { +export interface FsMigrationMarker { operation: 'set' | 'disable' | 'rotate'; startedAt: string; } +export class FsMigrationInterruptedError extends Error { + readonly code = 'FS_ENCRYPTION_MIGRATION_INTERRUPTED' as const; + + constructor(readonly marker: FsMigrationMarker) { + super(`Desktop filesystem encryption ${marker.operation} migration was interrupted`); + this.name = 'FsMigrationInterruptedError'; + } +} + async function writeMigrationMarker( apis: TauriApis, appDataPath: string, @@ -81,26 +90,41 @@ export async function clearFsMigrationMarker(): Promise { const apis = await loadTauriApis(); const appDataPath = await apis.appDataDir(); const markerPath = await apis.join(appDataPath, 'config', MIGRATION_MARKER_FILENAME); - await apis.remove(markerPath).catch(() => {}); + try { + await apis.remove(markerPath); + } catch (error) { + const message = + error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + if (!message.includes('not found') && !message.includes('enoent')) throw error; + } } /** * Returns the marker left by an fs-data migration that never reached completion (crash, forced - * quit, power loss mid-operation), or null if none exists. Called once at startup - * (FsCore.initialize()) to surface an honest warning rather than silently proceeding as if - * nothing happened — see issue #359 for the real fix (a resumable, journaled migration). + * quit, power loss mid-operation), or null if none exists. Startup checks this before persisted + * state hydration, so a mixed-key state cannot be mistaken for a first-run library — see #359. */ export async function checkForInterruptedFsMigration(): Promise { - try { - const apis = await loadTauriApis(); - const appDataPath = await apis.appDataDir(); - const markerPath = await apis.join(appDataPath, 'config', MIGRATION_MARKER_FILENAME); - if (!(await apis.exists(markerPath))) return null; - const content = await apis.readTextFile(markerPath); - return JSON.parse(content) as FsMigrationMarker; - } catch { - return null; + const apis = await loadTauriApis(); + const appDataPath = await apis.appDataDir(); + const markerPath = await apis.join(appDataPath, 'config', MIGRATION_MARKER_FILENAME); + if (!(await apis.exists(markerPath))) return null; + const marker = JSON.parse(await apis.readTextFile(markerPath)) as Partial; + if ( + (marker.operation !== 'set' && + marker.operation !== 'disable' && + marker.operation !== 'rotate') || + typeof marker.startedAt !== 'string' + ) { + throw new Error('Desktop filesystem encryption migration marker is malformed'); } + return marker as FsMigrationMarker; +} + +/** Block persistence hydration until a previously interrupted filesystem migration is recovered. */ +export async function assertNoInterruptedFsMigration(): Promise { + const marker = await checkForInterruptedFsMigration(); + if (marker) throw new FsMigrationInterruptedError(marker); } /** @@ -201,20 +225,15 @@ async function reprotectSnapshotFile( /** * Converges every fs-backed protected file to targetKey (first-time setup or rotate) or to - * plaintext (targetKey=null, disable). For 'disable'/'rotate', any positively-identified protected - * file that fails to decrypt under the current session key throws immediately rather than being - * skipped, so a partial migration can never silently strand a file at a key that's about to become - * unrecoverable. For 'set', such a file is logged and left untouched instead, so an unrelated - * pre-existing oddity can't block the user from enabling encryption for everything else. Writes a - * durable marker before starting; the caller must call clearFsMigrationMarker() once the whole - * operation (this call AND any subsequent IDB-side commit) succeeds — see - * checkForInterruptedFsMigration() and issue #359. + * plaintext (targetKey=null, disable). Every operation is strict: a read, decrypt, enumeration, or + * write failure leaves the durable marker in place and reports failure rather than claiming complete + * desktop protection. The caller clears the marker only after the subsequent IDB lifecycle commit. */ export async function migrateAllProtectedFsData( targetKey: CryptoKey | null, operation: FsMigrationMarker['operation'], ): Promise { - const opts: MigrationOptions = { targetKey, strict: operation !== 'set' }; + const opts: MigrationOptions = { targetKey, strict: true }; const apis = await loadTauriApis(); const appDataPath = await apis.appDataDir(); @@ -222,53 +241,44 @@ export async function migrateAllProtectedFsData( const configPath = await apis.join(appDataPath, 'config'); const configEntries = await listDirEntries(apis, configPath, opts.strict); - await Promise.all( - configEntries.map(async (entry) => { - if (!entry.name || entry.isDirectory) return; - if (entry.name === 'settings.json') { - const entryPath = await apis.join(configPath, entry.name); - await reprotectWholeFile(apis, entryPath, opts); - } else if (entry.name.endsWith('_key.enc.json')) { - const provider = entry.name.slice(0, -'_key.enc.json'.length); - await fileSystemService.reprotectApiKeyFile(provider, opts.targetKey, opts.strict); - } - }), - ); + for (const entry of configEntries) { + if (!entry.name || entry.isDirectory) continue; + if (entry.name === 'settings.json') { + const entryPath = await apis.join(configPath, entry.name); + await reprotectWholeFile(apis, entryPath, opts); + } else if (entry.name.endsWith('_key.enc.json')) { + const provider = entry.name.slice(0, -'_key.enc.json'.length); + await fileSystemService.reprotectApiKeyFile(provider, opts.targetKey, opts.strict); + } + } const snapshotsPath = await apis.join(appDataPath, 'snapshots'); const snapshotEntries = await listDirEntries(apis, snapshotsPath, opts.strict); - await Promise.all( - snapshotEntries.map(async (entry) => { - if (!entry.name?.endsWith('.json')) return; - const filePath = await apis.join(snapshotsPath, entry.name); - await reprotectSnapshotFile(apis, filePath, opts); - }), - ); + for (const entry of snapshotEntries) { + if (!entry.name?.endsWith('.json')) continue; + const filePath = await apis.join(snapshotsPath, entry.name); + await reprotectSnapshotFile(apis, filePath, opts); + } const imagesPath = await apis.join(appDataPath, 'images'); const imageEntries = await listDirEntries(apis, imagesPath, opts.strict); - await Promise.all( - imageEntries.map(async (entry) => { - if (!entry.name?.endsWith('.png')) return; - const filePath = await apis.join(imagesPath, entry.name); - await reprotectWholeFile(apis, filePath, opts); - }), - ); + for (const entry of imageEntries) { + if (!entry.name?.endsWith('.png')) continue; + const filePath = await apis.join(imagesPath, entry.name); + await reprotectWholeFile(apis, filePath, opts); + } // QNBS-v3: uses listDirEntries (not fileSystemService.listProjects(), which swallows every readDir failure to []) — a transient permission/I/O error enumerating projects/ must abort strict-mode migrations, not silently skip every project/Codex/vector file while still reporting success. const projectsPath = await apis.join(appDataPath, 'projects'); const projectEntries = await listDirEntries(apis, projectsPath, opts.strict); - await Promise.all( - projectEntries - .filter((entry) => entry.name) - .map(async (entry) => { - const projectDir = await apis.join(projectsPath, entry.name as string); - await reprotectWholeFile(apis, await apis.join(projectDir, 'project.json'), opts); - const codexDir = await apis.join(projectDir, 'codex'); - await reprotectWholeFile(apis, await apis.join(codexDir, 'codex.snap'), opts); - await reprotectWholeFile(apis, await apis.join(codexDir, 'vectors.snap'), opts); - }), - ); + for (const entry of projectEntries) { + if (!entry.name) continue; + const projectDir = await apis.join(projectsPath, entry.name); + await reprotectWholeFile(apis, await apis.join(projectDir, 'project.json'), opts); + const codexDir = await apis.join(projectDir, 'codex'); + await reprotectWholeFile(apis, await apis.join(codexDir, 'codex.snap'), opts); + await reprotectWholeFile(apis, await apis.join(codexDir, 'vectors.snap'), opts); + } } // QNBS-v3: both resetAllDatabases() (storage-init-failure recovery) and wipeAllAppData() (factory diff --git a/tests/unit/hooks/useSettingsView.test.ts b/tests/unit/hooks/useSettingsView.test.ts index ebb1fa3f..a340b7d2 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -723,7 +723,7 @@ describe('handlePassphraseConfirm — disable/rotate', () => { ]); }); - it('rolls back the just-created sentinel when the first-time-setup fs migration fails, in the Tauri runtime', async () => { + it('preserves the just-created sentinel when strict first-time setup fails in the Tauri runtime', async () => { mockIsTauriRuntime.mockReturnValue(true); mockResolveProtectedWriteKey.mockResolvedValue('newly-active-key'); mockMigrateAllProtectedFsData.mockRejectedValueOnce(new Error('marker write failed')); @@ -738,9 +738,8 @@ describe('handlePassphraseConfirm — disable/rotate', () => { ); }); - // QNBS-v3: no progress callback here — this is a rollback of setupIdbEncryption(), not the disable branch's user-facing migration. - expect(mockClearIdbPassphrase).toHaveBeenCalledWith(undefined); - expect(mockDispatch).not.toHaveBeenCalledWith( + expect(mockClearIdbPassphrase).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'featureFlags/setEnableIdbAtRestEncryption', payload: true, diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts index c851ff6d..7c7c1f26 100644 --- a/tests/unit/services/fs/fsEncryptionMigration.test.ts +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -61,8 +61,10 @@ vi.mock('../../../../services/logger', async (importOriginal) => { }); import { + assertNoInterruptedFsMigration, checkForInterruptedFsMigration, clearFsMigrationMarker, + FsMigrationInterruptedError, migrateAllProtectedFsData, } from '../../../../services/fs/fsEncryptionMigration'; import { fileSystemService } from '../../../../services/fs/index'; @@ -238,9 +240,9 @@ describe('migrateAllProtectedFsData — set (first-time setup)', () => { expect(await fileSystemService.getApiKey('gemini')).toBe('secret-key-123'); }); - it('skips (does not throw or discard) a file it cannot decrypt, unlike disable/rotate', async () => { - // Simulate a stray already-protected file left over from a previous, unrelated encryption - // session — the exact edge case 'set' must tolerate rather than abort on. + it('rejects instead of reporting successful setup when an existing file cannot decrypt', async () => { + // Simulate a stray already-protected file from a prior session: success would falsely claim + // every existing desktop file is protected by the new passphrase. await enableTestPassphrase(); await fileSystemService.saveProject(project as never); cryptoState.activeKey = null; @@ -251,8 +253,8 @@ describe('migrateAllProtectedFsData — set (first-time setup)', () => { cryptoState.activeKey = newKey; cryptoState.sentinelConfigured = true; - await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); - // Left untouched — the new key can't decrypt it, and 'set' must not destroy or crash on that. + await expect(migrateAllProtectedFsData(newKey, 'set')).rejects.toThrow(); + // It remains untouched and the caller retains recovery metadata. expect(fake.text.get('/app/projects/p1/project.json')).toBe(before); }); }); @@ -284,6 +286,24 @@ describe('migrateAllProtectedFsData — interrupted-migration marker', () => { it('reports no marker when none exists', async () => { expect(await checkForInterruptedFsMigration()).toBeNull(); + await expect(assertNoInterruptedFsMigration()).resolves.toBeUndefined(); + }); + + it('blocks startup hydration when a valid interrupted-migration marker exists', async () => { + fake.text.set( + '/app/config/fs-migration-marker.json', + JSON.stringify({ operation: 'rotate', startedAt: '2026-08-13T12:00:00.000Z' }), + ); + + await expect(assertNoInterruptedFsMigration()).rejects.toBeInstanceOf( + FsMigrationInterruptedError, + ); + }); + + it('propagates a marker-clear I/O failure instead of falsely reporting recovery complete', async () => { + fake.apis.remove = () => Promise.reject(new Error('EACCES marker')); + + await expect(clearFsMigrationMarker()).rejects.toThrow('EACCES marker'); }); }); @@ -399,14 +419,13 @@ describe('migrateAllProtectedFsData — safety', () => { await expect(migrateAllProtectedFsData(newKey, 'rotate')).rejects.toThrow(/anthropic/); }); - // QNBS-v3: a malformed API-key file must never strand setupIdbEncryption()'s already-activated - // sentinel/key — non-strict (first-time setup) must swallow even a synchronous JSON.parse throw. - it('does not throw when an API key file contains malformed JSON, in non-strict (set) mode', async () => { + // QNBS-v3: strict setup must preserve a recoverable pending state instead of claiming complete protection when an existing API-key file is malformed. + it('throws when an API key file contains malformed JSON during first-time setup', async () => { await fake.apis.mkdir('/app/config'); fake.text.set('/app/config/openai_key.enc.json', '{not valid json'); const newKey = await deriveKey('first-passphrase'); - await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); + await expect(migrateAllProtectedFsData(newKey, 'set')).rejects.toThrow(); }); it('throws when an API key file contains malformed JSON, in strict (disable/rotate) mode', async () => { @@ -417,8 +436,8 @@ describe('migrateAllProtectedFsData — safety', () => { await expect(migrateAllProtectedFsData(null, 'disable')).rejects.toThrow(); }); - // QNBS-v3: a 'set' migration running right after setupIdbEncryption() has already activated the sentinel must never throw from a routine per-file write failure — the caller has no safe partial-success state to leave the sentinel in. - it('logs and skips (does not throw) a write failure on an otherwise-migratable file, in non-strict (set) mode', async () => { + // QNBS-v3: setup must stop before success when a file cannot be atomically rewritten, leaving the marker and passphrase metadata available for recovery. + it('throws on a write failure during first-time setup', async () => { await fileSystemService.saveProject(project as never); const originalWriteTextFile = fake.apis.writeTextFile; fake.apis.writeTextFile = (p: string, c: string) => { @@ -428,8 +447,8 @@ describe('migrateAllProtectedFsData — safety', () => { }; const newKey = await deriveKey('first-passphrase'); - await expect(migrateAllProtectedFsData(newKey, 'set')).resolves.toBeUndefined(); - // The file is left exactly as it was before the failed write attempt — not corrupted, not stranded. + await expect(migrateAllProtectedFsData(newKey, 'set')).rejects.toThrow('EIO'); + // The file is left exactly as it was before the failed write attempt, with the marker retained. expect(fake.text.get('/app/projects/p1/project.json')).toBeDefined(); }); From 4bc0a6d538be945992f8c428f6e7d24904321e6e Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:29:10 +0200 Subject: [PATCH 10/12] fix(storage): migrate legacy filesystem snapshots --- services/fs/fsEncryptionMigration.ts | 19 ++++++++++++++++--- .../services/fs/fsEncryptionMigration.test.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts index 6a8ea39f..893c6831 100644 --- a/services/fs/fsEncryptionMigration.ts +++ b/services/fs/fsEncryptionMigration.ts @@ -18,6 +18,7 @@ import { logger } from '../logger'; import { idbEncryptWithKey } from '../storage/storageEncryptionService'; import { bytesToBase64, + compressData, loadTauriApis, type TauriApis, unprotectTextValue, @@ -195,10 +196,22 @@ async function reprotectSnapshotFile( let envelope: SnapshotEnvelopeShape; try { envelope = JSON.parse(raw) as SnapshotEnvelopeShape; - } catch { - return; // legacy raw-project-data snapshot format predates the envelope — never protected + } catch (error) { + if (opts.strict) throw error; + logger.warn(`Skipping ${path} — snapshot is not valid JSON:`, error); + return; + } + + if (typeof envelope.data !== 'string') { + if (!opts.targetKey) return; // Legacy raw snapshots are already in the requested plaintext state. + // QNBS-v3: raw pre-envelope snapshots contain whole project data, so wrap compressed project JSON in the current value-level envelope instead of leaving historical manuscripts plaintext. + const encryptedLegacyData = JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(opts.targetKey, compressData(envelope))), + }); + await writeTextFileAtomic(apis, path, JSON.stringify({ data: encryptedLegacyData })); + return; } - if (typeof envelope.data !== 'string') return; const originalData = envelope.data; let plaintext: string; try { diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts index 7c7c1f26..dd8b1f7a 100644 --- a/tests/unit/services/fs/fsEncryptionMigration.test.ts +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -257,6 +257,20 @@ describe('migrateAllProtectedFsData — set (first-time setup)', () => { // It remains untouched and the caller retains recovery metadata. expect(fake.text.get('/app/projects/p1/project.json')).toBe(before); }); + + it('wraps and encrypts a legacy raw-project snapshot during first-time setup', async () => { + fake.text.set('/app/snapshots/1.json', JSON.stringify(project)); + const newKey = await deriveKey('first-passphrase'); + + await migrateAllProtectedFsData(newKey, 'set'); + + const migrated = fake.text.get('/app/snapshots/1.json') as string; + expect(migrated).toContain('protected-v1'); + expect(migrated).not.toContain('hello world'); + cryptoState.activeKey = newKey; + cryptoState.sentinelConfigured = true; + await expect(fileSystemService.getSnapshotData(1)).resolves.toEqual(project); + }); }); describe('migrateAllProtectedFsData — interrupted-migration marker', () => { From 3cf8c5e219728dfd7a6c1167b4cc4017fcfe1a20 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:33:33 +0200 Subject: [PATCH 11/12] fix(storage): retain write admission through fs commit --- README.md | 4 +-- services/fs/fsCore.ts | 40 +++++++++++++++++++-------- services/fs/settingsFsStore.ts | 23 +++++++-------- services/fs/snapshotFsStore.ts | 8 +++--- tests/unit/services/fs/fsCore.test.ts | 32 +++++++++++++++++++++ 5 files changed, 78 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e81c3aa7..6d2cb5bd 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,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. +- Disable and passphrase rotation are available from Settings. IndexedDB uses its journal-backed migration protocol; desktop filesystem data is migrated before the shared key transition. An interrupted desktop filesystem migration blocks hydration for recovery rather than being treated as an empty library, but it is not yet crash-resumable (tracked in #359). - **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. - 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. @@ -333,7 +333,7 @@ different data, with different key material: |------|-----------|-------| | **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** | When at-rest encryption is configured and unlocked: user passphrase → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM over `{provider, apiKey}`. Without a configured at-rest passphrase, the key is deliberately stored as plaintext rather than under reconstructible pseudo-secret material. | `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` | diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 493ae56c..69452144 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -141,6 +141,17 @@ async function writeThenRename( await atomicRename(apis, tmpPath, finalPath); } +async function writeTextFileAtomicUnqueued( + apis: TauriApis, + path: string, + content: string, +): Promise { + const tmpPath = `${path}.tmp-${createTempSuffix()}`; + await writeThenRename(apis, tmpPath, path, () => + retryFs(() => apis.writeTextFile(tmpPath, content)), + ); +} + // QNBS-v3: takes a content-producer, not a value, so writeProtectedTextFileAtomic can enqueue BEFORE encrypting — otherwise two overlapping saves race on which one finishes encrypting first, letting an older save's slower encryption land last in the queue and overwrite a newer save's plaintext write. function enqueueTextFileWrite( apis: TauriApis, @@ -149,10 +160,7 @@ function enqueueTextFileWrite( ): Promise { return enqueueWrite(path, async () => { const content = await getContent(); - const tmpPath = `${path}.tmp-${createTempSuffix()}`; - await writeThenRename(apis, tmpPath, path, () => - retryFs(() => apis.writeTextFile(tmpPath, content)), - ); + await writeTextFileAtomicUnqueued(apis, path, content); }); } @@ -209,13 +217,15 @@ function parseProtectedTextEnvelope(raw: string): ProtectedTextEnvelope | null { * never need to decrypt (mirrors the IDB path's own "encryption applied at the value level" design). */ export async function protectTextValue(plaintext: string): Promise { - return withProtectedWriteAdmission(async () => { - const key = await resolveProtectedWriteKey(); - if (!key) return plaintext; - return JSON.stringify({ - scheme: PROTECTED_TEXT_SCHEME, - data: bytesToBase64(await idbEncryptWithKey(key, plaintext)), - }); + return withProtectedWriteAdmission(() => protectTextValueWithinAdmission(plaintext)); +} + +async function protectTextValueWithinAdmission(plaintext: string): Promise { + const key = await resolveProtectedWriteKey(); + if (!key) return plaintext; + return JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(key, plaintext)), }); } @@ -248,8 +258,14 @@ export function writeProtectedTextFileAtomic( apis: TauriApis, path: string, plaintext: string, + formatContent: (protectedValue: string) => string = (protectedValue) => protectedValue, ): Promise { - return enqueueTextFileWrite(apis, path, () => protectTextValue(plaintext)); + return enqueueWrite(path, () => + withProtectedWriteAdmission(async () => { + const protectedValue = await protectTextValueWithinAdmission(plaintext); + await writeTextFileAtomicUnqueued(apis, path, formatContent(protectedValue)); + }), + ); } /** Whole-file variant of unprotectTextValue, for stores with no separate plaintext metadata to preserve. */ diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index bfa029b1..ae33340e 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -15,6 +15,7 @@ import { statusActions } from '../../features/status/statusSlice'; import type { Settings } from '../../types'; import { logger } from '../logger'; import { normalizePersistedSettings } from '../storage/idbProjectStore'; +import { withProtectedWriteAdmission } from '../storage/protectedWriteAdmission'; import { IdbStorageLockedError, idbDecryptWithKey, @@ -109,18 +110,18 @@ export class FsSettingsStore extends FsCore { const configPath = await apis.join(appDataPath, 'config'); if (!(await apis.exists(configPath))) await apis.mkdir(configPath, { recursive: true }); - // QNBS-v3: resolveProtectedWriteKey() throws IdbStorageLockedError when configured-but-locked — propagated deliberately (fail closed) rather than silently falling back to plaintext, matching the existing IDB protected-write policy this reuses. - const key = await resolveProtectedWriteKey(); - const payload: ProtectedApiKeyPayload | PlaintextApiKeyPayload = key - ? { - scheme: PROTECTED_SCHEME, - // QNBS-v3: encrypts {provider, apiKey} together (not just the bare key) so a ciphertext swapped between two providers' files decrypts but fails the provider check below, instead of silently handing one provider's key to another. - data: bytesToBase64(await idbEncryptWithKey(key, { provider, apiKey: apiKey.trim() })), - } - : { scheme: PLAINTEXT_SCHEME, value: apiKey.trim() }; - const filePath = await apis.join(configPath, `${provider}_key.enc.json`); - await writeTextFileAtomic(apis, filePath, JSON.stringify(payload)); + await withProtectedWriteAdmission(async () => { + // QNBS-v3: keep key resolution, encryption, and atomic publication in one shared admission so a lifecycle transition cannot commit after this write has captured the outgoing key. + const key = await resolveProtectedWriteKey(); + const payload: ProtectedApiKeyPayload | PlaintextApiKeyPayload = key + ? { + scheme: PROTECTED_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(key, { provider, apiKey: apiKey.trim() })), + } + : { scheme: PLAINTEXT_SCHEME, value: apiKey.trim() }; + await writeTextFileAtomic(apis, filePath, JSON.stringify(payload)); + }); } private async readProtectedApiKey(provider: string, base64Data: string): Promise { diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts index 0d0533de..7a76df0e 100644 --- a/services/fs/snapshotFsStore.ts +++ b/services/fs/snapshotFsStore.ts @@ -11,10 +11,8 @@ import { compressData, countProjectWords, decompressData, - protectTextValue, retryFs, unprotectTextValue, - writeTextFileAtomic, } from './fsCore'; // Envelope stored in each snapshot file — outer shell is plain JSON (id/name/date/wordCount stay @@ -45,11 +43,13 @@ export class FsSnapshotStore extends FsCodexStore { name: snapshotLabel, date: new Date().toISOString(), wordCount: countProjectWords(data), - data: await protectTextValue(compressData(data)), + data: '', }; const snapshotFile = await apis.join(snapshotsPath, `${id}.json`); // QNBS-v3: atomic write — a crash/power-loss mid-write must never leave a snapshot truncated. - await writeTextFileAtomic(apis, snapshotFile, JSON.stringify(envelope)); + await writeProtectedTextFileAtomic(apis, snapshotFile, compressData(data), (protectedData) => + JSON.stringify({ ...envelope, data: protectedData }), + ); return id; } diff --git a/tests/unit/services/fs/fsCore.test.ts b/tests/unit/services/fs/fsCore.test.ts index c75fc248..d3e5121a 100644 --- a/tests/unit/services/fs/fsCore.test.ts +++ b/tests/unit/services/fs/fsCore.test.ts @@ -54,6 +54,7 @@ vi.mock('../../../../services/storage/storageEncryptionService', async (importOr }; }); +import { withMigrationAdmission } from '../../../../services/storage/protectedWriteAdmission'; import { SecureRecordCorruptError, StorageEncryptionService, @@ -434,6 +435,37 @@ describe('protectTextValue / unprotectTextValue / writeProtectedTextFileAtomic / expect(text.get('/app/project.json')).toBe('{"title":"My Novel"}'); }); + it('holds write admission through atomic publication so a migration cannot overtake the captured key', async () => { + await enableTestPassphrase(); + const { apis, text } = makeAtomicWriteFake(); + let startWrite: (() => void) | undefined; + let releaseWrite: (() => void) | undefined; + const writeStarted = new Promise((resolve) => { + startWrite = resolve; + }); + const allowWrite = new Promise((resolve) => { + releaseWrite = resolve; + }); + apis.writeTextFile = async (path, content) => { + startWrite?.(); + await allowWrite; + text.set(path, content); + }; + + const write = writeProtectedTextFileAtomic(apis, '/app/project.json', '{"title":"My Novel"}'); + await writeStarted; + let migrationEntered = false; + const migration = withMigrationAdmission(async () => { + migrationEntered = true; + }); + await Promise.resolve(); + expect(migrationEntered).toBe(false); + + releaseWrite?.(); + await Promise.all([write, migration]); + expect(migrationEntered).toBe(true); + }); + // QNBS-v3: an older call's key-resolution step used to run OUTSIDE the per-path write queue, so a slower-to-encrypt older save could land in the queue after a faster-to-encrypt newer save and overwrite it — regression test for that ordering gap. it('serializes concurrent protected writes to the same path in call order, even when the OLDER call resolves its key slower', async () => { await enableTestPassphrase(); From 1b1c7fd21a077e909882136ebb7f0bb42a0021f3 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:18:08 +0200 Subject: [PATCH 12/12] fix(storage): coordinate desktop key transitions --- hooks/useSettingsView.ts | 57 ++++++++++++------- services/fs/fsCore.ts | 52 +++++++++++++++++ services/fs/fsEncryptionMigration.ts | 55 ++++++++++++------ services/fs/settingsFsStore.ts | 28 +++++++-- .../encryptionMigrationOrchestrator.ts | 12 +++- services/storage/protectedStoreMigration.ts | 14 ++++- services/storage/storageEncryptionService.ts | 13 +++++ 7 files changed, 182 insertions(+), 49 deletions(-) diff --git a/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index 66beb99a..869df091 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -24,6 +24,7 @@ import { } from '../services/fs/fsEncryptionMigration'; import { logger } from '../services/logger'; import type { ProtectedStoreMigrationProgress } from '../services/storage/protectedStoreMigration'; +import { withMigrationAdmission } from '../services/storage/protectedWriteAdmission'; import { clearIdbEncryptionKey, clearIdbPassphrase, @@ -388,18 +389,18 @@ export const useSettingsView = () => { const handlePassphraseConfirm = useCallback( async (_current: string, newPassphrase: string) => { if (passphraseModal === 'set') { - // QNBS-v3: setupIdbEncryption derives key, writes sentinel to IDB, sets _activeKey - await setupIdbEncryption(newPassphrase); - // QNBS-v3: persist configured state before FS migration so an interrupted strict setup retains the key material required for recovery instead of orphaning already-protected files. - dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(true)); - setEncryptionReady(true); - if (isTauriRuntime()) { + const setup = async () => { + await setupIdbEncryption(newPassphrase); + dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(true)); + setEncryptionReady(true); const key = await resolveProtectedWriteKey(); if (key) { await migrateAllProtectedFsData(key, 'set'); await clearFsMigrationMarker(); } - } + }; + if (isTauriRuntime()) await withMigrationAdmission(setup); + else await setup(); // QNBS-v3: WCAG 4.1.3 — toast confirms success for keyboard/AT users who can't see status text toast.success(t('settings.privacy.encryptionActiveStatus')); } else if (passphraseModal === 'unlock') { @@ -411,11 +412,18 @@ 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)); - // QNBS-v3: cleared only now, not inside the bridge — a crash between the bridge succeeding and this IDB commit must still leave the marker in place to detect, since fs files are already plaintext but the sentinel isn't cleared yet. - if (isTauriRuntime()) await clearFsMigrationMarker(); + const disable = async () => { + const sourceKey = await resolveProtectedWriteKey(); + if (!sourceKey) + throw new Error('Encryption must be unlocked before it can be disabled'); + await migrateAllProtectedFsData(null, 'disable', sourceKey); + await clearIdbPassphrase((progress) => setMigrationProgress(progress), { + alreadyHasExclusiveAdmission: true, + }); + await clearFsMigrationMarker(); + }; + if (isTauriRuntime()) await withMigrationAdmission(disable); + else await clearIdbPassphrase((progress) => setMigrationProgress(progress)); } finally { setMigrationProgress(null); } @@ -426,17 +434,24 @@ export const useSettingsView = () => { 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()) { - // QNBS-v3: verify _current against the durable sentinel BEFORE mutating any fs file — otherwise a mistyped current passphrase lets the bridge re-key everything to the new key while rotateIdbPassphrase() below then rejects (wrong _current) and never activates that key, stranding fs data under a key the active session never adopts. - await deriveAndVerifySourceKeyFromSentinel(_current); + const rotate = async () => { + const sourceKey = await deriveAndVerifySourceKeyFromSentinel(_current); const targetKey = await deriveRotationTargetKey(newPassphrase); - await migrateAllProtectedFsData(targetKey, 'rotate'); + await migrateAllProtectedFsData(targetKey, 'rotate', sourceKey); + await rotateIdbPassphrase( + _current, + newPassphrase, + (progress) => setMigrationProgress(progress), + { alreadyHasExclusiveAdmission: true }, + ); + await clearFsMigrationMarker(); + }; + if (isTauriRuntime()) await withMigrationAdmission(rotate); + else { + await rotateIdbPassphrase(_current, newPassphrase, (progress) => + setMigrationProgress(progress), + ); } - await rotateIdbPassphrase(_current, newPassphrase, (progress) => - setMigrationProgress(progress), - ); - // QNBS-v3: cleared only now, not inside the bridge — a crash between the bridge re-keying every fs file to the new key and this IDB commit updating the sentinel must still leave the marker in place; clearing it earlier would make that exact window (new-key fs files, old-key sentinel) undetectable at next startup. - if (isTauriRuntime()) await clearFsMigrationMarker(); } finally { setMigrationProgress(null); } diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index 69452144..763abd3d 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -193,6 +193,8 @@ interface ProtectedTextEnvelope { data: string; } +export type FsEncryptionMigrationOperation = 'set' | 'disable' | 'rotate'; + // QNBS-v3: a value that claims scheme==='protected-v1' but has an invalid/missing `data` field is corrupted ciphertext, not plaintext that happens to mention the scheme — throwing here (instead of falling through as "not protected") stops callers from deserializing the envelope shell itself as real domain data. function parseProtectedTextEnvelope(raw: string): ProtectedTextEnvelope | null { let parsed: unknown; @@ -229,6 +231,56 @@ async function protectTextValueWithinAdmission(plaintext: string): Promise { + const envelope = parseProtectedTextEnvelope(stored); + if (!envelope) { + if (operation === 'disable') return stored; + if (!targetKey) throw new Error('Filesystem encryption migration is missing its target key'); + return JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(targetKey, stored)), + }); + } + + const decryptWith = async (key: CryptoKey): Promise => { + try { + return await idbDecryptWithKey(key, base64ToBytes(envelope.data)); + } catch { + throw new SecureRecordCorruptError(); + } + }; + if (operation === 'disable') { + if (!sourceKey) throw new Error('Filesystem encryption migration is missing its source key'); + return decryptWith(sourceKey); + } + if (!targetKey) throw new Error('Filesystem encryption migration is missing its target key'); + if (operation === 'set') { + // A protected file during first enable cannot be attributed safely without its prior key. + await decryptWith(targetKey); + return stored; + } + try { + await decryptWith(targetKey); + return stored; // Replay-safe after an interruption between publication and checkpointing. + } catch (targetError) { + if (!sourceKey) throw targetError; + const plaintext = await decryptWith(sourceKey); + return JSON.stringify({ + scheme: PROTECTED_TEXT_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(targetKey, plaintext)), + }); + } +} + /** * Reverse of protectTextValue — transparently handles either a protected envelope or plain * (legacy/unprotected) text. Propagates IdbStorageLockedError when the value is protected but the diff --git a/services/fs/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts index 893c6831..a85f0118 100644 --- a/services/fs/fsEncryptionMigration.ts +++ b/services/fs/fsEncryptionMigration.ts @@ -15,13 +15,13 @@ */ import { logger } from '../logger'; -import { idbEncryptWithKey } from '../storage/storageEncryptionService'; +import { idbEncryptWithKey, resolveProtectedWriteKey } from '../storage/storageEncryptionService'; import { bytesToBase64, compressData, loadTauriApis, + migrateProtectedTextValue, type TauriApis, - unprotectTextValue, writeTextFileAtomic, } from './fsCore'; import { fileSystemService } from './index'; @@ -29,6 +29,8 @@ import { fileSystemService } from './index'; const PROTECTED_TEXT_SCHEME = 'protected-v1'; interface MigrationOptions { + operation: FsMigrationMarker['operation']; + sourceKey?: CryptoKey; targetKey: CryptoKey | null; // QNBS-v3: every lifecycle operation is strict, so setup never reports complete encryption while a pre-existing file remains plaintext or unreadable. strict: true; @@ -152,18 +154,18 @@ async function reprotectWholeFile( } let plaintext: string; try { - plaintext = await unprotectTextValue(raw); + plaintext = await migrateProtectedTextValue( + raw, + opts.operation, + opts.sourceKey, + opts.targetKey ?? undefined, + ); } catch (error) { if (opts.strict) throw error; logger.warn(`Skipping ${path} — could not read its current content:`, error); return; } - const content = opts.targetKey - ? JSON.stringify({ - scheme: PROTECTED_TEXT_SCHEME, - data: bytesToBase64(await idbEncryptWithKey(opts.targetKey, plaintext)), - }) - : plaintext; + const content = plaintext; if (content === raw) return; // already in the desired state try { await writeTextFileAtomic(apis, path, content); @@ -215,18 +217,18 @@ async function reprotectSnapshotFile( const originalData = envelope.data; let plaintext: string; try { - plaintext = await unprotectTextValue(originalData); + plaintext = await migrateProtectedTextValue( + originalData, + opts.operation, + opts.sourceKey, + opts.targetKey ?? undefined, + ); } catch (error) { if (opts.strict) throw error; logger.warn(`Skipping ${path} — could not read its current data field:`, error); return; } - envelope.data = opts.targetKey - ? JSON.stringify({ - scheme: PROTECTED_TEXT_SCHEME, - data: bytesToBase64(await idbEncryptWithKey(opts.targetKey, plaintext)), - }) - : plaintext; + envelope.data = plaintext; if (envelope.data === originalData) return; // already in the desired state try { await writeTextFileAtomic(apis, path, JSON.stringify(envelope)); @@ -245,8 +247,20 @@ async function reprotectSnapshotFile( export async function migrateAllProtectedFsData( targetKey: CryptoKey | null, operation: FsMigrationMarker['operation'], + sourceKey?: CryptoKey, ): Promise { - const opts: MigrationOptions = { targetKey, strict: true }; + // QNBS-v3: production lifecycle calls pass an explicit source key while holding exclusive admission; this fallback preserves the standalone bridge API for focused tests and callers that run before the marker exists. + const effectiveSourceKey = + sourceKey ?? (operation === 'set' ? undefined : await resolveProtectedWriteKey()); + if (operation !== 'set' && !effectiveSourceKey) { + throw new Error('Filesystem encryption migration is missing its source key'); + } + const opts: MigrationOptions = { + operation, + ...(effectiveSourceKey ? { sourceKey: effectiveSourceKey } : {}), + targetKey, + strict: true, + }; const apis = await loadTauriApis(); const appDataPath = await apis.appDataDir(); @@ -261,7 +275,12 @@ export async function migrateAllProtectedFsData( await reprotectWholeFile(apis, entryPath, opts); } else if (entry.name.endsWith('_key.enc.json')) { const provider = entry.name.slice(0, -'_key.enc.json'.length); - await fileSystemService.reprotectApiKeyFile(provider, opts.targetKey, opts.strict); + await fileSystemService.reprotectApiKeyFile( + provider, + opts.targetKey, + opts.strict, + opts.sourceKey, + ); } } diff --git a/services/fs/settingsFsStore.ts b/services/fs/settingsFsStore.ts index ae33340e..03ad40da 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -219,9 +219,10 @@ export class FsSettingsStore extends FsCore { provider: string, targetKey: CryptoKey | null, strict = true, + sourceKey?: CryptoKey, ): Promise { try { - await this.reprotectApiKeyFileInner(provider, targetKey); + await this.reprotectApiKeyFileInner(provider, targetKey, sourceKey); } catch (error) { if (strict) throw error; // QNBS-v3: non-strict (first-time setup) must never throw — any per-file error is logged and skipped so it can never strand setupIdbEncryption()'s already-activated sentinel/key. @@ -232,6 +233,7 @@ export class FsSettingsStore extends FsCore { private async reprotectApiKeyFileInner( provider: string, targetKey: CryptoKey | null, + sourceKey?: CryptoKey, ): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); @@ -245,16 +247,30 @@ export class FsSettingsStore extends FsCore { // QNBS-v3: covers first-time setup, where every existing key file starts as plaintext-v1 — without this, 'set' would leave already-saved keys unencrypted until their next incidental re-save. apiKey = parsed['value']; } else if (parsed['scheme'] === PROTECTED_SCHEME && typeof parsed['data'] === 'string') { - const sourceKey = await resolveProtectedWriteKey(); if (!sourceKey) { throw new Error( `Protected API key for provider "${provider}" exists but at-rest encryption is no longer configured`, ); } - const decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( - sourceKey, - base64ToBytes(parsed['data']), - ); + let decrypted: { provider: string; apiKey: string }; + if (targetKey) { + try { + decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( + targetKey, + base64ToBytes(parsed['data']), + ); + } catch { + decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( + sourceKey, + base64ToBytes(parsed['data']), + ); + } + } else { + decrypted = await idbDecryptWithKey<{ provider: string; apiKey: string }>( + sourceKey, + base64ToBytes(parsed['data']), + ); + } // QNBS-v3: same provider-identity check readProtectedApiKey() enforces on ordinary reads — without it, a ciphertext swapped between two provider files gets "laundered" into a correctly-labeled new file by this migration, silently bypassing the cross-file substitution guard. if (decrypted.provider !== provider) { throw new Error( diff --git a/services/storage/encryptionMigrationOrchestrator.ts b/services/storage/encryptionMigrationOrchestrator.ts index 9d8500c2..7dc78da2 100644 --- a/services/storage/encryptionMigrationOrchestrator.ts +++ b/services/storage/encryptionMigrationOrchestrator.ts @@ -15,6 +15,7 @@ import { type EncryptionMigrationKeys, type ProtectedStoreAdapter, type ProtectedStoreMigrationProgressCallback, + type ProtectedStoreMigrationRunOptions, runProtectedStoreMigration, } from './protectedStoreMigration'; import { getRegisteredSecondaryProtectedStoreAdapters } from './secondaryProtectedStoreAdapters'; @@ -43,6 +44,7 @@ export interface StartProductionEncryptionMigrationInput { /** A verifier encrypted with the target key — required for 'enable'/'rekey', omitted for 'disable'. */ targetVerifier?: number[]; onProgress?: ProtectedStoreMigrationProgressCallback; + admission?: ProtectedStoreMigrationRunOptions; } /** @@ -62,7 +64,13 @@ export async function runProductionEncryptionMigration( ...(input.targetVerifier ? { targetVerifier: input.targetVerifier } : {}), stores: adapters.map((adapter) => ({ id: adapter.id, processed: 0, verified: 0, done: false })), }); - return runProtectedStoreMigration(journal, adapters, input.keys, input.onProgress); + return runProtectedStoreMigration( + journal, + adapters, + input.keys, + input.onProgress, + input.admission, + ); } /** @@ -74,11 +82,13 @@ export async function resumeProductionEncryptionMigration( journal: EncryptionMigrationJournal, keys: EncryptionMigrationKeys, onProgress?: ProtectedStoreMigrationProgressCallback, + admission?: ProtectedStoreMigrationRunOptions, ): Promise { return runProtectedStoreMigration( journal, getRegisteredProtectedStoreAdapters(), keys, onProgress, + admission, ); } diff --git a/services/storage/protectedStoreMigration.ts b/services/storage/protectedStoreMigration.ts index eb11385a..3f4c8cae 100644 --- a/services/storage/protectedStoreMigration.ts +++ b/services/storage/protectedStoreMigration.ts @@ -54,6 +54,11 @@ export interface ProtectedStoreAdapter { verify(context: Omit): Promise; } +export interface ProtectedStoreMigrationRunOptions { + /** The caller owns one exclusive admission across an external participant and the final key commit. */ + alreadyHasExclusiveAdmission?: boolean; +} + export class ProtectedStoreMigrationAdapterError extends Error { constructor(message: string) { super(message); @@ -224,6 +229,7 @@ export async function runProtectedStoreMigration( adapters: readonly ProtectedStoreAdapter[], keys: EncryptionMigrationKeys, onProgress?: ProtectedStoreMigrationProgressCallback, + options: ProtectedStoreMigrationRunOptions = {}, ): Promise { let journal = initialJournal; if (journal.phase === 'recovery-required') { @@ -260,9 +266,11 @@ export async function runProtectedStoreMigration( let checkpoint = checkpointFor(journal, adapter.id); while (!checkpoint.done) { // QNBS-v3: exclusive admission bounds the race window to one batch, not the whole run — closes the write-vs-migration TOCTOU gap (#338) while still letting writers proceed between batches. - const batch = await withMigrationAdmission(() => - adapter.migrateNext(migrationContext(journal, checkpoint, keys)), - ); + const migrateBatch = () => + adapter.migrateNext(migrationContext(journal, checkpoint, keys)); + const batch = options.alreadyHasExclusiveAdmission + ? await migrateBatch() + : await withMigrationAdmission(migrateBatch); checkpoint = nextCheckpoint(checkpoint, batch); journal = await updateEncryptionMigrationJournal(journal, { phase: 'migrating', diff --git a/services/storage/storageEncryptionService.ts b/services/storage/storageEncryptionService.ts index 21769b6b..33368c44 100644 --- a/services/storage/storageEncryptionService.ts +++ b/services/storage/storageEncryptionService.ts @@ -32,6 +32,11 @@ import { decodeSecureRecordValue, encodeSecureRecordValue } from './secureRecord // QNBS-v3: re-exported so a write that already captured its key via resolveProtectedWriteKey() can re-check only the migration guard pre-write, without re-running its redundant lock check. export { assertNoActiveEncryptionMigration } from './encryptionMigrationJournal'; +export interface EncryptionLifecycleAdmissionOptions { + /** A desktop coordinator already owns the exclusive admission across filesystem and IDB work. */ + alreadyHasExclusiveAdmission?: boolean; +} + const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 minimum for PBKDF2-HMAC-SHA-256 const IV_BYTE_LENGTH = 12; const SALT_BYTE_LENGTH = 32; @@ -738,6 +743,7 @@ async function commitRekeyMigration( */ export async function clearIdbPassphrase( onProgress?: ProtectedStoreMigrationProgressCallback, + admission: EncryptionLifecycleAdmissionOptions = {}, ): Promise { if (!_activeKey) throw new IdbStorageLockedError(); const existingJournal = await readEncryptionMigrationJournal(); @@ -755,6 +761,9 @@ export async function clearIdbPassphrase( operation: 'disable', keys: { sourceKey }, ...(onProgress ? { onProgress } : {}), + ...(admission.alreadyHasExclusiveAdmission + ? { admission: { alreadyHasExclusiveAdmission: true } } + : {}), }); // QNBS-v3: journal is durably 'committing' here — every store has been migrated and verified // plaintext. Only bookkeeping remains; a failure below just needs a retry via the recovery UX. @@ -772,6 +781,7 @@ export async function rotateIdbPassphrase( oldPassphrase: string, newPassphrase: string, onProgress?: ProtectedStoreMigrationProgressCallback, + admission: EncryptionLifecycleAdmissionOptions = {}, ): Promise { if (!oldPassphrase || !newPassphrase) throw new Error('Passphrase must not be empty'); const existingJournal = await readEncryptionMigrationJournal(); @@ -789,6 +799,9 @@ export async function rotateIdbPassphrase( keys: { sourceKey, targetKey }, targetVerifier, ...(onProgress ? { onProgress } : {}), + ...(admission.alreadyHasExclusiveAdmission + ? { admission: { alreadyHasExclusiveAdmission: true } } + : {}), }); // QNBS-v3: journal is durably 'committing' here — every store is verified re-encrypted under the // target key. Only bookkeeping remains; a failure below just needs a retry via the recovery UX.