diff --git a/App.tsx b/App.tsx index c5b8b992..2cf2af7f 100644 --- a/App.tsx +++ b/App.tsx @@ -221,8 +221,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 @@ -278,8 +277,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(); }, []); @@ -340,24 +338,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); @@ -428,8 +421,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; @@ -467,15 +459,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 @@ -603,8 +591,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/CHANGELOG.md b/CHANGELOG.md index 6dcf4347..4e7799e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,75 @@ 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 files — both the binary + blob (`.bin`) *and* its metadata sidecar (`.meta.json`, which includes `originalFileName` — + filenames can themselves carry sensitive project information). The `.bin` payload needs a + byte-native encrypt path rather than the JSON-serializing helpers used here; both remain + plaintext pending a follow-up. + **Review-loop follow-up fix to the same change:** disabling or rotating the at-rest passphrase + previously destroyed or swapped the shared salt/session key (`storageEncryptionService.ts`'s + `clearIdbPassphrase()`/`rotateIdbPassphrase()`) with no awareness that desktop's `services/fs/*` + data depends on the same key material — every fs-backed protected file (project.json, settings, + API keys, snapshots, Codex, RAG vectors, images) would have been permanently stranded under a + now-unrecoverable key. A new migration bridge (`services/fs/fsEncryptionMigration.ts`) now + converts every fs-backed protected file to plaintext (disable) or re-encrypts it under the + independently-derived new target key (rotate) *before* the sentinel/session key is touched; any + file that fails to decrypt under the still-active old key aborts the whole disable/rotate + operation instead of silently stranding it. Wired into `hooks/useSettingsView.ts`'s + `handlePassphraseConfirm`, gated on `isTauriRuntime()` (no-op on web). + **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. + **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/README.md b/README.md index 66401b01..6d2cb5bd 100644 --- a/README.md +++ b/README.md @@ -311,8 +311,8 @@ 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. +- 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. ### 🔐 Encrypted Library Backup @@ -326,14 +326,15 @@ One-click encrypted export of your entire project library from **Settings → Da ### 🔑 Encryption — which mechanism protects what -There is no single blanket "encrypted at rest" guarantee — four independent mechanisms protect +There is no single blanket "encrypted at rest" guarantee — five independent mechanisms protect different data, with different key material: | Data | Mechanism | Where | |------|-----------|-------| | **Browser BYOK API key** | Random, non-extractable AES-256-GCM key generated via `crypto.subtle.generateKey()` — no passphrase, nothing to derive | `services/storage/idbKeyStore.ts` | | **Browser IDB-at-rest data** _(opt-in, B-1)_ | User passphrase → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/storage/storageEncryptionService.ts` | -| **Desktop (Tauri) BYOK API key** | Install-scoped secret material → PBKDF2 (600 000 iterations, SHA-256, random 32-byte salt) → AES-256-GCM, non-extractable key | `services/fs/fsCore.ts`, `services/fs/settingsFsStore.ts` | +| **Desktop (Tauri) 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` | See [`docs/SECURITY-THREAT-MODEL.md`](docs/SECURITY-THREAT-MODEL.md) for the full threat-model mapping. 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/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/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/hooks/useSettingsView.ts b/hooks/useSettingsView.ts index 7e652f1b..869df091 100644 --- a/hooks/useSettingsView.ts +++ b/hooks/useSettingsView.ts @@ -18,17 +18,26 @@ import { settingsActions } from '../features/settings/settingsSlice'; import { statusActions } from '../features/status/statusSlice'; import { useTranslation } from '../hooks/useTranslation'; import { wipeAllAppData } from '../services/factoryResetService'; +import { + clearFsMigrationMarker, + migrateAllProtectedFsData, +} 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, + deriveAndVerifySourceKeyFromSentinel, + deriveRotationTargetKey, isIdbEncryptionReady, + resolveProtectedWriteKey, rotateIdbPassphrase, setupIdbEncryption, verifyAndInitIdbEncryption, } from '../services/storage/storageEncryptionService'; import { storageService } from '../services/storageService'; +import { isTauriRuntime } from '../services/tauriRuntime'; import type { AccessibilitySettings, AdvancedAiSettings, @@ -119,8 +128,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; @@ -226,8 +234,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 @@ -270,8 +277,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; @@ -383,10 +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); - dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(true)); - setEncryptionReady(true); + 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') { @@ -398,7 +412,18 @@ export const useSettingsView = () => { // QNBS-v3: clearIdbPassphrase() requires an already-unlocked session key — no passphrase re-entry. setMigrationProgress(null); try { - await clearIdbPassphrase((progress) => setMigrationProgress(progress)); + 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); } @@ -408,9 +433,25 @@ export const useSettingsView = () => { } else if (passphraseModal === 'rotate') { setMigrationProgress(null); try { - await rotateIdbPassphrase(_current, newPassphrase, (progress) => - setMigrationProgress(progress), - ); + // 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. + const rotate = async () => { + const sourceKey = await deriveAndVerifySourceKeyFromSentinel(_current); + const targetKey = await deriveRotationTargetKey(newPassphrase); + 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), + ); + } } finally { setMigrationProgress(null); } 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/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/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/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..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", @@ -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 55e5833a..81ffb844 100644 --- a/services/fs/assetFsStore.ts +++ b/services/fs/assetFsStore.ts @@ -1,12 +1,23 @@ /** * 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 { isStorageAccessError } from '../storage/storageEncryptionService'; import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend'; -import { retryFs, sanitizePathSegment, writeFileAtomic, writeTextFileAtomic } from './fsCore'; +import { + readProtectedTextFile, + retryFs, + sanitizePathSegment, + writeFileAtomic, + writeProtectedTextFileAtomic, + writeTextFileAtomic, +} from './fsCore'; import { FsSnapshotStore } from './snapshotFsStore'; export class FsAssetStore extends FsSnapshotStore { @@ -23,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, cleanBase64); + await writeProtectedTextFileAtomic(apis, imageFile, cleanBase64); } async getImage(id: string): Promise { @@ -40,9 +51,11 @@ export class FsAssetStore extends FsSnapshotStore { return null; } - const base64Data = await retryFs(() => apis.readTextFile(imageFile)); + 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 (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 ce7c0f75..a5b0c9fb 100644 --- a/services/fs/codexFsStore.ts +++ b/services/fs/codexFsStore.ts @@ -1,17 +1,20 @@ /** * 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. */ import type { StoryCodex } from '../../types'; import { logger } from '../logger'; +import { isStorageAccessError } from '../storage/storageEncryptionService'; import { compressData, decompressData, + readProtectedTextFile, retryFs, sanitizePathSegment, - writeTextFileAtomic, + writeProtectedTextFileAtomic, } from './fsCore'; import { FsSettingsStore } from './settingsFsStore'; @@ -25,8 +28,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,9 +39,11 @@ 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) { + // QNBS-v3: a locked session is not "no codex" — never conflate the two. + if (isStorageAccessError(error)) throw error; logger.error('Failed to load story codex:', error); return null; } @@ -65,8 +70,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,9 +81,11 @@ 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) { + // QNBS-v3: a locked session is not "no vectors" — never conflate the two. + 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 6d221db7..763abd3d 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -5,6 +5,14 @@ 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 export type TauriApis = { @@ -133,15 +141,33 @@ async function writeThenRename( await atomicRename(apis, tmpPath, finalPath); } -export function writeTextFileAtomic(apis: TauriApis, path: string, content: string): Promise { - return enqueueWrite(path, () => { - const tmpPath = `${path}.tmp-${createTempSuffix()}`; - return writeThenRename(apis, tmpPath, path, () => - retryFs(() => apis.writeTextFile(tmpPath, content)), - ); +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, + path: string, + getContent: () => Promise, +): Promise { + return enqueueWrite(path, async () => { + const content = await getContent(); + await writeTextFileAtomicUnqueued(apis, path, 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()}`; @@ -149,6 +175,156 @@ 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; +} + +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; + try { + parsed = JSON.parse(raw); + } catch { + 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 obj as unknown as ProtectedTextEnvelope; +} + +/** + * 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 { + 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)), + }); +} + +/** + * Converts a value while the caller already owns the exclusive lifecycle admission. This deliberately + * bypasses ordinary readiness checks: the migration journal/marker itself makes those checks reject. + */ +export async function migrateProtectedTextValue( + stored: string, + operation: FsEncryptionMigrationOperation, + sourceKey: CryptoKey | undefined, + targetKey: CryptoKey | undefined, +): 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 + * 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 { + 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. */ +export function writeProtectedTextFileAtomic( + apis: TauriApis, + path: string, + plaintext: string, + formatContent: (protectedValue: string) => string = (protectedValue) => protectedValue, +): Promise { + 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. */ +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/fsEncryptionMigration.ts b/services/fs/fsEncryptionMigration.ts new file mode 100644 index 00000000..a85f0118 --- /dev/null +++ b/services/fs/fsEncryptionMigration.ts @@ -0,0 +1,338 @@ +/** + * Desktop fs-backed protected-data migration bridge. `services/storage/storageEncryptionService.ts`'s + * 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, resolveProtectedWriteKey } from '../storage/storageEncryptionService'; +import { + bytesToBase64, + compressData, + loadTauriApis, + migrateProtectedTextValue, + type TauriApis, + writeTextFileAtomic, +} from './fsCore'; +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; +} + +// 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 []; + 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. +const MIGRATION_MARKER_FILENAME = 'fs-migration-marker.json'; + +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, + 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)); +} + +/** + * 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); + 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. 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 { + 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); +} + +/** + * 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, + opts: MigrationOptions, +): Promise { + // 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 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 = plaintext; + if (content === raw) return; // already in the desired state + 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 { + 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, + opts: MigrationOptions, +): Promise { + 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; + } 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; + } + const originalData = envelope.data; + let plaintext: string; + try { + 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 = plaintext; + if (envelope.data === originalData) return; // already in the desired state + 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); + } +} + +/** + * Converges every fs-backed protected file to targetKey (first-time setup or rotate) or to + * 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'], + sourceKey?: CryptoKey, +): Promise { + // 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(); + + await writeMigrationMarker(apis, appDataPath, operation); + + const configPath = await apis.join(appDataPath, 'config'); + const configEntries = await listDirEntries(apis, configPath, 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, + opts.sourceKey, + ); + } + } + + const snapshotsPath = await apis.join(appDataPath, 'snapshots'); + const snapshotEntries = await listDirEntries(apis, snapshotsPath, opts.strict); + 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); + 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); + 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 +// 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 0f59d538..658c82fa 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. */ @@ -8,13 +10,16 @@ import type { EntityState } from '@reduxjs/toolkit'; import type { Character, StoryProject, World } from '../../types'; import { logger } from '../logger'; import { parseImportedProjectJson } from '../projectImportSchema'; +import { isStorageAccessError } from '../storage/storageEncryptionService'; import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend'; import { FsAssetStore } from './assetFsStore'; import { compressData, decompressData, + readProtectedTextFile, retryFs, sanitizePathSegment, + writeProtectedTextFileAtomic, writeTextFileAtomic, } from './fsCore'; @@ -42,7 +47,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,9 +98,11 @@ 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) { + // 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 (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 2ef7d323..03ad40da 100644 --- a/services/fs/settingsFsStore.ts +++ b/services/fs/settingsFsStore.ts @@ -15,14 +15,24 @@ 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, idbEncryptWithKey, + isStorageAccessError, 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 +48,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 +60,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,11 +73,13 @@ 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); } 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 (isStorageAccessError(error)) throw error; logger.error('Failed to load settings:', error); return null; } @@ -96,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 { @@ -193,6 +207,94 @@ 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, + strict = true, + sourceKey?: CryptoKey, + ): Promise { + try { + 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. + logger.warn(`Skipping API key for provider "${provider}" during encryption setup:`, error); + } + } + + private async reprotectApiKeyFileInner( + provider: string, + targetKey: CryptoKey | null, + sourceKey?: CryptoKey, + ): 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; + + 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, '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') { + if (!sourceKey) { + throw new Error( + `Protected API key for provider "${provider}" exists but at-rest encryption is no longer configured`, + ); + } + 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( + `Decrypted payload belongs to provider "${decrypted.provider}", not "${provider}"`, + ); + } + 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. + return; + } + + const payload: ProtectedApiKeyPayload | PlaintextApiKeyPayload = targetKey + ? { + scheme: PROTECTED_SCHEME, + data: bytesToBase64(await idbEncryptWithKey(targetKey, { provider, apiKey })), + } + : { 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 { try { const apis = await this.getApis(); diff --git a/services/fs/snapshotFsStore.ts b/services/fs/snapshotFsStore.ts index f995271d..7a76df0e 100644 --- a/services/fs/snapshotFsStore.ts +++ b/services/fs/snapshotFsStore.ts @@ -5,22 +5,26 @@ import type { ProjectSnapshot } from '../../types'; import { logger } from '../logger'; +import { isStorageAccessError } from '../storage/storageEncryptionService'; import { FsCodexStore } from './codexFsStore'; import { compressData, countProjectWords, decompressData, retryFs, - writeTextFileAtomic, + unprotectTextValue, } 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,11 +43,13 @@ export class FsSnapshotStore extends FsCodexStore { name: snapshotLabel, date: new Date().toISOString(), wordCount: countProjectWords(data), - data: 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; } @@ -59,13 +65,15 @@ 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; } catch (error) { + // QNBS-v3: a locked session is not "no snapshot" — never conflate the two. + if (isStorageAccessError(error)) throw error; logger.error('Failed to load snapshot:', error); return null; } 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 2fc986d3..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; @@ -102,6 +107,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; @@ -259,6 +273,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 +347,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 +410,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 +636,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 +664,7 @@ export async function verifyAndInitIdbEncryption(passphrase: string): Promise { if (!_activeKey) throw new IdbStorageLockedError(); const existingJournal = await readEncryptionMigrationJournal(); @@ -734,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. @@ -751,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(); @@ -768,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. @@ -793,6 +827,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 @@ -838,7 +884,7 @@ export async function resumeEncryptionMigration( // no credential left to verify against; the disable itself is already effectively done. await completeEncryptionMigration(journal); await clearCompletedEncryptionMigration(); - _activeKey = null; + setActiveKey(null); _sentinelPresenceCache = null; return; } @@ -883,7 +929,7 @@ export async function resumeEncryptionMigration( }); await completeEncryptionMigration(journal); await clearCompletedEncryptionMigration(); - _activeKey = targetKey; + setActiveKey(targetKey); _sentinelPresenceCache = true; return; } 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 deca826a..a340b7d2 100644 --- a/tests/unit/hooks/useSettingsView.test.ts +++ b/tests/unit/hooks/useSettingsView.test.ts @@ -39,6 +39,12 @@ 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 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 = { theme: 'dark' as const, @@ -205,6 +211,10 @@ 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), + resolveProtectedWriteKey: () => mockResolveProtectedWriteKey(), + deriveAndVerifySourceKeyFromSentinel: (passphrase: string) => + mockDeriveAndVerifySourceKeyFromSentinel(passphrase), })); vi.mock('../../../services/storageService', () => ({ @@ -215,6 +225,17 @@ 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, operation: unknown) => + mockMigrateAllProtectedFsData(targetKey, operation), + clearFsMigrationMarker: () => mockClearFsMigrationMarker(), +})); + +vi.mock('../../../services/tauriRuntime', () => ({ + isTauriRuntime: () => mockIsTauriRuntime(), +})); + // Stub URL.createObjectURL / URL.revokeObjectURL vi.stubGlobal('URL', { ...URL, @@ -611,6 +632,12 @@ describe('handlePassphraseConfirm — disable/rotate', () => { afterEach(() => { 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'); + mockIsTauriRuntime.mockReturnValue(false); }); it('calls clearIdbPassphrase with a progress callback, updates the flag, and toasts on success', async () => { @@ -663,6 +690,76 @@ 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, and clears the fs migration marker on success, 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'); + }); + mockClearFsMigrationMarker.mockImplementation(async () => { + callOrder.push('clearFsMigrationMarker'); + }); + + 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', + 'clearFsMigrationMarker', + ]); + }); + + 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')); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('set'); + }); + await act(async () => { + await expect(result.current.handlePassphraseConfirm('', 'newpass123')).rejects.toThrow( + 'marker write failed', + ); + }); + + expect(mockClearIdbPassphrase).not.toHaveBeenCalled(); + expect(mockDispatch).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()); + 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 () => { @@ -735,6 +832,128 @@ 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, and clears the fs migration marker only AFTER it, in the Tauri runtime', async () => { + mockIsTauriRuntime.mockReturnValue(true); + const callOrder: string[] = []; + mockMigrateAllProtectedFsData.mockImplementation(async () => { + callOrder.push('migrateAllProtectedFsData'); + }); + mockClearIdbPassphrase.mockImplementation(async () => { + callOrder.push('clearIdbPassphrase'); + }); + mockClearFsMigrationMarker.mockImplementation(async () => { + callOrder.push('clearFsMigrationMarker'); + }); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('disable'); + }); + await act(async () => { + await result.current.handlePassphraseConfirm('', ''); + }); + + expect(mockMigrateAllProtectedFsData).toHaveBeenCalledWith(null, 'disable'); + expect(mockDeriveRotationTargetKey).not.toHaveBeenCalled(); + // 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, 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[] = []; + mockMigrateAllProtectedFsData.mockImplementation(async () => { + callOrder.push('migrateAllProtectedFsData'); + }); + mockRotateIdbPassphrase.mockImplementation(async () => { + callOrder.push('rotateIdbPassphrase'); + }); + mockClearFsMigrationMarker.mockImplementation(async () => { + callOrder.push('clearFsMigrationMarker'); + }); + + const { result } = renderHook(() => useSettingsView()); + act(() => { + result.current.setPassphraseModal('rotate'); + }); + await act(async () => { + 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'); + // 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 + // 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')); + + 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(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 6bbedbff..d3e5121a 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,59 @@ 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). +// 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, + keyResolutionDelaysMs: [] as number[], + }, +})); +vi.mock('../../../../services/storage/storageEncryptionService', async (importOriginal) => { + const actual = + await importOriginal(); + 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)); + if (cryptoState.activeKey) return cryptoState.activeKey; + if (cryptoState.sentinelConfigured) throw new actual.IdbStorageLockedError(); + return null; + }, + }; +}); + +import { withMigrationAdmission } from '../../../../services/storage/protectedWriteAdmission'; +import { + SecureRecordCorruptError, + StorageEncryptionService, +} from '../../../../services/storage/storageEncryptionService'; + +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. function makeDirTreeFake(tree: Record) { const removed: string[] = []; @@ -46,7 +93,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 +105,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 +128,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 +350,137 @@ 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); + }); + + // 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'); + 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('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(); + 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"}'); + }); + + 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(); + 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', () => { it('round-trips small data uncompressed (plain JSON)', () => { const data = { a: 1, b: ['x', 'y'], c: 'hello' }; diff --git a/tests/unit/services/fs/fsEncryptionMigration.test.ts b/tests/unit/services/fs/fsEncryptionMigration.test.ts new file mode 100644 index 00000000..dd8b1f7a --- /dev/null +++ b/tests/unit/services/fs/fsEncryptionMigration.test.ts @@ -0,0 +1,481 @@ +/** + * 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), + 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()); + 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 { + assertNoInterruptedFsMigration, + checkForInterruptedFsMigration, + clearFsMigrationMarker, + FsMigrationInterruptedError, + 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, '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'); + 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, '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('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; + 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')).rejects.toThrow(); + // 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', () => { + // 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(); + }); + + 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(); + 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'); + }); +}); + +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, 'rotate'); + + // 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, 'disable')).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, '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/); + }); + + // 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; + 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: 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')).rejects.toThrow(); + }); + + 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(); + }); + + // 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) => { + 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')).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(); + }); + + 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 5e17e890..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()); @@ -60,7 +66,11 @@ 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, + SecureRecordCorruptError, + StorageEncryptionService, +} from '../../../../services/storage/storageEncryptionService'; interface FakeFs { apis: TauriApis; @@ -147,6 +157,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 +193,47 @@ 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'); + }); + + // 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('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; + 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 +299,27 @@ 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: 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'); const stored = JSON.parse(fake.text.get('/app/config/openai_key.enc.json') as string); @@ -443,6 +521,57 @@ 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' }] }); + }); + + // 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(); + 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', () => { @@ -461,6 +590,34 @@ 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 }]); + }); + + // 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', () => { @@ -471,6 +628,26 @@ 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'); + }); + + // 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, { diff --git a/tests/unit/settings/AiProviderCard.test.tsx b/tests/unit/settings/AiProviderCard.test.tsx index 0e4d737a..611ce20f 100644 --- a/tests/unit/settings/AiProviderCard.test.tsx +++ b/tests/unit/settings/AiProviderCard.test.tsx @@ -17,6 +17,11 @@ vi.mock('../../../hooks/useTranslation', () => ({ 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/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. 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'); });