From c088b7413805a979d7e3342c31523a65cc5f00ef Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 21 Jul 2026 19:24:44 +0800 Subject: [PATCH] feat: add read-only update recovery --- README.md | 2 +- apps/desktop/e2e/workbench.spec.ts | 29 ++ apps/desktop/src-tauri/src/lib.rs | 308 ++++++++++++++++-- apps/desktop/src/App.test.tsx | 53 +++ apps/desktop/src/App.tsx | 21 +- apps/desktop/src/SettingsDialog.tsx | 12 +- .../desktop-api/CaptureCompatibilityCode.ts | 2 +- .../generated/desktop-api/WorkspaceSource.ts | 2 +- apps/desktop/src/styles.css | 8 + apps/desktop/src/workspace.ts | 27 +- crates/desktop-api/src/compatibility.rs | 34 ++ crates/desktop-api/src/export.rs | 6 +- crates/desktop-api/src/lib.rs | 3 +- crates/storage/src/key.rs | 23 +- crates/storage/src/lib.rs | 5 + crates/storage/src/store.rs | 43 +++ crates/storage/tests/storage.rs | 82 ++++- docs/progress.html | 6 +- docs/threat-model.html | 2 +- schemas/desktop-api/v0.1.schema.json | 2 + 20 files changed, 608 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 27e6123..da262a5 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Connection 设置页会按 Proxy bundle、loopback 端点、本地 CA、系统 ## 签名更新 -桌面设置页通过 Tauri updater 检查固定的 GitHub Release `latest.json`。安装命令会重新核对版本、切回 Gateway、恢复受管系统代理,并在应用数据目录写入 SQLCipher 备份和版本化恢复 journal 后才下载签名制品。未配置可信公钥的构建会明确禁用更新。 +桌面设置页通过 Tauri updater 检查固定的 GitHub Release `latest.json`。安装命令会重新核对版本、切回 Gateway、恢复受管系统代理,并在应用数据目录写入 SQLCipher 备份和版本化恢复 journal 后才下载签名制品。若升级后主库无法打开,应用只接受版本、固定文件名和字节数均匹配的加密快照,并以禁止捕获和系统变更的只读模式开放搜索、检查与脱敏导出。未配置可信公钥的构建会明确禁用更新。 正式 bundle 构建必须在环境中提供: diff --git a/apps/desktop/e2e/workbench.spec.ts b/apps/desktop/e2e/workbench.spec.ts index 15de6c6..9f34514 100644 --- a/apps/desktop/e2e/workbench.spec.ts +++ b/apps/desktop/e2e/workbench.spec.ts @@ -117,4 +117,33 @@ test.describe("desktop workbench quality baseline", () => { expect(layout.scrollWidth).toBe(layout.clientWidth); expect(layout.scrollHeight).toBeLessThanOrEqual(layout.clientHeight); }); + + test("keeps validated update recovery read-only and exportable", async ({ page }) => { + await page.setViewportSize(MINIMUM_VIEWPORT); + await page.goto("/?recoveryMode=1"); + + await expect(page.getByRole("status", { name: "Read-only update recovery mode" })) + .toBeVisible(); + await expect(page.getByRole("button", { name: "Resume capture" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Gateway" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Proxy" })).toBeDisabled(); + + await page.getByRole("button", { name: "Export request" }).click(); + await expect(page.getByRole("dialog", { name: "Export request" })).toBeVisible(); + await page.getByRole("button", { name: "Close export" }).click(); + await page.getByRole("button", { name: "Settings" }).click(); + const dialog = page.getByRole("dialog", { name: "Settings & diagnostics" }); + await dialog.getByRole("tab", { name: "Updates" }).click(); + await expect(dialog.getByText("Updates are disabled")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Check" })).toBeDisabled(); + + const layout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + clientHeight: document.documentElement.clientHeight, + scrollHeight: document.documentElement.scrollHeight, + })); + expect(layout.scrollWidth).toBe(layout.clientWidth); + expect(layout.scrollHeight).toBe(layout.clientHeight); + }); }); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2fb54e9..ae930df 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -39,8 +39,9 @@ use codeischeap_desktop_api::{ CaptureMode, CapturedRequest, CertificateAuthority, CertificateAuthorityState, CertificatePrivateMaterial, CertificateTrust, DesktopApiError, DiagnosticEvent, ExportPreview, ExportProfile, ExportReceipt, SupportBundlePreview, UpdateStatus, WorkspaceBootstrap, - build_batch_export_preview, build_export_preview, build_support_bundle_preview, - diagnose_capture_compatibility, load_request, load_workspace, search_requests, + WorkspaceSource, build_batch_export_preview, build_export_preview, + build_support_bundle_preview, diagnose_capture_compatibility, load_request, load_workspace, + recovery_read_only_compatibility, search_requests, }; use codeischeap_gateway::{Gateway, GatewayCapture, GatewayCaptureEvent}; use codeischeap_process_attribution::resolve_loopback_client_pid; @@ -62,9 +63,9 @@ use codeischeap_sidecar_runtime::{ uninstall_certificate_authority as uninstall_ca_trust, }; use codeischeap_storage::{ - EncryptedStore, OsKeyStore, RetentionPolicy, RetentionReport, StorageError, + DatabaseKeyStore, EncryptedStore, OsKeyStore, RetentionPolicy, RetentionReport, StorageError, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager, State, WindowEvent}; @@ -100,6 +101,7 @@ const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(30); const UPDATE_RECOVERY_DIRECTORY: &str = "update-recovery"; const UPDATE_RECOVERY_BACKUP_FILENAME: &str = "captures.pre-update.db"; const UPDATE_RECOVERY_JOURNAL_FILENAME: &str = "pending.v0.1.json"; +const MAX_UPDATE_RECOVERY_JOURNAL_BYTES: u64 = 16 * 1024; type SharedStore = Arc>>; @@ -273,6 +275,7 @@ struct RuntimeSnapshot { system_proxy_active: bool, proxy_session_event_count: u64, certificate_authority: CertificateAuthority, + read_only_recovery: bool, } #[derive(Debug)] @@ -331,13 +334,13 @@ struct UpdateDownloadProgress { finished: bool, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct UpdateRecoveryJournal { - schema_version: &'static str, + schema_version: String, source_version: String, target_version: String, - backup_file: &'static str, + backup_file: String, backup_bytes: u64, created_at_unix_ms: u64, } @@ -348,8 +351,10 @@ async fn bootstrap_workspace( state: State<'_, DesktopState>, ) -> Result { ensure_proxy_recovery(&app, &state).await?; - initialize_store(&app, &state.store)?; - ensure_gateway(&app, &state).await?; + initialize_store(&app, &state)?; + if !store_is_read_only(&state.store)? { + ensure_gateway(&app, &state).await?; + } let workspace = load_runtime_workspace(&app, &state).await?; emit_sidecar_error_once(&app, &state); Ok(workspace) @@ -361,7 +366,7 @@ async fn search_workspace( app: AppHandle, state: State<'_, DesktopState>, ) -> Result, String> { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; let store = state .store .lock() @@ -381,6 +386,8 @@ async fn set_capture_active( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; let mode = *state.mode.lock().await; match mode { CaptureMode::Gateway => { @@ -406,7 +413,8 @@ async fn set_capture_mode( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; ensure_gateway(&app, &state).await?; switch_capture_mode(&app, &state, mode).await?; load_runtime_workspace(&app, &state).await @@ -417,7 +425,8 @@ async fn install_certificate_authority_trust( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; let confdir = application_certificate_confdir(&app)?; tokio::task::spawn_blocking(move || install_ca_trust(confdir)) .await @@ -437,7 +446,8 @@ async fn uninstall_certificate_authority_trust( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; if *state.mode.lock().await == CaptureMode::Proxy { switch_capture_mode(&app, &state, CaptureMode::Gateway).await?; } @@ -456,7 +466,7 @@ async fn preview_capture_export( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; let store = state.store.clone(); tauri::async_runtime::spawn_blocking(move || { build_capture_export(&store, &capture_id, profile, current_unix_ms()?) @@ -472,7 +482,7 @@ async fn preview_batch_capture_export( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; let store = state.store.clone(); tauri::async_runtime::spawn_blocking(move || { build_batch_capture_export(&store, &capture_ids, profile, current_unix_ms()?) @@ -491,7 +501,7 @@ async fn write_capture_export( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; let store = state.store.clone(); tauri::async_runtime::spawn_blocking(move || { let preview = build_capture_export(&store, &capture_id, profile, exported_at_unix_ms)?; @@ -519,7 +529,7 @@ async fn write_batch_capture_export( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; let store = state.store.clone(); tauri::async_runtime::spawn_blocking(move || { write_batch_capture_export_file( @@ -541,7 +551,7 @@ async fn preview_support_bundle( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; let workspace = load_runtime_workspace(&app, &state).await?; let diagnostic_events = load_application_diagnostic_events(&app); build_support_bundle_preview( @@ -562,7 +572,7 @@ async fn write_support_bundle( app: AppHandle, state: State<'_, DesktopState>, ) -> Result { - initialize_store(&app, &state.store)?; + initialize_store(&app, &state)?; let workspace = load_runtime_workspace(&app, &state).await?; let diagnostic_events = load_application_diagnostic_events(&app); tauri::async_runtime::spawn_blocking(move || { @@ -621,9 +631,10 @@ async fn install_update( } ensure_proxy_recovery(&app, &state).await?; + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; ensure_gateway(&app, &state).await?; switch_capture_mode(&app, &state, CaptureMode::Gateway).await?; - initialize_store(&app, &state.store)?; prepare_update_recovery_snapshot(&app, &state.store, &update.version).await?; let progress_app = app.clone(); @@ -739,10 +750,72 @@ pub fn run() { fn open_application_store(app: &AppHandle) -> Result> { let database_path = app.path().app_data_dir()?.join("captures.db"); let key_store = OsKeyStore::new(KEY_SERVICE, KEY_ACCOUNT)?; - Ok(EncryptedStore::open_with_key_store( - database_path, - &key_store, - )?) + let key = if database_path.exists() { + key_store.load()? + } else { + key_store.load_or_create()? + }; + Ok(EncryptedStore::open(database_path, key)?) +} + +fn open_application_recovery_store(app: &AppHandle) -> Result { + let directory = app + .path() + .app_data_dir() + .map_err(|error| format!("application data directory is unavailable: {error}"))? + .join(UPDATE_RECOVERY_DIRECTORY); + let backup_path = + validate_update_recovery_backup(&directory, &app.package_info().version.to_string())?; + let key_store = OsKeyStore::new(KEY_SERVICE, KEY_ACCOUNT) + .map_err(|error| format!("database key store is unavailable: {error}"))?; + EncryptedStore::open_readonly_with_key_store(backup_path, &key_store) + .map_err(|error| format!("encrypted recovery backup could not be opened: {error}")) +} + +fn validate_update_recovery_backup( + directory: &Path, + running_version: &str, +) -> Result { + let journal_path = directory.join(UPDATE_RECOVERY_JOURNAL_FILENAME); + let journal_metadata = fs::symlink_metadata(&journal_path) + .map_err(|error| format!("update recovery journal is unavailable: {error}"))?; + if journal_metadata.file_type().is_symlink() || !journal_metadata.is_file() { + return Err("update recovery journal must be a regular file".to_owned()); + } + if journal_metadata.len() == 0 || journal_metadata.len() > MAX_UPDATE_RECOVERY_JOURNAL_BYTES { + return Err("update recovery journal size is invalid".to_owned()); + } + let encoded = fs::read(&journal_path) + .map_err(|error| format!("update recovery journal could not be read: {error}"))?; + let journal: UpdateRecoveryJournal = serde_json::from_slice(&encoded) + .map_err(|error| format!("update recovery journal is invalid: {error}"))?; + if journal.schema_version != "0.1" { + return Err("update recovery journal schema is unsupported".to_owned()); + } + if running_version.is_empty() || journal.target_version != running_version { + return Err("update recovery journal does not target this application version".to_owned()); + } + if journal.source_version.trim().is_empty() || journal.source_version == journal.target_version + { + return Err("update recovery journal version transition is invalid".to_owned()); + } + if journal.backup_file != UPDATE_RECOVERY_BACKUP_FILENAME { + return Err("update recovery journal backup filename is invalid".to_owned()); + } + if journal.backup_bytes == 0 || journal.created_at_unix_ms == 0 { + return Err("update recovery journal metadata is incomplete".to_owned()); + } + + let backup_path = directory.join(UPDATE_RECOVERY_BACKUP_FILENAME); + let backup_metadata = fs::symlink_metadata(&backup_path) + .map_err(|error| format!("update recovery backup is unavailable: {error}"))?; + if backup_metadata.file_type().is_symlink() || !backup_metadata.is_file() { + return Err("update recovery backup must be a regular file".to_owned()); + } + if backup_metadata.len() != journal.backup_bytes { + return Err("update recovery backup size does not match its journal".to_owned()); + } + Ok(backup_path) } fn open_application_sidecar(app: &AppHandle) -> Result>, Box> { @@ -838,10 +911,10 @@ fn write_update_recovery_snapshot( .map_err(|error| format!("pre-update encrypted backup could not be inspected: {error}"))? .len(); let journal = UpdateRecoveryJournal { - schema_version: "0.1", + schema_version: "0.1".to_owned(), source_version, target_version, - backup_file: UPDATE_RECOVERY_BACKUP_FILENAME, + backup_file: UPDATE_RECOVERY_BACKUP_FILENAME.to_owned(), backup_bytes, created_at_unix_ms, }; @@ -883,21 +956,60 @@ where } } -fn initialize_store(app: &AppHandle, store: &SharedStore) -> Result<(), String> { - let mut store = store +fn initialize_store(app: &AppHandle, state: &DesktopState) -> Result<(), String> { + let mut store = state + .store .lock() .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())?; if store.is_none() { - let mut initialized = open_application_store(app).map_err(|error| error.to_string())?; - remove_legacy_demo_capture(&mut initialized).map_err(|error| error.to_string())?; - if let Err(error) = maintain_store(&mut initialized) { - emit_runtime_error(app, "capture_retention_failed", error.to_string()); - } + let initialized = match open_application_store(app) { + Ok(mut initialized) => { + remove_legacy_demo_capture(&mut initialized).map_err(|error| error.to_string())?; + if let Err(error) = maintain_store(&mut initialized) { + emit_runtime_error(app, "capture_retention_failed", error.to_string()); + } + initialized + } + Err(primary_error) => { + let recovery = open_application_recovery_store(app).map_err(|recovery_error| { + format!( + "encrypted workspace could not be opened: {primary_error}; validated update recovery is unavailable: {recovery_error}" + ) + })?; + state.capture_active.store(false, Ordering::Release); + emit_runtime_error( + app, + "update_recovery_read_only", + format!( + "the primary encrypted workspace failed to open after an update; a validated backup is open read-only: {primary_error}" + ), + ); + recovery + } + }; *store = Some(initialized); } Ok(()) } +fn store_is_read_only(store: &SharedStore) -> Result { + let store = store + .lock() + .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())?; + Ok(store.as_ref().is_some_and(EncryptedStore::is_read_only)) +} + +fn ensure_store_writable(store: &SharedStore) -> Result<(), String> { + if store_is_read_only(store)? { + Err( + "capture controls are disabled while an update recovery backup is open read-only" + .to_owned(), + ) + } else { + Ok(()) + } +} + fn remove_legacy_demo_capture( store: &mut EncryptedStore, ) -> Result { @@ -1215,10 +1327,25 @@ async fn runtime_snapshot(app: &AppHandle, state: &DesktopState) -> RuntimeSnaps system_proxy_active, proxy_session_event_count: state.proxy_session_event_count.load(Ordering::Acquire), certificate_authority: application_certificate_authority(app), + read_only_recovery: store_is_read_only(&state.store).unwrap_or(false), } } fn apply_runtime_state(workspace: &mut WorkspaceBootstrap, snapshot: RuntimeSnapshot) { + if snapshot.read_only_recovery { + workspace.source = WorkspaceSource::RecoveryBackup; + workspace.capture.active = false; + workspace.capture.can_control = false; + workspace.capture.proxy_available = false; + workspace.capture.mode = CaptureMode::Gateway; + workspace.capture.profile = "Validated update recovery backup".to_owned(); + workspace.capture.endpoint = "Capture disabled".to_owned(); + workspace.capture.storage = format!("{} / read-only recovery", workspace.capture.storage); + workspace.capture.certificate_authority = snapshot.certificate_authority; + workspace.capture.certificate_authority.can_manage_trust = false; + workspace.compatibility = recovery_read_only_compatibility(); + return; + } let (profile, endpoint) = match snapshot.mode { CaptureMode::Gateway => ( "OpenAI-compatible local gateway", @@ -2362,6 +2489,11 @@ mod tests { assert_eq!(journal["targetVersion"], "0.2.0"); assert_eq!(journal["createdAtUnixMs"], 42); assert!(!files_contain(&recovery, b"Fix the failing parser test.")); + assert_eq!( + validate_update_recovery_backup(&recovery, "0.2.0") + .expect("matching recovery journal must validate"), + recovery.join(UPDATE_RECOVERY_BACKUP_FILENAME) + ); let restored = EncryptedStore::restore_from( recovery.join(UPDATE_RECOVERY_BACKUP_FILENAME), @@ -2375,6 +2507,66 @@ mod tests { assert_eq!(restored.capture_count().expect("capture count"), 1); } + #[test] + fn update_recovery_validation_rejects_stale_tampered_and_traversal_journals() { + let directory = tempdir().expect("temp directory must be created"); + let mut store = EncryptedStore::open( + directory.path().join("captures.db"), + DatabaseKey::from_bytes([0x6A; 32]), + ) + .expect("encrypted store must open"); + seed_demo_capture(&mut store).expect("demo capture must seed"); + let store = Arc::new(Mutex::new(Some(store))); + let recovery = directory.path().join("update-recovery"); + write_update_recovery_snapshot( + &store, + &recovery, + "0.1.0".to_owned(), + "0.2.0".to_owned(), + 42, + ) + .expect("pre-update snapshot must succeed"); + let journal_path = recovery.join(UPDATE_RECOVERY_JOURNAL_FILENAME); + let valid: serde_json::Value = + serde_json::from_slice(&fs::read(&journal_path).expect("journal must be readable")) + .expect("journal must parse"); + let primary_path = directory.path().join("captures.db"); + let primary_before = fs::read(&primary_path).expect("primary database must be readable"); + + for (field, value) in [ + ("targetVersion", serde_json::json!("0.3.0")), + ("backupFile", serde_json::json!("../captures.db")), + ("backupBytes", serde_json::json!(1)), + ("schemaVersion", serde_json::json!("9.9")), + ] { + let mut invalid = valid.clone(); + invalid[field] = value; + fs::write( + &journal_path, + serde_json::to_vec_pretty(&invalid).expect("invalid journal must encode"), + ) + .expect("invalid journal must write"); + assert!( + validate_update_recovery_backup(&recovery, "0.2.0").is_err(), + "invalid {field} must be rejected" + ); + } + + let mut unknown = valid; + unknown["unexpected"] = serde_json::json!(true); + fs::write( + &journal_path, + serde_json::to_vec_pretty(&unknown).expect("unknown journal must encode"), + ) + .expect("unknown journal must write"); + assert!(validate_update_recovery_backup(&recovery, "0.2.0").is_err()); + assert_eq!( + fs::read(&primary_path).expect("primary database must remain readable"), + primary_before, + "recovery validation must never alter the failed primary database" + ); + } + #[test] fn legacy_demo_capture_is_removed_from_live_workspaces() { let directory = tempdir().expect("temp directory must be created"); @@ -2409,6 +2601,7 @@ mod tests { system_proxy_active: false, proxy_session_event_count: 0, certificate_authority: CertificateAuthority::missing(), + read_only_recovery: false, }, ); @@ -2424,6 +2617,51 @@ mod tests { ); } + #[test] + fn read_only_recovery_overrides_every_capture_control() { + let directory = tempdir().expect("temp directory must be created"); + let store = EncryptedStore::open( + directory.path().join("captures.db"), + DatabaseKey::from_bytes([0x6B; 32]), + ) + .expect("encrypted store must open"); + let mut workspace = load_workspace(&store).expect("workspace must load"); + let mut certificate_authority = CertificateAuthority::missing(); + certificate_authority.can_manage_trust = true; + + apply_runtime_state( + &mut workspace, + RuntimeSnapshot { + mode: CaptureMode::Proxy, + active: true, + proxy_available: true, + gateway_endpoint: Some("http://127.0.0.1:8787".to_owned()), + proxy_endpoint: Some("http://127.0.0.1:43125".to_owned()), + system_proxy_active: true, + proxy_session_event_count: 1, + certificate_authority, + read_only_recovery: true, + }, + ); + + assert_eq!(workspace.source, WorkspaceSource::RecoveryBackup); + assert!(!workspace.capture.active); + assert!(!workspace.capture.can_control); + assert!(!workspace.capture.proxy_available); + assert_eq!(workspace.capture.mode, CaptureMode::Gateway); + assert_eq!(workspace.capture.endpoint, "Capture disabled"); + assert!(workspace.capture.storage.contains("read-only recovery")); + assert!(!workspace.capture.certificate_authority.can_manage_trust); + assert_eq!( + workspace.compatibility.code, + codeischeap_desktop_api::CaptureCompatibilityCode::RecoveryReadOnly + ); + assert_eq!( + workspace.compatibility.action, + codeischeap_desktop_api::CompatibilityAction::None + ); + } + #[test] fn proxy_runtime_controls_workspace_capture_state() { let directory = tempdir().expect("temp directory must be created"); @@ -2456,6 +2694,7 @@ mod tests { system_proxy_active: true, proxy_session_event_count: 0, certificate_authority: certificate_authority.clone(), + read_only_recovery: false, }, ); @@ -2488,6 +2727,7 @@ mod tests { system_proxy_active: true, proxy_session_event_count: 1, certificate_authority, + read_only_recovery: false, }, ); assert_eq!( diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 2feeb20..32a37a5 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -169,6 +169,59 @@ describe("request workbench", () => { expect(within(dialog).getByText(/managed system proxy settings have been restored/)).toBeInTheDocument(); }); + it("keeps recovery history searchable and exportable while disabling mutations", async () => { + const user = userEvent.setup(); + window.__TAURI_INTERNALS__ = {}; + const workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; + workspace.source = "recovery_backup"; + workspace.capture = { + ...workspace.capture, + active: false, + canControl: false, + proxyAvailable: false, + endpoint: "Capture disabled", + storage: `${workspace.capture.storage} / read-only recovery`, + }; + const preview: ExportPreview = { + profile: "minimal", + suggestedFilename: "codeischeap-recovery-request.json", + content: "{\"recovery\":true}\n", + byteCount: 18, + contentSha256: "d".repeat(64), + exportedAtUnixMs: 1_700_000_000_200, + redactions: [], + policyVersion: "0.1", + }; + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "bootstrap_workspace") return structuredClone(workspace); + if (command === "preview_capture_export") return preview; + throw new Error(`Unexpected command: ${command}`); + }); + + const { container } = render(); + expect(await screen.findByRole("status", { name: "Read-only update recovery mode" })) + .toHaveTextContent("searchable and exportable"); + expect(screen.getByText("Read-only", { selector: ".capture-indicator" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resume capture" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Gateway" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Proxy" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "Export request" })); + expect(await screen.findByRole("dialog", { name: "Export request" })).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("preview_capture_export", { + captureId: workspace.requests[0].id, + profile: "minimal", + }); + await user.click(screen.getByRole("button", { name: "Close export" })); + await user.click(screen.getByRole("button", { name: "Settings" })); + const settings = screen.getByRole("dialog", { name: "Settings & diagnostics" }); + await user.click(within(settings).getByRole("tab", { name: "Updates" })); + expect(within(settings).getByText("Updates are disabled")).toBeInTheDocument(); + expect(within(settings).getByRole("button", { name: "Check" })).toBeDisabled(); + expect((await axe.run(container, { rules: { "color-contrast": { enabled: false } } })).violations) + .toEqual([]); + }); + it("passes automated accessibility checks for the workspace and dialogs", async () => { const user = userEvent.setup(); const { container } = render(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0943160..812fc9c 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -134,7 +134,8 @@ export function App() { ); setCaptureActive(value.capture.active); setCaptureMode(value.capture.mode); - if (value.capture.requestCount === 0 + if (value.source !== "recovery_backup" + && value.capture.requestCount === 0 && localStorage.getItem("codeischeap.first-connection.dismissed") !== "true") { setSettingsOpen(true); } @@ -345,8 +346,10 @@ export function App() { return
CLoading workspace
; } + const recoveryMode = workspace.source === "recovery_backup"; + return ( -
+
setTheme((value) => value === "light" ? "dark" : "light")} onOpenSettings={() => setSettingsOpen(true)} /> + {recoveryMode &&
+ +
Read-only recovery modeThe primary workspace could not open after an update. This validated encrypted backup remains searchable and exportable; capture and system changes are disabled.
+
}
void; onOpenSettings: () => void; }) { + const recoveryMode = source === "recovery_backup"; + const sourceLabel = source === "encrypted_local" + ? "Encrypted local workspace" + : recoveryMode ? "Recovery backup" : "Synthetic workspace"; return (
-
CCodeIsCheap{source === "encrypted_local" ? "Encrypted local workspace" : "Synthetic workspace"}
+
CCodeIsCheap{sourceLabel}
- {active ? "Capturing" : "Paused"} + {recoveryMode ? "Read-only" : active ? "Capturing" : "Paused"} @@ -523,7 +534,7 @@ function CaptureSidebar({ workspace, active, canControl, proxyAvailable, mode, m
Health
{mode === "gateway" ? "Gateway" : "Proxy"}{runtimeError ? "Issue" : "Healthy"}
-
Local storeReady
+
Local store{workspace.source === "recovery_backup" ? "Read-only" : "Ready"}
CredentialsExcluded
No request data leaves this device
diff --git a/apps/desktop/src/SettingsDialog.tsx b/apps/desktop/src/SettingsDialog.tsx index 45b28a9..30ce483 100644 --- a/apps/desktop/src/SettingsDialog.tsx +++ b/apps/desktop/src/SettingsDialog.tsx @@ -61,6 +61,7 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr const { dialogRef, initialFocusRef } = useModalDialog(onClose); const certificate = workspace.capture.certificateAuthority; const compatibility = workspace.compatibility; + const recoveryMode = workspace.source === "recovery_backup"; const canTrust = certificate.canManageTrust && certificate.state === "ready" && certificate.trust === "not_trusted"; @@ -219,8 +220,8 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr - - + + @@ -232,9 +233,12 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr
{supportPreview?.content ?? "Generating support bundle preview"}
} {tab === "updates" &&
+ {recoveryMode &&
+
Recovery lockUpdates are disabledExport required history and repair or restore the primary workspace before installing another release.
+
}
Installed version{updateStatus?.currentVersion ?? "Current build"}Stable channel · signed packages only
- +
{updateStatus &&
@@ -242,7 +246,7 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr {updateStatus.configured ? updateStatus.availableVersion ? `Version ${updateStatus.availableVersion}` : "Up to date" : "Not configured"} {updateStatus.configured ? updateStatus.notes || updateStatus.publishedAt || "No newer signed release is available." : "This build does not contain a trusted update public key."}
- {updateStatus.availableVersion && } + {updateStatus.availableVersion && }
} {updateInstalling &&
{updateProgress?.finished ? "Verifying signed package" : "Downloading update"}{formatUpdateProgress(updateProgress)}
diff --git a/apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.ts b/apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.ts index e4b5a7b..136301a 100644 --- a/apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.ts +++ b/apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CaptureCompatibilityCode = "gateway_ready" | "gateway_unavailable" | "proxy_bundle_unavailable" | "proxy_unavailable" | "certificate_missing" | "certificate_invalid" | "certificate_trust_required" | "capture_paused" | "proxy_capture_unobserved" | "proxy_capture_observed"; +export type CaptureCompatibilityCode = "gateway_ready" | "gateway_unavailable" | "proxy_bundle_unavailable" | "proxy_unavailable" | "certificate_missing" | "certificate_invalid" | "certificate_trust_required" | "capture_paused" | "recovery_read_only" | "proxy_capture_unobserved" | "proxy_capture_observed"; diff --git a/apps/desktop/src/generated/desktop-api/WorkspaceSource.ts b/apps/desktop/src/generated/desktop-api/WorkspaceSource.ts index db9c48d..7a6d694 100644 --- a/apps/desktop/src/generated/desktop-api/WorkspaceSource.ts +++ b/apps/desktop/src/generated/desktop-api/WorkspaceSource.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type WorkspaceSource = "encrypted_local" | "synthetic_fixture"; +export type WorkspaceSource = "encrypted_local" | "recovery_backup" | "synthetic_fixture"; diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index dc165e4..64b972a 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -56,6 +56,7 @@ button { color: inherit; } button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [role="separator"]:focus-visible, [role="tabpanel"]:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } .app-shell { height: 100%; display: grid; grid-template-rows: 44px minmax(0, 1fr); background: var(--bg); } +.app-shell.has-recovery-banner { grid-template-rows: 44px 52px minmax(0, 1fr); } .titlebar { display: flex; align-items: center; justify-content: space-between; min-width: 0; border-bottom: 1px solid var(--border); background: var(--surface); padding: 0 10px 0 12px; user-select: none; } .brand-lockup, .titlebar-actions, .capture-indicator { display: flex; align-items: center; } .brand-lockup { gap: 8px; min-width: 0; } @@ -66,12 +67,18 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .capture-indicator { gap: 6px; margin-right: 8px; color: var(--text-secondary); font-size: 12px; } .capture-indicator > span, .state-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-tertiary); } .capture-indicator.is-live > span, .state-dot.is-live { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.capture-indicator.is-recovery { color: var(--amber); } +.capture-indicator.is-recovery > span { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); } .icon-button { width: 30px; height: 30px; display: inline-grid; place-items: center; padding: 0; border: 0; border-radius: 5px; background: transparent; cursor: pointer; } .icon-button:hover { background: var(--surface-subtle); } .icon-button.is-active { color: var(--accent-strong); background: var(--accent-soft); } .icon-button:disabled, .segmented-control button:disabled { cursor: not-allowed; opacity: 0.45; } .icon-button:disabled:hover { background: transparent; } +.recovery-banner { min-width: 0; display: flex; align-items: center; gap: 10px; padding: 7px 14px; border-bottom: 1px solid color-mix(in srgb, var(--amber) 35%, var(--border)); background: var(--amber-soft); color: var(--amber); } +.recovery-banner > div { min-width: 0; display: grid; gap: 2px; } +.recovery-banner strong { font-size: 12px; } +.recovery-banner span { overflow: hidden; color: var(--text-secondary); font-size: 11px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } .workspace { min-width: 960px; min-height: 0; display: grid; overflow: hidden; } .capture-sidebar, .request-pane, .inspector { min-width: 0; min-height: 0; background: var(--surface); } .capture-sidebar { display: flex; flex-direction: column; overflow: auto; border-right: 0; background: var(--surface-subtle); } @@ -102,6 +109,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .system-health > div:not(.section-label) { display: grid; grid-template-columns: 17px 1fr auto; gap: 7px; align-items: center; color: var(--text-secondary); } .system-health b { font-size: 10px; } .health-ok { color: var(--accent); } +.health-warning { color: var(--amber); } .health-error { color: var(--danger); } .sidebar-footer { margin-top: auto; display: flex; gap: 7px; align-items: center; padding: 12px 14px; color: var(--text-tertiary); font-size: 10px; border-top: 1px solid var(--border); } diff --git a/apps/desktop/src/workspace.ts b/apps/desktop/src/workspace.ts index 2b1f2a9..bbff9c5 100644 --- a/apps/desktop/src/workspace.ts +++ b/apps/desktop/src/workspace.ts @@ -37,8 +37,28 @@ function browserFixture(): WorkspaceBootstrap { const workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; if (!import.meta.env.DEV) return workspace; + const parameters = new URLSearchParams(window.location.search); + if (parameters.get("recoveryMode") === "1") { + workspace.source = "recovery_backup"; + workspace.capture = { + ...workspace.capture, + active: false, + canControl: false, + proxyAvailable: false, + mode: "gateway", + profile: "Validated update recovery backup", + endpoint: "Capture disabled", + storage: `${workspace.capture.storage} / read-only recovery`, + certificateAuthority: { + ...workspace.capture.certificateAuthority, + canManageTrust: false, + }, + }; + return workspace; + } + const requestedCount = Number.parseInt( - new URLSearchParams(window.location.search).get("fixtureRequests") ?? "", + parameters.get("fixtureRequests") ?? "", 10, ); if (!Number.isInteger(requestedCount) @@ -384,8 +404,9 @@ async function fixtureSupportBundlePreview( health: { encryptedStore: workspace.capture.storage.toLocaleLowerCase().includes("sqlcipher") || workspace.source === "synthetic_fixture", - captureRuntime: runtimeIssue == null, - endpointConnected: workspace.capture.endpoint !== "Not connected", + captureRuntime: runtimeIssue == null && workspace.source !== "recovery_backup", + endpointConnected: workspace.capture.endpoint !== "Not connected" + && workspace.source !== "recovery_backup", proxyBundle: workspace.capture.proxyAvailable, }, compatibility: workspace.compatibility, diff --git a/crates/desktop-api/src/compatibility.rs b/crates/desktop-api/src/compatibility.rs index 8c33367..c6ad1ac 100644 --- a/crates/desktop-api/src/compatibility.rs +++ b/crates/desktop-api/src/compatibility.rs @@ -31,6 +31,7 @@ pub enum CaptureCompatibilityCode { CertificateInvalid, CertificateTrustRequired, CapturePaused, + RecoveryReadOnly, ProxyCaptureUnobserved, ProxyCaptureObserved, } @@ -99,6 +100,39 @@ pub fn diagnose_capture_compatibility( } } +#[must_use] +pub fn recovery_read_only_compatibility() -> CaptureCompatibility { + CaptureCompatibility { + code: CaptureCompatibilityCode::RecoveryReadOnly, + status: CaptureCompatibilityStatus::Attention, + confidence: CompatibilityConfidence::High, + title: "Read-only update recovery".to_owned(), + summary: "A validated encrypted backup is open because the primary workspace failed after an update. Capture and system changes remain disabled.".to_owned(), + recommended_mode: CaptureMode::Gateway, + action: CompatibilityAction::None, + steps: vec![ + CompatibilityStep { + id: "recovery_backup".to_owned(), + status: CompatibilityStepStatus::Pass, + label: "Encrypted recovery backup".to_owned(), + detail: "Validated and open read-only".to_owned(), + }, + CompatibilityStep { + id: "capture_lock".to_owned(), + status: CompatibilityStepStatus::Attention, + label: "Capture and system changes".to_owned(), + detail: "Disabled to preserve recovery history".to_owned(), + }, + CompatibilityStep { + id: "recovery_export".to_owned(), + status: CompatibilityStepStatus::Pass, + label: "Search and export".to_owned(), + detail: "Available for recovery and support".to_owned(), + }, + ], + } +} + struct DiagnosticSummary { code: CaptureCompatibilityCode, status: CaptureCompatibilityStatus, diff --git a/crates/desktop-api/src/export.rs b/crates/desktop-api/src/export.rs index df0daf9..169ab8b 100644 --- a/crates/desktop-api/src/export.rs +++ b/crates/desktop-api/src/export.rs @@ -179,8 +179,10 @@ pub fn build_support_bundle_preview( "health": { "encryptedStore": workspace.capture.storage.to_ascii_lowercase().contains("sqlcipher") || workspace.source == WorkspaceSource::SyntheticFixture, - "captureRuntime": runtime_issue.is_none(), - "endpointConnected": workspace.capture.endpoint != "Not connected", + "captureRuntime": runtime_issue.is_none() + && workspace.source != WorkspaceSource::RecoveryBackup, + "endpointConnected": workspace.capture.endpoint != "Not connected" + && workspace.source != WorkspaceSource::RecoveryBackup, "proxyBundle": workspace.capture.proxy_available, }, "compatibility": &workspace.compatibility, diff --git a/crates/desktop-api/src/lib.rs b/crates/desktop-api/src/lib.rs index a091062..208cd19 100644 --- a/crates/desktop-api/src/lib.rs +++ b/crates/desktop-api/src/lib.rs @@ -7,7 +7,7 @@ mod update; pub use compatibility::{ CaptureCompatibility, CaptureCompatibilityCode, CaptureCompatibilityStatus, CompatibilityAction, CompatibilityConfidence, CompatibilityStep, CompatibilityStepStatus, - diagnose_capture_compatibility, + diagnose_capture_compatibility, recovery_read_only_compatibility, }; pub use export::{ @@ -49,6 +49,7 @@ pub struct WorkspaceBootstrap { #[serde(rename_all = "snake_case")] pub enum WorkspaceSource { EncryptedLocal, + RecoveryBackup, SyntheticFixture, } diff --git a/crates/storage/src/key.rs b/crates/storage/src/key.rs index 9bcfbc1..5b9027d 100644 --- a/crates/storage/src/key.rs +++ b/crates/storage/src/key.rs @@ -40,6 +40,7 @@ pub enum KeyStoreError { InvalidIdentifier, UnsupportedPlatform, RandomUnavailable, + MissingStoredKey, InvalidStoredKey, Backend(String), } @@ -57,6 +58,7 @@ impl fmt::Display for KeyStoreError { Self::RandomUnavailable => { write!(formatter, "cryptographic randomness is unavailable") } + Self::MissingStoredKey => write!(formatter, "stored database key is unavailable"), Self::InvalidStoredKey => write!(formatter, "stored database key is invalid"), Self::Backend(_) => write!(formatter, "OS credential storage operation failed"), } @@ -66,6 +68,7 @@ impl fmt::Display for KeyStoreError { impl std::error::Error for KeyStoreError {} pub trait DatabaseKeyStore { + fn load(&self) -> Result; fn load_or_create(&self) -> Result; fn delete(&self) -> Result<(), KeyStoreError>; } @@ -98,7 +101,7 @@ impl OsKeyStore { #[cfg(any(target_os = "windows", target_os = "macos"))] impl DatabaseKeyStore for OsKeyStore { - fn load_or_create(&self) -> Result { + fn load(&self) -> Result { let entry = self.entry()?; match entry.get_secret() { Ok(mut stored) => { @@ -111,14 +114,22 @@ impl DatabaseKeyStore for OsKeyStore { stored.zeroize(); Ok(DatabaseKey::from_bytes(bytes)) } - Err(keyring::Error::NoEntry) => { + Err(keyring::Error::NoEntry) => Err(KeyStoreError::MissingStoredKey), + Err(error) => Err(KeyStoreError::Backend(error.to_string())), + } + } + + fn load_or_create(&self) -> Result { + match self.load() { + Ok(key) => Ok(key), + Err(KeyStoreError::MissingStoredKey) => { let key = DatabaseKey::generate()?; - entry + self.entry()? .set_secret(key.expose()) .map_err(|error| KeyStoreError::Backend(error.to_string()))?; Ok(key) } - Err(error) => Err(KeyStoreError::Backend(error.to_string())), + Err(error) => Err(error), } } @@ -132,6 +143,10 @@ impl DatabaseKeyStore for OsKeyStore { #[cfg(not(any(target_os = "windows", target_os = "macos")))] impl DatabaseKeyStore for OsKeyStore { + fn load(&self) -> Result { + Err(KeyStoreError::UnsupportedPlatform) + } + fn load_or_create(&self) -> Result { Err(KeyStoreError::UnsupportedPlatform) } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index ae7ffbb..1240165 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -34,6 +34,7 @@ pub enum StorageError { InvalidPageLimit, EmptySearch, InvalidBackupTarget, + ReadOnly, InvalidRetentionPolicy, DiskPressure { available_bytes: u64, @@ -70,6 +71,10 @@ impl fmt::Display for StorageError { Self::InvalidPageLimit => write!(formatter, "capture page limit is invalid"), Self::EmptySearch => write!(formatter, "capture search query is empty"), Self::InvalidBackupTarget => write!(formatter, "database backup target is invalid"), + Self::ReadOnly => write!( + formatter, + "encrypted workspace is open in read-only recovery mode" + ), Self::InvalidRetentionPolicy => { write!(formatter, "capture retention policy is invalid") } diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index b89ed02..313ed92 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -25,6 +25,7 @@ pub struct EncryptedStore { key: DatabaseKey, path: PathBuf, options: StorageOptions, + read_only: bool, } impl EncryptedStore { @@ -47,6 +48,21 @@ impl EncryptedStore { key, path, options, + read_only: false, + }) + } + + pub fn open_readonly(path: impl AsRef, key: DatabaseKey) -> Result { + let path = path.as_ref().to_path_buf(); + let connection = open_readonly_connection(&path, &key)?; + validate_schema(&connection)?; + integrity_check(&connection)?; + Ok(Self { + connection, + key, + path, + options: StorageOptions::default(), + read_only: true, }) } @@ -66,11 +82,24 @@ impl EncryptedStore { Self::open_with_options(path, key, options) } + pub fn open_readonly_with_key_store( + path: impl AsRef, + key_store: &impl DatabaseKeyStore, + ) -> Result { + let key = key_store.load()?; + Self::open_readonly(path, key) + } + #[must_use] pub fn path(&self) -> &Path { &self.path } + #[must_use] + pub const fn is_read_only(&self) -> bool { + self.read_only + } + pub fn cipher_version(&self) -> Result { cipher_version(&self.connection) } @@ -101,6 +130,7 @@ impl EncryptedStore { } pub fn upsert_captures(&mut self, writes: &[CaptureWrite<'_>]) -> Result { + self.ensure_writable()?; if writes.is_empty() { return Ok(0); } @@ -243,6 +273,7 @@ impl EncryptedStore { } pub fn delete_capture(&mut self, capture_id: &str) -> Result { + self.ensure_writable()?; let transaction = self .connection .transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -269,6 +300,7 @@ impl EncryptedStore { policy: &RetentionPolicy, now_unix_ms: u64, ) -> Result { + self.ensure_writable()?; validate_retention_policy(policy)?; let mut report = RetentionReport { deleted_by_age: 0, @@ -316,12 +348,14 @@ impl EncryptedStore { } pub fn checkpoint(&self) -> Result<(), StorageError> { + self.ensure_writable()?; self.connection .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; Ok(()) } pub fn backup_to(&self, path: impl AsRef) -> Result<(), StorageError> { + self.ensure_writable()?; let path = path.as_ref(); if path == self.path { return Err(StorageError::InvalidBackupTarget); @@ -369,8 +403,17 @@ impl EncryptedStore { key, path: destination_path, options: StorageOptions::default(), + read_only: false, }) } + + fn ensure_writable(&self) -> Result<(), StorageError> { + if self.read_only { + Err(StorageError::ReadOnly) + } else { + Ok(()) + } + } } struct PreparedCapture { diff --git a/crates/storage/tests/storage.rs b/crates/storage/tests/storage.rs index f5257e1..6630edc 100644 --- a/crates/storage/tests/storage.rs +++ b/crates/storage/tests/storage.rs @@ -9,8 +9,8 @@ use codeischeap_capture_ipc::{ use codeischeap_capture_policy::{CapturePolicy, SanitizedCapture}; use codeischeap_prompt_ir::{Evidence, MessageRole, PromptIr, PromptPart, ResponseTrace}; use codeischeap_storage::{ - CaptureCursor, CaptureWrite, DatabaseKey, EncryptedStore, RetentionPolicy, SCHEMA_VERSION, - StorageError, StorageOptions, + CaptureCursor, CaptureWrite, DatabaseKey, DatabaseKeyStore, EncryptedStore, KeyStoreError, + RetentionPolicy, SCHEMA_VERSION, StorageError, StorageOptions, }; use tempfile::tempdir; @@ -18,6 +18,22 @@ const KEY_BYTES: [u8; 32] = [0x42; 32]; const SECRET_CANARY: &str = "storage-secret-canary"; const PROMPT_CANARY: &str = "encrypted-prompt-canary migration plan"; +struct ExistingRecoveryKeyStore; + +impl DatabaseKeyStore for ExistingRecoveryKeyStore { + fn load(&self) -> Result { + Ok(DatabaseKey::from_bytes(KEY_BYTES)) + } + + fn load_or_create(&self) -> Result { + panic!("read-only recovery must never create a database key") + } + + fn delete(&self) -> Result<(), KeyStoreError> { + Ok(()) + } +} + fn sanitized_capture(id: &str, observed_at: u64, prompt: &str) -> SanitizedCapture { let policy = CapturePolicy::load_default().expect("policy must load"); let envelope = CaptureEnvelope { @@ -270,6 +286,68 @@ fn online_backup_restores_an_encrypted_searchable_database() { assert_files_do_not_contain(directory.path(), PROMPT_CANARY.as_bytes()); } +#[test] +fn encrypted_backup_opens_read_only_and_rejects_every_store_mutation() { + let directory = tempdir().expect("temp directory must be created"); + let database_path = directory.path().join("captures.db"); + let backup_path = directory.path().join("captures.pre-update.db"); + let mut writable = EncryptedStore::open(&database_path, DatabaseKey::from_bytes(KEY_BYTES)) + .expect("encrypted store must open"); + writable + .upsert_capture( + &sanitized_capture("recovery_capture", 42, "recovery marker"), + None, + ) + .expect("recovery fixture must persist"); + writable + .backup_to(&backup_path) + .expect("encrypted backup must succeed"); + drop(writable); + + let before = fs::read(&backup_path).expect("backup must be readable"); + let mut recovery = + EncryptedStore::open_readonly_with_key_store(&backup_path, &ExistingRecoveryKeyStore) + .expect("encrypted backup must open read-only with an existing key"); + + assert!(recovery.is_read_only()); + assert_eq!(recovery.capture_count().expect("capture count"), 1); + assert_eq!( + recovery + .search_captures("recovery marker", 10) + .expect("read-only FTS must work")[0] + .capture_id, + "recovery_capture" + ); + assert!(matches!( + recovery.upsert_capture( + &sanitized_capture("blocked_write", 43, "must not persist"), + None, + ), + Err(StorageError::ReadOnly) + )); + assert!(matches!( + recovery.delete_capture("recovery_capture"), + Err(StorageError::ReadOnly) + )); + assert!(matches!( + recovery.enforce_retention(&RetentionPolicy::default(), 100), + Err(StorageError::ReadOnly) + )); + assert!(matches!(recovery.checkpoint(), Err(StorageError::ReadOnly))); + assert!(matches!( + recovery.backup_to(directory.path().join("blocked.db")), + Err(StorageError::ReadOnly) + )); + drop(recovery); + + assert_eq!( + fs::read(&backup_path).expect("backup must remain readable"), + before, + "read-only access must not alter the recovery backup" + ); + assert!(!directory.path().join("blocked.db").exists()); +} + #[test] fn cursor_pagination_and_delete_are_stable() { let directory = tempdir().expect("temp directory must be created"); diff --git a/docs/progress.html b/docs/progress.html index 8b494a7..6442e7e 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -22,7 +22,7 @@

开发进度跟踪

最后更新2026-07-21
-
当前结论四厂商 Prompt IR、搜索、Compare 与安全导出已闭环;三平台桌面 IPC 已启用 OS 级用户隔离与逐连接 sidecar 身份核对。签名更新入口在安装前恢复系统代理并建立加密恢复快照;下一步完成正式签名制品、真实管理员故障矩阵、屏幕阅读器验收与插件 WASI 最小权限。
+
当前结论四厂商 Prompt IR、搜索、Compare 与安全导出已闭环;三平台桌面 IPC 已启用 OS 级用户隔离与逐连接 sidecar 身份核对。签名更新入口在安装前恢复系统代理并建立加密恢复快照,升级后主库失败可校验 journal 并只读打开历史;下一步完成正式签名制品、真实回滚演练、管理员故障矩阵、屏幕阅读器验收与插件 WASI 最小权限。

1. 更新规则

    @@ -102,7 +102,7 @@

    5. 当前迭代:S5 / S6

- + @@ -129,7 +129,7 @@

6. 后续迭代承诺

- +
TST-002Gateway/Proxy 集成测试In progress82%Codex / TBD2026-08-25认证 IPC、暂停丢弃、真实模式切换与进程树清理、压缩、流式脱敏、非目标 TLS、HTTP/2 基线一致性及 Windows CA 精确增删通过;真实 sidecar 在客户端取消后保持存活,IPC 停止消费且 72 个事件超过容量 64 时,36 个目标响应仍在 15 秒内完成;真实 ROOT 往返和其余协议一致性待扩展
TST-003平台故障注入In progress80%Codex / TBD2026-08-25Windows/macOS 强杀恢复、活 owner 防误恢复、sidecar 独立退出回退和端口冲突拒绝已通过;PAC 原状态下应用与同步回滚双重失败会交由独立 watchdog 恢复;macOS CA 拒绝恢复及 helper 协议、权限、命令转义和恢复文件校验已覆盖;授权桌面 helper 的真实故障矩阵待完成
TST-004UI、性能与可访问性In progress80%Codex / TBD2026-09-08axe、WAI-ARIA tabs、方向键、焦点循环/恢复、键盘分隔条和双主题对比度已门禁;Ubuntu Chromium 固定 960×620 明色与 1440×900 暗色像素基线,千条列表要求首屏 <2 秒、首尾滚动 <2 秒、筛选 <750 ms 且可见 DOM <40 行;Gateway fake provider 基准要求额外延迟 P95 <20 ms,最新本地基线为 0.609 ms;屏幕阅读器人工验收待完成
REL-001签名安装、升级和回滚In progress55%Codex / Release Owner TBD2026-09-22设置页可检查、下载并安装 Tauri 签名更新;未配置公钥时失败关闭,安装前强制回到 Gateway、恢复代理并写入可恢复的加密数据库快照与版本 journal。正式签名安装包、密钥托管、迁移失败回退及只读恢复演练待完成
REL-001签名安装、升级和回滚In progress70%Codex / Release Owner TBD2026-09-22设置页可检查、下载并安装 Tauri 签名更新;未配置公钥时失败关闭,安装前强制回到 Gateway、恢复代理并写入加密快照与版本 journal。主库打开或迁移失败时仅接受版本、固定文件名、大小和 schema 均匹配的快照,并以禁写模式保留搜索、支持包和脱敏导出;正式签名安装包、密钥托管与真实回滚演练待完成
REL-002诊断与支持包In progress75%Codex / Support Owner TBD2026-09-22版本化 JSON 支持包可预览、复制和保存,包含不带请求标识的兼容诊断树;256 KiB code-only journal 与最近 100 条事件接入,排除 Prompt、Raw 和日志详情;支持处理流程待完成
SPIKE-001Gateway 流式透明转发验证Done100%Codex / TBD2026-07-14双向流式、取消传递、头清理与稳定 502 集成测试通过
SPIKE-002mitmproxy sidecar IPC/打包验证Done100%Codex / TBD2026-07-14凭据清理、IPC、打包与真实转发通过
S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressSEC-002、CAP-007 完成,IPC 抗阻塞、双模式 OS PID 归因、Unix owner-only socket、Windows owner-only named pipe、readiness 身份校验、PAC/CA 拒绝恢复、macOS 授权 helper 和兼容诊断树已接入;继续真实故障验收与 WASI 最小权限
S6PAR-005~007、APP-005Done四厂商适配器、token/成本/指纹、全文搜索和结构/文本 Compare 全部完成
S7TST-004、性能、可访问性、诊断与保留In progressaxe、键盘与焦点、双主题截图、千条列表预算及 Gateway P95 门禁完成;继续屏幕阅读器和 Beta 真实环境测量
S8SEC-004、REL-001/002、安全评审In progress仓库供应链门禁、签名更新入口、预更新网络恢复与加密快照、code-only 支持包已接入;正式签名制品、回滚演练、支持流程与安全评审待推进
S8SEC-004、REL-001/002、安全评审In progress仓库供应链门禁、签名更新入口、预更新网络恢复与加密快照、失败后只读历史恢复、code-only 支持包已接入;正式签名制品、真实回滚演练、支持流程与安全评审待推进
S9REL-003、完整回归与发布演练Not started阻断项清零
diff --git a/docs/threat-model.html b/docs/threat-model.html index d320321..f33a8a5 100644 --- a/docs/threat-model.html +++ b/docs/threat-model.html @@ -49,7 +49,7 @@

威胁台账

TM-04数据库文件被离线读取SQLCipher、WAL、迁移、加密备份、错误密钥、DB/WAL canary 与跨平台 OS 凭据库 CI锁定状态与 key 丢失恢复体验 TM-05本地 CA 私钥泄露或残留信任每设备 CA、跨平台私钥权限检查、指纹 UI、Windows CurrentUser 与 macOS User 幂等卸载已实现;macOS 信任拒绝会清理本次新增证书,桌面保持未信任并允许重试两平台交互式验收;发布前独立安全评审 TM-06崩溃后系统代理残留Windows/macOS 强杀恢复实验;Windows 桌面已接入私有 journal、watchdog、启动恢复和活 owner 校验;sidecar 单独退出会按代际恢复代理并回退 Gateway;端口占用者无法通过独立 256-bit readiness token 校验,冲突启动会清理进程树且不会切换系统代理;PAC 双重失败由 watchdog 恢复;macOS 使用静态逐参数 quote 的授权命令启动受校验可执行文件,helper 控制 socket 分别校验用户 owner 与 root peer,owner 断连、helper 崩溃和下次启动均进入独立恢复链macOS 授权桌面 helper 的真实管理员交互与强杀故障矩阵 - TM-07依赖、sidecar 或更新包被替换CI Action 固定完整 SHA;直接依赖、锁文件、artifact integrity、CODEOWNERS 与 Dependabot 覆盖由策略脚本校验;PR 拒绝新增高危漏洞,diff、time 与 serde_with 已升级到安全版本;sidecar manifest、SBOM 与启动前 hash 校验已接入。Tauri updater 仅接受编译时可信公钥签名的 HTTPS release,安装前重新核对版本、恢复代理并生成加密数据库恢复快照正式公钥托管、平台签名、迁移失败回滚和发布演练;持续跟踪 Tauri Linux GTK3 链的 glib 上游修复;发布硬门槛 + TM-07依赖、sidecar 或更新包被替换CI Action 固定完整 SHA;直接依赖、锁文件、artifact integrity、CODEOWNERS 与 Dependabot 覆盖由策略脚本校验;PR 拒绝新增高危漏洞,diff、time 与 serde_with 已升级到安全版本;sidecar manifest、SBOM 与启动前 hash 校验已接入。Tauri updater 仅接受编译时可信公钥签名的 HTTPS release,安装前重新核对版本、恢复代理并生成加密数据库恢复快照;主库升级失败时仅接受目标版本、固定文件名、字节数和 schema 均匹配的非符号链接快照,并在存储边界拒绝写入正式公钥托管、平台签名、真实回滚和发布演练;持续跟踪 Tauri Linux GTK3 链的 glib 上游修复;发布硬门槛 TM-08复制或导出泄露秘密单请求、最多 200 条批量导出及支持包均强制扫描与完整预览;版本化 corpus 覆盖 36 个敏感字段名、17 类文本 secret 及近似反例,Rust/Python/Web 一致性回归通过;稳定占位符、策略版本、SHA-256 防陈旧和只新建文件已实现独立安全评审与 corpus 持续维护 TM-09恶意 Prompt 触发 UI 注入React 默认转义、Tauri CSPRaw 仅纯文本、URL 不自动执行、XSS fixture TM-10超大/高速流量耗尽内存或磁盘认证帧 1 KiB、Envelope 4 MiB、sidecar 有界队列、IPC 连接截止时间、存储配额与磁盘压力暂停完善丢弃/降级指标与高压故障注入 diff --git a/schemas/desktop-api/v0.1.schema.json b/schemas/desktop-api/v0.1.schema.json index f70f7e8..89af214 100644 --- a/schemas/desktop-api/v0.1.schema.json +++ b/schemas/desktop-api/v0.1.schema.json @@ -168,6 +168,7 @@ "certificate_invalid", "certificate_trust_required", "capture_paused", + "recovery_read_only", "proxy_capture_unobserved", "proxy_capture_observed" ] @@ -629,6 +630,7 @@ "type": "string", "enum": [ "encrypted_local", + "recovery_backup", "synthetic_fixture" ] }