Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 9 additions & 14 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ import { pluginRegistry } from './services/pluginRegistry';
import { repairProjectI18nFields } from './services/projectI18nRepair';
import { hasCompletedSpotlightTour, startSpotlightTour } from './services/spotlightTour';
import {
clearIdbPassphrase,
hasPassphraseSentinel,
isIdbEncryptionReady,
} from './services/storage/storageEncryptionService';
Expand Down Expand Up @@ -196,15 +195,6 @@ const App: FC<AppProps> = ({ isNewUser }) => {
const isIdbUnlockOpen = useTransientUiStore((s) => s.isIdbUnlockOpen);
const setIdbUnlockOpen = useTransientUiStore((s) => s.setIdbUnlockOpen);

// QNBS-v3: escape hatch — clears sentinel + disables flag so the app is accessible again
const handleForgotPassphrase = useCallback(async () => {
await clearIdbPassphrase();
dispatch(featureFlagsActions.setEnableIdbAtRestEncryption(false));
setIdbUnlockOpen(false);
// QNBS-v3: WCAG 4.1.3 — assertive announcement so screen reader users know the security state changed
announce(t('settings.privacy.encryptionDisabledStatus'), 'assertive');
}, [dispatch, setIdbUnlockOpen, announce, t]);

// Collaboration Panel State
const [isCollabPanelOpen, setIsCollabPanelOpen] = useState(false);

Expand Down Expand Up @@ -256,6 +246,14 @@ const App: FC<AppProps> = ({ isNewUser }) => {
}
}, [settings.appearancePreset]);

useEffect(() => {
// QNBS-v3: Decorative fixed layers are opt-out so long-form writers can keep a neutral canvas.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
document.body.classList.toggle(
'writing-surface-plain',
settings.writingSurfaceStyle === 'plain',
);
}, [settings.writingSurfaceStyle]);

useEffect(() => {
document.body.classList.toggle(
'accessibility-high-contrast',
Expand Down Expand Up @@ -809,10 +807,7 @@ const App: FC<AppProps> = ({ isNewUser }) => {
)}
{isIdbUnlockOpen && (
<ErrorBoundary onReset={() => setIdbUnlockOpen(false)}>
<IdbUnlockModal
onUnlocked={() => setIdbUnlockOpen(false)}
onForgotPassphrase={() => void handleForgotPassphrase()}
/>
<IdbUnlockModal onUnlocked={() => setIdbUnlockOpen(false)} />
</ErrorBoundary>
)}
</div>
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,20 +307,21 @@ Real-time P2P co-editing via **Yjs + collab-transport** (vendor fork of y-webrtc

### 🔒 IDB At-Rest Encryption _(B-1, v1.19.0)_

All project data, snapshots, and settings stored in IndexedDB can be encrypted at rest via `services/storage/storageEncryptionService.ts`:
The current primary project, settings, snapshot, image, Codex, RAG, and binder-asset IndexedDB paths can be encrypted at rest via `services/storage/storageEncryptionService.ts`. This is not yet a claim that every IndexedDB surface is covered:

- **AES-256-GCM** with a PBKDF2-derived key (600 000 iterations, SHA-256, 32-byte random salt).
- Gated behind `featureFlags.enableIdbAtRestEncryption` (on by default since v1.23; the passphrase unlock UX is complete — Settings → Privacy).
- 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.
- Same passphrase-entry unlock screen (`IdbUnlockModal`) on cold start, session-scoped in-memory key, on **both** the web build and the Tauri desktop build — Tauri's WebView uses the same IndexedDB-backed storage path, not an OS keychain. (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.)
Comment thread
qnbs marked this conversation as resolved.
Outdated
- GDPR-compliant: encrypted blobs are unreadable without the passphrase, even from the browser profile directory.
- 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

One-click encrypted export of your entire project library from **Settings → Data**:

- Archives all projects as a **ZIP** containing `META.json` + `vault.bin`.
- `vault.bin` is encrypted with **AES-256-GCM** — the decryption key is derived from your chosen passphrase using PBKDF2.
- No plaintext project data ever leaves your device unencrypted.
- The encrypted vault holds its project payload in `vault.bin`; users must still protect the downloaded archive and should not confuse it with ordinary plaintext JSON export.
- Import on any device using the same passphrase to restore your full library.

### 🔑 Encryption — which mechanism protects what
Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ are all version-bumped and synced for this release.
- ✅ **Production blank screen — zod/rolldown DCE** (2026-06-02) — `init_locales is not defined`: rolldown's prod DCE dropped zod's `__esm` init wrappers (zod `sideEffects:false`). Fixed via `patches/zod@4.4.3.patch` (`sideEffects:true`). Added `smoke:prod` (headless mount check on built `dist/`) to CI build job + `unhandledrejection` startup handler — closes the dev-mode-E2E blind spot
- 🔄 **C-6** — ar/he UI translation **complete** (2026-06-03): 18 modules translated in `locales/{ar,he}/` (help.json English fallback), Noto fonts + RTL shell layout shipped as Beta. Remaining: native-speaker review + help-article prose — community task. See `docs/I18N-GLOSSARY-RTL.md`
- 🔄 **C-7 remainder** — Coverage → L85%/B75%/F80%; Stryker break 75→80 (current thresholds: L73/F65/B58). **Phase 3 started (2026-06-02):** +33 LoRA tests (useLoraView, training wizard, sub-panels — were 0%)
- ✅ IDB at-rest encryption UX (2026-06-02 reconciliation) — `IdbUnlockModal` (startup unlock + 2-step forgot-passphrase escape hatch, `App.tsx:182-188,638-643`), `PassphraseModal` (set/change/disable), real read/write gating `idbProjectStore.ts:209-265`, session lock + key rotation (Phase 1). `enableIdbAtRestEncryption` flag in Settings › Privacy with ⚠ warning
- 🟡 IDB at-rest encryption lifecycle (2026-08-11 reconciliation) — startup unlock, session lock, and fail-closed protected writes are implemented. Disable, forgot-passphrase deletion, and passphrase rotation are intentionally blocked until a durable cross-database migration journal and recovery protocol exist; do not describe them as completed.
- ✅ **P0-2** — Plugin worker isolation (`workers/plugin.worker.ts`) — routes plugin execution to isolated worker context with timeout and sandboxed API
- 🟡 **P0-4** — DuckDB OPFS at-rest encryption (`services/duckdb/duckdbEncryption.ts`) — cell-level encryption is now wired for the one column holding literal manuscript prose, `codex_mentions.excerpt` (v1.25.0): `duckdbCodexWrite()` encrypts it into `excerpt_enc BLOB` when `enableIdbAtRestEncryption` is active, with `services/duckdb/codexExcerptEncryptionMigration.ts` backfilling pre-existing plaintext rows. Full OPFS **file-level** encryption remains infeasible (DuckDB-WASM owns the OPFS file handle directly) and is an accepted, permanent limitation, not a remaining task — see `.github/SECURITY.md` SEC-6.
- ✅ **P0-5** — Voice WASM model download UI (`components/voice/VoiceModelDownloadModal.tsx`) — progress modal for Whisper/Kokoro model downloads with cancel/retry
Expand Down
26 changes: 26 additions & 0 deletions components/settings/GeneralSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,32 @@ export const AppearanceSection: FC = () => {
</Button>
</div>
</div>
<div className="pt-2 border-t border-[var(--sc-border-subtle)] space-y-2">
<span className="text-sm font-medium text-[var(--sc-text-secondary)]">
{t('settings.appearance.writingSurface')}
</span>
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
<p className="text-xs text-[var(--sc-text-muted)]">
{t('settings.appearance.writingSurfaceHint')}
</p>
<div className="grid grid-cols-2 gap-3">
<Button
variant={settings.writingSurfaceStyle === 'textured' ? 'primary' : 'secondary'}
size="sm"
onClick={() => handleSettingChange('writingSurfaceStyle', 'textured')}
className="justify-center"
>
{t('settings.appearance.writingSurfaceTextured')}
</Button>
<Button
variant={settings.writingSurfaceStyle === 'plain' ? 'primary' : 'secondary'}
size="sm"
onClick={() => handleSettingChange('writingSurfaceStyle', 'plain')}
className="justify-center"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{t('settings.appearance.writingSurfacePlain')}
</Button>
</div>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</CardContent>
</Card>
<Card>
Expand Down
51 changes: 1 addition & 50 deletions components/settings/IdbUnlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Modal } from '../ui/Modal';

interface Props {
onUnlocked: () => void;
onForgotPassphrase?: () => void;
}

const ATTEMPT_STORAGE_KEY = 'worldscript-idb-unlock-attempts';
Expand Down Expand Up @@ -100,16 +99,13 @@ function lockoutMs(attempts: number): number {
return Math.min(2 ** (attempts - 4), 60) * 1000;
}

export const IdbUnlockModal: FC<Props> = ({ onUnlocked, onForgotPassphrase }) => {
export const IdbUnlockModal: FC<Props> = ({ onUnlocked }) => {
const { t } = useTranslation();
const [passphrase, setPassphrase] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
// QNBS-v3: two-step confirm for forgot-passphrase to prevent accidental clicks
const [showForgotConfirm, setShowForgotConfirm] = useState(false);
const [lockoutRemaining, setLockoutRemaining] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const cancelBtnRef = useRef<HTMLButtonElement>(null);

// QNBS-v3: Rate-limiting tick — update remaining lockout time every second
useEffect(() => {
Expand All @@ -127,17 +123,6 @@ export const IdbUnlockModal: FC<Props> = ({ onUnlocked, onForgotPassphrase }) =>
inputRef.current?.focus();
}, []);

// QNBS-v3: WCAG 2.4.3 focus management — when confirmation panel opens, move focus to
// the Cancel button so keyboard users don't lose their position in the document.
useEffect(() => {
if (showForgotConfirm) {
cancelBtnRef.current?.focus();
} else {
// Restore focus to the passphrase input when the panel is dismissed.
inputRef.current?.focus();
}
}, [showForgotConfirm]);

const handleUnlock = useCallback(async () => {
if (!passphrase) return;
if (lockoutRemaining > 0) return;
Expand Down Expand Up @@ -238,40 +223,6 @@ export const IdbUnlockModal: FC<Props> = ({ onUnlocked, onForgotPassphrase }) =>
: t('settings.privacy.encryptionUnlockButton')}
</Button>
</div>

{onForgotPassphrase && (
<div className="border-t border-[var(--sc-border-subtle)] pt-4 space-y-3">
{!showForgotConfirm ? (
<button
type="button"
onClick={() => setShowForgotConfirm(true)}
className="text-sm text-[var(--sc-text-muted)] underline underline-offset-2 hover:text-[var(--sc-text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--sc-border-focus)] rounded"
>
{t('settings.privacy.encryptionForgotPassphrase')}
</button>
) : (
<div className="space-y-3">
{/* QNBS-v3: role="alert" so screen readers immediately read the warning when this section appears */}
<p
id="idb-forgot-warning"
role="alert"
className="text-sm text-[var(--sc-warning-fg)]"
>
{t('settings.privacy.encryptionForgotPassphraseWarning')}
</p>
<div className="flex gap-2 justify-end">
<Button ref={cancelBtnRef} onClick={() => setShowForgotConfirm(false)}>
{t('common.cancel')}
</Button>
{/* QNBS-v3: aria-describedby links destructive button to the warning text for AT users */}
<Button onClick={onForgotPassphrase} aria-describedby="idb-forgot-warning">
{t('settings.privacy.encryptionDisableConfirm')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</Modal>
);
Expand Down
68 changes: 9 additions & 59 deletions components/settings/PassphraseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
import { Spinner } from '../ui/Spinner';

export type PassphraseModalMode = 'set' | 'change' | 'disable' | 'unlock';
export type PassphraseModalMode = 'set' | 'unlock';

interface Props {
mode: PassphraseModalMode;
onClose: () => void;
/** Called with (current, next) — for 'set': ('' , passphrase); for 'unlock'/'disable': (passphrase, ''); for 'change': (old, new). */
/** Called with (current, next) — for 'set': ('', passphrase); for 'unlock': (passphrase, ''). */
onConfirm: (current: string, next: string) => Promise<void>;
}

Expand All @@ -35,14 +35,10 @@ export const PassphraseModal: FC<Props> = ({ mode, onClose, onConfirm }) => {
const title =
mode === 'set'
? t('settings.privacy.encryptionModalSetTitle')
: mode === 'change'
? t('settings.privacy.encryptionModalChangeTitle')
: mode === 'unlock'
? t('settings.privacy.encryptionModalUnlockTitle')
: t('settings.privacy.encryptionModalDisableTitle');
: t('settings.privacy.encryptionModalUnlockTitle');

const validate = useCallback((): string => {
if (mode === 'set' || mode === 'change') {
if (mode === 'set') {
if (next.length < MIN_LEN) return t('settings.privacy.encryptionTooShort');
if (next !== confirm) return t('settings.privacy.encryptionMismatch');
}
Expand Down Expand Up @@ -71,22 +67,12 @@ export const PassphraseModal: FC<Props> = ({ mode, onClose, onConfirm }) => {
const confirmButtonLabel =
mode === 'set'
? t('settings.privacy.encryptionSetButton')
: mode === 'change'
? t('settings.privacy.encryptionChangeButton')
: mode === 'unlock'
? t('settings.privacy.encryptionUnlockButton')
: t('settings.privacy.encryptionDisableButton');
: t('settings.privacy.encryptionUnlockButton');

const hasError = error.length > 0;

// QNBS-v3: 'disable' mode is a destructive confirmation — alertdialog announces immediately via AT
return (
<Modal
isOpen={true}
onClose={onClose}
title={title}
variant={mode === 'disable' ? 'alertdialog' : 'dialog'}
>
<Modal isOpen={true} onClose={onClose} title={title}>
<div className="space-y-4">
{/* 'unlock' mode: single current-passphrase field to re-derive the in-memory key */}
{mode === 'unlock' && (
Expand Down Expand Up @@ -114,33 +100,7 @@ export const PassphraseModal: FC<Props> = ({ mode, onClose, onConfirm }) => {
</div>
)}

{(mode === 'change' || mode === 'disable') && (
<div className="space-y-1">
<label
htmlFor="enc-current"
className="text-sm font-medium text-[var(--sc-text-primary)]"
>
{t('settings.privacy.encryptionCurrentPassphrase')}
</label>
<input
ref={firstFieldRef}
id="enc-current"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => {
setCurrent(e.target.value);
setError('');
}}
// QNBS-v3: aria-describedby + aria-invalid wire the error to the field for screen readers
aria-describedby={hasError ? ERROR_ID : undefined}
aria-invalid={hasError}
className="w-full px-3 py-2 rounded-lg border border-[var(--sc-border-subtle)] bg-[var(--sc-surface-base)] text-[var(--sc-text-primary)] focus-visible:ring-2 focus-visible:ring-[var(--sc-border-focus)] outline-none"
/>
</div>
)}

{(mode === 'set' || mode === 'change') && (
{mode === 'set' && (
<>
<div className="space-y-1">
<label
Expand All @@ -150,7 +110,7 @@ export const PassphraseModal: FC<Props> = ({ mode, onClose, onConfirm }) => {
{t('settings.privacy.encryptionNewPassphrase')}
</label>
<input
ref={mode === 'set' ? firstFieldRef : undefined}
ref={firstFieldRef}
id="enc-next"
type="password"
autoComplete="new-password"
Expand Down Expand Up @@ -194,16 +154,6 @@ export const PassphraseModal: FC<Props> = ({ mode, onClose, onConfirm }) => {
</p>
)}

{/* forgot passphrase hint — only relevant when entering an existing passphrase */}
{(mode === 'unlock' || mode === 'disable') && (
<p className="text-xs text-[var(--sc-text-muted)]">
{t('settings.privacy.encryptionForgotPassphrase')}{' '}
<span className="text-[var(--sc-warning-fg)]">
{t('settings.privacy.encryptionForgotPassphraseWarning')}
</span>
</p>
)}

{/* QNBS-v3: pre-rendered with minHeight so the DOM node exists before text is injected —
required by NVDA/JAWS for role="alert" to fire the live-region announcement */}
<p
Expand All @@ -220,7 +170,7 @@ export const PassphraseModal: FC<Props> = ({ mode, onClose, onConfirm }) => {
{t('common.cancel')}
</Button>
<Button
variant={mode === 'disable' ? 'danger' : 'primary'}
variant="primary"
onClick={() => void handleSubmit()}
disabled={busy}
aria-busy={busy}
Expand Down
14 changes: 4 additions & 10 deletions components/settings/PrivacySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ export const PrivacySection: FC = () => {
/>
<ToggleSwitch
label={t('settings.privacy.dataEncryption')}
checked={featureFlags.enableIdbAtRestEncryption && encryptionReady}
// QNBS-v3: Locked is still encrypted; showing it as off invited an unsafe disable path.
checked={featureFlags.enableIdbAtRestEncryption}
hint={encEnabled ? t('settings.privacy.encryptionWarning') : undefined}
disabled={encEnabled}
onChange={(v) => {
// QNBS-v3: dataEncryption is now a read-through of the actual IDB at-rest encryption
// state. Toggling it opens the passphrase modal to set up or disable encryption.
if (v) setPassphraseModal('set');
else setPassphraseModal('disable');
}}
/>
<ToggleSwitch
Expand Down Expand Up @@ -107,12 +107,6 @@ export const PrivacySection: FC = () => {
)}
{encEnabled && encryptionReady && (
<>
<Button variant="secondary" onClick={() => setPassphraseModal('change')}>
{t('settings.privacy.encryptionChangeAction')}
</Button>
<Button variant="danger" onClick={() => setPassphraseModal('disable')}>
{t('settings.privacy.encryptionDisableAction')}
</Button>
{/* QNBS-v3: Lock Session clears the in-memory key without disabling encryption —
user must re-enter passphrase on next access. */}
<Button variant="secondary" onClick={handleLockSession}>
Expand Down
Loading
Loading