diff --git a/CLAUDE.md b/CLAUDE.md index ab75119..39d3024 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -552,6 +552,40 @@ survived 92 commits and fourteen days in the public GitHub mirror, past five aud independent reviews, because every one of them read the code under change and this sat in a test nobody had reason to open. Fixtures are never live values; there is no case where they need to be. +## Settings export/import + +`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export` +(triple-c#35). Exports the *host* environment — global `AppSettings` (already the non-secret +shape persisted to `settings.json`) plus the global secrets that live in the OS keychain instead: +the shared Claude Code OAuth login and the model gateway's two keys. Per-project settings, +per-project secrets, and anything in a project's Docker volumes are deliberately out of scope — +this is not a project backup. + +- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives + a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC + brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual + encryption. A wrong password fails GCM's authentication tag rather than producing silent + garbage. The salt and nonce are not secret and are written in the clear in the file's own + header — the salt's job is only to make two exports of the same password derive different keys, + and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on + every export already gives it. +- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s + `pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog + handing Rust a host path string is the exact shape of bug that produced this app's past + criticals. `preview_settings_import` resolves the chosen path itself and remembers it + (`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without + a path ever crossing back over IPC. +- **The password is re-entered, not cached, between preview and apply.** Nothing here holds + decrypted plaintext — secrets included — in memory for longer than one command's execution. + `preview_settings_import` returns counts and presence flags only (`SettingsImportPreview`), + never a secret value, so it's safe to hand to the frontend and render directly. +- **Import replaces settings wholesale, but only writes secrets actually present in the file.** + An import is "restore this environment," so the settings half is a full replace, not a + field-by-field merge. Secrets are different on purpose: an absent secret in the export means + "the source machine never had this configured," not "delete this on import" — a user who wants + to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the + gateway key). + ## Testing Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file: diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index c75619d..b5b92bd 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,6 +82,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -280,6 +327,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -310,6 +363,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -569,6 +631,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -753,6 +826,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.20.11" @@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1550,6 +1633,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -2114,6 +2207,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2831,6 +2933,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -2913,6 +3021,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -3194,6 +3313,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -5149,6 +5280,8 @@ dependencies = [ name = "triple-c" version = "0.4.0" dependencies = [ + "aes-gcm", + "argon2", "axum", "base64 0.22.1", "bollard", @@ -5287,6 +5420,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index b97b6cb..88bad73 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -36,6 +36,8 @@ tower-http = { version = "0.6", features = ["cors"] } base64 = "0.22" rand = "0.9" local-ip-address = "0.6" +argon2 = "0.5" +aes-gcm = "0.10" [dev-dependencies] # `test-util` (not part of tokio's `full`) lets the auto-start retry tests run diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index bd2ecfc..cf2fb81 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod install_helper_commands; pub mod migration_commands; pub mod project_commands; pub mod settings_commands; +pub mod settings_export_commands; pub mod stt_commands; pub mod terminal_commands; pub mod update_commands; diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs new file mode 100644 index 0000000..ebebc81 --- /dev/null +++ b/app/src-tauri/src/commands/settings_export_commands.rs @@ -0,0 +1,232 @@ +//! Settings export/import — see triple-c#35. +//! +//! Exports the *host* environment (global `AppSettings` plus the global +//! secrets kept in the OS keychain: the shared Claude Code OAuth login and +//! the model gateway's two keys), encrypted with a user-chosen password — +//! see `storage::settings_crypto` for the actual cryptography. Deliberately +//! out of scope: per-project settings, per-project secrets, and anything +//! living in a project's Docker volumes. +//! +//! **The save/open dialogs are opened from Rust**, the same pattern +//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already +//! establish and document at length: a frontend-driven dialog handing Rust a +//! host path string is the exact shape of bug that produced this app's past +//! criticals, so the boundary here is drawn the same place. The frontend can +//! ask for a picker; it cannot name a host path as an *input*. `preview_ +//! settings_import` resolves the chosen path itself and remembers it +//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads +//! the same file without the path ever crossing back over IPC. +//! +//! The password is re-entered (not cached) between preview and apply, so +//! that nothing here holds decrypted plaintext — export/import secrets +//! included — in memory for longer than one command's execution. + +use std::path::PathBuf; + +use tauri::State; +use tauri_plugin_dialog::DialogExt; + +use crate::models::{ + ExportedSecrets, SettingsExportPayload, SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION, +}; +use crate::storage::{secure, settings_crypto}; +use crate::AppState; + +const FILE_EXTENSION: &str = "triplec"; + +fn suggested_export_name() -> String { + // Timestamped so exporting more than once doesn't silently overwrite an + // earlier file just because the save dialog defaults to the same name. + format!( + "triple-c-settings-{}.{}", + chrono::Utc::now().format("%Y%m%d-%H%M%S"), + FILE_EXTENSION + ) +} + +async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option { + let (tx, rx) = tokio::sync::oneshot::channel(); + window + .dialog() + .file() + .set_parent(window) + .set_title("Export Triple-C settings") + .set_file_name(suggested) + .add_filter("Triple-C settings export", &[FILE_EXTENSION]) + .save_file(move |picked| { + let _ = tx.send(picked); + }); + rx.await.ok().flatten().and_then(|p| p.into_path().ok()) +} + +async fn pick_import_open_path(window: &tauri::Window) -> Option { + let (tx, rx) = tokio::sync::oneshot::channel(); + window + .dialog() + .file() + .set_parent(window) + .set_title("Import Triple-C settings") + .add_filter("Triple-C settings export", &[FILE_EXTENSION]) + .pick_file(move |picked| { + let _ = tx.send(picked); + }); + rx.await.ok().flatten().and_then(|p| p.into_path().ok()) +} + +/// Gather the current global secrets. A missing secret reads as `None` — a +/// keychain read failure is treated as "nothing to export" for that one +/// entry rather than aborting the whole export, matching how the rest of +/// this app degrades a keychain error to "absent" (`has_claude_oauth_token`, +/// `has_gateway_api_key`) rather than surfacing it as a hard failure. +fn gather_secrets() -> ExportedSecrets { + ExportedSecrets { + claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(), + gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(), + gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(), + } +} + +/// Export the current global settings and secrets to a password-encrypted +/// file. `Ok(false)` means the save dialog was dismissed — not an error, and +/// deliberately distinguishable from one so the frontend shows nothing +/// rather than a "failed" toast for a plain cancel. +#[tauri::command] +pub async fn export_settings( + password: String, + window: tauri::Window, + state: State<'_, AppState>, +) -> Result { + if password.is_empty() { + return Err("A password is required to export settings.".to_string()); + } + + let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else { + return Ok(false); + }; + + let secrets = gather_secrets(); + if secrets.is_empty() { + log::info!("Exporting settings with no global secrets configured on this machine"); + } + + let payload = SettingsExportPayload { + format_version: SETTINGS_EXPORT_FORMAT_VERSION, + exported_at: chrono::Utc::now().to_rfc3339(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + settings: state.settings_store.get(), + secrets, + }; + + let plaintext = serde_json::to_vec(&payload) + .map_err(|e| format!("Failed to prepare settings for export: {}", e))?; + let encrypted = settings_crypto::encrypt(&plaintext, &password)?; + + std::fs::write(&dest, encrypted).map_err(|e| format!("Failed to write export file: {}", e))?; + + Ok(true) +} + +/// Open a file picker, decrypt the chosen file with `password`, and return a +/// preview (counts and presence flags only — never a secret value) for a +/// confirmation UI. `Ok(None)` means the picker was dismissed. +/// +/// Remembers the resolved path in `AppState::pending_settings_import` for +/// `apply_settings_import` to re-read; does **not** remember the decrypted +/// payload itself, so the password must be supplied again to actually apply +/// it — seeing the preview is not the same as committing to it. +#[tauri::command] +pub async fn preview_settings_import( + password: String, + window: tauri::Window, + state: State<'_, AppState>, +) -> Result, String> { + if password.is_empty() { + return Err("A password is required to open a settings export.".to_string()); + } + + let Some(path) = pick_import_open_path(&window).await else { + return Ok(None); + }; + + let payload = read_and_decrypt(&path, &password)?; + let preview = SettingsImportPreview::from_payload(&payload); + + *state.pending_settings_import.lock().await = Some(path); + + Ok(Some(preview)) +} + +/// Apply the import a prior `preview_settings_import` call resolved a path +/// for. Fails if no preview is pending — this is not a general "decrypt and +/// apply this file" entry point, deliberately: seeing the preview first is +/// required, not just encouraged, since it is the only place a user is told +/// what an import is about to touch before it touches it. +/// +/// Global settings are replaced wholesale — an import is "restore this +/// environment," not a field-by-field merge. Global secrets are handled +/// differently and on purpose: **only secrets actually present in the +/// import are written**; a secret the export doesn't have is left alone on +/// this machine rather than cleared, because an absent secret in the export +/// means "the source machine never had this configured," not "delete this +/// on import." A user who wants to clear a secret already has dedicated UI +/// for that (signing out of shared auth, clearing the gateway key). +#[tauri::command] +pub async fn apply_settings_import( + password: String, + state: State<'_, AppState>, +) -> Result { + if password.is_empty() { + return Err("A password is required to import settings.".to_string()); + } + + let path = state + .pending_settings_import + .lock() + .await + .take() + .ok_or_else(|| "No import is pending — choose a file first.".to_string())?; + + let payload = read_and_decrypt(&path, &password)?; + + let saved = crate::commands::settings_commands::update_settings(payload.settings, state).await?; + + if let Some(token) = non_blank(payload.secrets.claude_oauth_token) { + if let Err(e) = secure::store_claude_oauth_token(&token) { + log::warn!("Settings import: could not restore the shared Claude login: {}", e); + } + } + if let Some(key) = non_blank(payload.secrets.gateway_api_key) { + if let Err(e) = secure::store_gateway_api_key(&key) { + log::warn!("Settings import: could not restore the gateway provider API key: {}", e); + } + } + if let Some(key) = non_blank(payload.secrets.gateway_master_key) { + if let Err(e) = secure::store_gateway_master_key(&key) { + log::warn!("Settings import: could not restore the gateway master key: {}", e); + } + } + + Ok(saved) +} + +fn non_blank(value: Option) -> Option { + value.filter(|v| !v.trim().is_empty()) +} + +fn read_and_decrypt(path: &std::path::Path, password: &str) -> Result { + let encrypted = std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?; + let plaintext = settings_crypto::decrypt(&encrypted, password)?; + + let payload: SettingsExportPayload = serde_json::from_slice(&plaintext) + .map_err(|e| format!("This file doesn't look like a valid settings export: {}", e))?; + + if payload.format_version > SETTINGS_EXPORT_FORMAT_VERSION { + return Err(format!( + "This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \ + Update Triple-C before importing it.", + payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION + )); + } + + Ok(payload) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 2bbe8f1..679c3df 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -29,6 +29,15 @@ pub struct AppState { pub auth_bridge: Arc, pub web_terminal_server: Arc>>, pub lifecycle: Arc, + /// The file `preview_settings_import` last decrypted successfully, held + /// so `apply_settings_import` can re-read and re-decrypt the same file + /// without the frontend ever passing a host path back to Rust as an + /// argument — see the doc comment on `commands::settings_export_commands` + /// for why that direction specifically is the one this app treats as + /// dangerous. Deliberately re-decrypted rather than cached in plaintext: + /// nothing here holds a decrypted secret in memory for longer than one + /// command's execution. + pub pending_settings_import: Arc>>, } // ───────────────────────────────────────────────────────────────────────────── @@ -222,6 +231,7 @@ pub fn run() { auth_bridge, web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), lifecycle, + pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)), }) .setup(move |app| { match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) { @@ -494,6 +504,10 @@ pub fn run() { commands::settings_commands::inspect_ca_cert_path, commands::settings_commands::list_aws_profiles, commands::settings_commands::detect_host_timezone, + // Settings export/import + commands::settings_export_commands::export_settings, + commands::settings_export_commands::preview_settings_import, + commands::settings_export_commands::apply_settings_import, // Terminal commands::terminal_commands::open_terminal_session, commands::terminal_commands::terminal_input, diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index e442ae7..a868bff 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -3,6 +3,7 @@ pub mod container_config; pub mod app_settings; pub mod gateway_settings; pub mod migration; +pub mod settings_export; pub mod update_info; pub use project::*; @@ -10,4 +11,5 @@ pub use container_config::*; pub use app_settings::*; pub use gateway_settings::*; pub use migration::*; +pub use settings_export::*; pub use update_info::*; diff --git a/app/src-tauri/src/models/settings_export.rs b/app/src-tauri/src/models/settings_export.rs new file mode 100644 index 0000000..0e71df8 --- /dev/null +++ b/app/src-tauri/src/models/settings_export.rs @@ -0,0 +1,180 @@ +//! Settings export/import — see triple-c#35. +//! +//! `SettingsExportPayload` is the whole plaintext export before encryption +//! and after decryption (see `storage::settings_crypto`). It bundles +//! `AppSettings` (already the non-secret shape persisted to `settings.json`) +//! with the global secrets that live in the OS keychain instead — the shared +//! Claude Code OAuth login and the model gateway's two keys. Per-project +//! settings, per-project secrets, and anything living in a project's Docker +//! volumes are deliberately out of scope: this exports the *host* +//! environment, not any one project's. + +use serde::{Deserialize, Serialize}; + +use super::AppSettings; + +/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that +/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a +/// field is ever removed or its meaning changes. `apply_settings_import` +/// checks this before touching anything. +pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1; + +/// The global secrets bundled into an export. Deliberately a separate struct +/// from `AppSettings`: these live in the OS keychain, never in +/// `settings.json`, and — outside of this export/import flow — the values +/// themselves never cross into the frontend; see the doc comments on +/// `storage::secure::get_gateway_api_key` and +/// `commands::settings_export_commands` for why that boundary matters here +/// too. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExportedSecrets { + #[serde(default)] + pub claude_oauth_token: Option, + #[serde(default)] + pub gateway_api_key: Option, + #[serde(default)] + pub gateway_master_key: Option, +} + +impl ExportedSecrets { + pub fn is_empty(&self) -> bool { + self.claude_oauth_token.is_none() + && self.gateway_api_key.is_none() + && self.gateway_master_key.is_none() + } +} + +/// The full plaintext payload — this is what gets encrypted on export and +/// what decryption recovers on import. Never written to disk unencrypted; +/// see `storage::settings_crypto`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsExportPayload { + pub format_version: u32, + /// RFC3339. Purely informational — shown in the import preview so a user + /// picking between a few old export files has something to go on. + pub exported_at: String, + /// The exporting app's `CARGO_PKG_VERSION`. Also informational: every + /// field below already round-trips through `#[serde(default)]`-covered + /// `AppSettings`, so an older or newer export still deserializes; this is + /// for a human to notice "this is from a much older version" if an import + /// ever looks wrong, not something the code branches on. + pub app_version: String, + pub settings: AppSettings, + #[serde(default)] + pub secrets: ExportedSecrets, +} + +/// What `preview_settings_import` hands the frontend before anything is +/// applied — counts and presence flags only, **never** a secret value itself, +/// so this type is safe to return across the IPC boundary and render +/// directly. The confirmation UI is built from this. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsImportPreview { + pub exported_at: String, + pub app_version: String, + pub custom_env_var_count: usize, + pub gateway_model_count: usize, + pub has_claude_code_settings: bool, + pub has_claude_oauth_token: bool, + pub has_gateway_api_key: bool, + pub has_gateway_master_key: bool, +} + +impl SettingsImportPreview { + pub fn from_payload(payload: &SettingsExportPayload) -> Self { + Self { + exported_at: payload.exported_at.clone(), + app_version: payload.app_version.clone(), + custom_env_var_count: payload.settings.global_custom_env_vars.len(), + gateway_model_count: payload.settings.gateway.models.len(), + has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(), + has_claude_oauth_token: payload + .secrets + .claude_oauth_token + .as_deref() + .is_some_and(|t| !t.trim().is_empty()), + has_gateway_api_key: payload + .secrets + .gateway_api_key + .as_deref() + .is_some_and(|k| !k.trim().is_empty()), + has_gateway_master_key: payload + .secrets + .gateway_master_key + .as_deref() + .is_some_and(|k| !k.trim().is_empty()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::AppSettings; + + fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload { + let mut settings = AppSettings::default(); + settings.global_custom_env_vars = vec![ + crate::models::EnvVar { key: "A".to_string(), value: "1".to_string() }, + crate::models::EnvVar { key: "B".to_string(), value: "2".to_string() }, + ]; + SettingsExportPayload { + format_version: SETTINGS_EXPORT_FORMAT_VERSION, + exported_at: "2026-08-27T00:00:00Z".to_string(), + app_version: "0.4.14".to_string(), + settings, + secrets, + } + } + + #[test] + fn the_preview_never_carries_a_secret_value() { + let payload = payload_with(ExportedSecrets { + claude_oauth_token: Some("sk-super-secret-token".to_string()), + gateway_api_key: Some("sk-another-secret".to_string()), + gateway_master_key: Some("sk-triple-c-yet-another".to_string()), + }); + let preview = SettingsImportPreview::from_payload(&payload); + let serialized = serde_json::to_string(&preview).unwrap(); + + assert!(!serialized.contains("sk-super-secret-token")); + assert!(!serialized.contains("sk-another-secret")); + assert!(!serialized.contains("sk-triple-c-yet-another")); + assert!(preview.has_claude_oauth_token); + assert!(preview.has_gateway_api_key); + assert!(preview.has_gateway_master_key); + } + + #[test] + fn a_blank_secret_reads_as_absent_in_the_preview() { + // A keychain entry that exists but holds only whitespace must not + // read as "present" — same "blank counts as absent" rule the + // keychain layer itself applies when storing these. + let payload = payload_with(ExportedSecrets { + claude_oauth_token: Some(" ".to_string()), + gateway_api_key: None, + gateway_master_key: None, + }); + let preview = SettingsImportPreview::from_payload(&payload); + assert!(!preview.has_claude_oauth_token); + assert!(!preview.has_gateway_api_key); + assert!(!preview.has_gateway_master_key); + } + + #[test] + fn counts_reflect_the_real_settings() { + let payload = payload_with(ExportedSecrets::default()); + let preview = SettingsImportPreview::from_payload(&payload); + assert_eq!(preview.custom_env_var_count, 2); + } + + #[test] + fn an_empty_secrets_bundle_reports_itself_as_empty() { + assert!(ExportedSecrets::default().is_empty()); + assert!(!ExportedSecrets { + claude_oauth_token: Some("x".to_string()), + ..Default::default() + } + .is_empty()); + } +} diff --git a/app/src-tauri/src/storage/mod.rs b/app/src-tauri/src/storage/mod.rs index 151759b..fda731f 100644 --- a/app/src-tauri/src/storage/mod.rs +++ b/app/src-tauri/src/storage/mod.rs @@ -2,6 +2,7 @@ pub mod migration_store; pub mod pending_cleanup; pub mod projects_store; pub mod secure; +pub mod settings_crypto; pub mod settings_store; #[allow(unused_imports)] diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index a1c7e86..081dbf6 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -321,28 +321,52 @@ pub fn delete_gateway_api_key() -> Result<(), String> { /// only enforces auth when a master key is configured, so Triple-C always /// configures one. pub fn get_or_create_gateway_master_key() -> Result { - if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? { - if !existing.trim().is_empty() { - return Ok(existing); - } + if let Some(existing) = get_gateway_master_key()? { + return Ok(existing); } regenerate_gateway_master_key() } +/// Read the gateway master key without minting one if none exists yet. +/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side +/// effect the read half of that function must not have — settings export +/// (triple-c#35) needs "is there one, and if so what is it", not "make sure +/// one exists". +pub fn get_gateway_master_key() -> Result, String> { + Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? + .filter(|k| !k.trim().is_empty())) +} + /// Mint a new gateway master key, invalidating the old one. Projects using the /// previous value must be updated. pub fn regenerate_gateway_master_key() -> Result { // LiteLLM requires the master key to start with `sk-`. let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple()); + store_gateway_master_key(&key)?; + Ok(key) +} + +/// Store an exact given gateway master key, replacing any previous one. +/// +/// Distinct from [`regenerate_gateway_master_key`], which always mints a +/// fresh random value: this exists for settings import (triple-c#35), where +/// restoring the *same* key an export captured is the point — projects on +/// the destination machine may not exist yet, but a project migrated or +/// re-added later that still has the old key pasted into its config must +/// keep working against it. Blank input is rejected rather than silently +/// stored, matching every other `store_*` function in this module. +pub fn store_gateway_master_key(key: &str) -> Result<(), String> { + if key.trim().is_empty() { + return Err("Refusing to store an empty gateway master key.".to_string()); + } let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT) .map_err(|e| format!("Keyring error: {}", e))?; entry - .set_password(&key) + .set_password(key.trim()) .map_err(|e| format!("Failed to store the gateway master key: {}", e))?; - bump_gateway_secret_version()?; - Ok(key) + bump_gateway_secret_version() } diff --git a/app/src-tauri/src/storage/settings_crypto.rs b/app/src-tauri/src/storage/settings_crypto.rs new file mode 100644 index 0000000..499e7d4 --- /dev/null +++ b/app/src-tauri/src/storage/settings_crypto.rs @@ -0,0 +1,158 @@ +//! Password-based encryption for the settings export/import file — see +//! triple-c#35. +//! +//! The exported payload can carry live credentials (the shared Claude OAuth +//! token, the gateway provider/master keys — see +//! `commands::settings_export_commands`), so this is not encryption for its +//! own sake; a wrong or missing key here is a real credential leak, not a +//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory- +//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at +//! any reasonable iteration count is not), and AES-256-GCM is what actually +//! encrypts — authenticated, so a wrong password is detected by a failed tag +//! check rather than producing silent garbage. +//! +//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) | +//! ciphertext+tag`. The salt and nonce are not secret — they are written in +//! the clear right here, on purpose. The salt's only job is to make two +//! exports with the same password derive different keys (defeats a +//! precomputed-table attack against the password alone); the nonce's job is +//! GCM's requirement that a (key, nonce) pair never repeat. Both hold +//! because a fresh random value is drawn for each, on every call to +//! [`encrypt`]. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use argon2::{Algorithm, Argon2, Params, Version}; +use rand::RngCore; + +/// Identifies the file as a Triple-C settings export and pins the format — +/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a +/// new magic value, not a silent reinterpretation of old bytes. +const MAGIC: &[u8; 4] = b"TCX1"; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; +const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN; + +/// Argon2id parameters: memory cost in KiB, time cost (iterations), +/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation +/// for Argon2id — deliberately heavier than a login-flow KDF would use, since +/// this runs once per export/import rather than on every request, so trading +/// roughly a second of wall time for real brute-force resistance costs +/// nothing a user would notice. +fn argon2_params() -> Params { + Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid") +} + +fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LEN], String> { + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params()); + let mut key = [0u8; KEY_LEN]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| format!("Failed to derive encryption key: {}", e))?; + Ok(key) +} + +/// Encrypt `plaintext` with a key derived from `password`. Returns the whole +/// file's bytes (header + ciphertext) — see the module doc for the layout. +pub fn encrypt(plaintext: &[u8], password: &str) -> Result, String> { + let mut salt = [0u8; SALT_LEN]; + rand::rng().fill_bytes(&mut salt); + let key = derive_key(password, &salt)?; + + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("Failed to initialize cipher: {}", e))?; + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| format!("Encryption failed: {}", e))?; + + let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&salt); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Decrypt a file produced by [`encrypt`]. The one error this returns for a +/// wrong password is deliberately generic ("wrong password, or the file is +/// corrupted") rather than distinguishing the two: GCM's authentication tag +/// fails to verify for the wrong key on essentially any ciphertext, so there +/// is no reliable way to tell "wrong password" from "corrupted file" apart, +/// and guessing would be worse than saying so. +pub fn decrypt(data: &[u8], password: &str) -> Result, String> { + if data.len() < HEADER_LEN { + return Err("This does not look like a Triple-C settings export (file too short).".to_string()); + } + if &data[..MAGIC.len()] != MAGIC { + return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string()); + } + let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN]; + let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN]; + let ciphertext = &data[HEADER_LEN..]; + + let key = derive_key(password, salt)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("Failed to initialize cipher: {}", e))?; + let nonce = Nonce::from_slice(nonce_bytes); + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| "Wrong password, or the file is corrupted.".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_round_trip_with_the_right_password_recovers_the_plaintext() { + let plaintext = b"{\"settings\": \"whatever\"}"; + let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap(); + let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn the_wrong_password_fails_rather_than_returning_garbage() { + let encrypted = encrypt(b"secret payload", "correct password").unwrap(); + let result = decrypt(&encrypted, "wrong password"); + assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed"); + } + + #[test] + fn two_exports_of_the_same_plaintext_and_password_produce_different_files() { + // If this ever failed it would mean the salt or nonce stopped being + // randomized — either one repeating is a real security regression + // (a fixed salt lets an attacker precompute against the password + // alone; a repeated (key, nonce) pair breaks GCM's guarantees + // outright), not just a cosmetic one. + let a = encrypt(b"same plaintext", "same password").unwrap(); + let b = encrypt(b"same plaintext", "same password").unwrap(); + assert_ne!(a, b, "two independent exports must not be byte-identical"); + } + + #[test] + fn corrupting_a_single_byte_of_ciphertext_is_detected() { + let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap(); + let last = encrypted.len() - 1; + encrypted[last] ^= 0xFF; + assert!(decrypt(&encrypted, "a password").is_err()); + } + + #[test] + fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() { + assert!(decrypt(b"short", "any password").is_err()); + assert!(decrypt(b"", "any password").is_err()); + } + + #[test] + fn a_file_with_the_wrong_magic_is_rejected() { + let mut encrypted = encrypt(b"payload", "password").unwrap(); + encrypted[0] = b'X'; + assert!(decrypt(&encrypted, "password").is_err()); + } +} diff --git a/app/src/components/settings/ExportSettingsModal.test.tsx b/app/src/components/settings/ExportSettingsModal.test.tsx new file mode 100644 index 0000000..c8e85f7 --- /dev/null +++ b/app/src/components/settings/ExportSettingsModal.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ExportSettingsModal from "./ExportSettingsModal"; + +const exportSettings = vi.fn(); + +vi.mock("../../lib/tauri-commands", () => ({ + exportSettings: (password: string) => exportSettings(password), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function fillPasswords(password: string, confirm: string) { + fireEvent.change(screen.getByLabelText("Password"), { target: { value: password } }); + fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: confirm } }); +} + +describe("ExportSettingsModal", () => { + it("keeps the submit button disabled until the passwords are long enough and match", () => { + render(); + const submit = screen.getByRole("button", { name: /choose where to save/i }); + expect(submit).toBeDisabled(); + + fillPasswords("short", "short"); + expect(submit).toBeDisabled(); + expect(screen.getByText(/use at least 8 characters/i)).toBeInTheDocument(); + + fillPasswords("longenoughpassword", "different"); + expect(submit).toBeDisabled(); + expect(screen.getByText(/don't match/i)).toBeInTheDocument(); + + fillPasswords("longenoughpassword", "longenoughpassword"); + expect(submit).not.toBeDisabled(); + }); + + it("exports with the entered password and shows success", async () => { + exportSettings.mockResolvedValue(true); + render(); + + fillPasswords("longenoughpassword", "longenoughpassword"); + fireEvent.click(screen.getByRole("button", { name: /choose where to save/i })); + + await waitFor(() => expect(exportSettings).toHaveBeenCalledWith("longenoughpassword")); + await waitFor(() => expect(screen.getByText(/settings exported/i)).toBeInTheDocument()); + }); + + it("closes quietly when the save dialog is dismissed", async () => { + exportSettings.mockResolvedValue(false); + const onClose = vi.fn(); + render(); + + fillPasswords("longenoughpassword", "longenoughpassword"); + fireEvent.click(screen.getByRole("button", { name: /choose where to save/i })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(screen.queryByText(/settings exported/i)).not.toBeInTheDocument(); + }); + + it("shows an error rather than closing when the export fails", async () => { + exportSettings.mockRejectedValue("Disk is full"); + const onClose = vi.fn(); + render(); + + fillPasswords("longenoughpassword", "longenoughpassword"); + fireEvent.click(screen.getByRole("button", { name: /choose where to save/i })); + + await waitFor(() => expect(screen.getByText("Disk is full")).toBeInTheDocument()); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/settings/ExportSettingsModal.tsx b/app/src/components/settings/ExportSettingsModal.tsx new file mode 100644 index 0000000..cd1d70a --- /dev/null +++ b/app/src/components/settings/ExportSettingsModal.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import Field, { inputClass } from "../ui/Field"; +import { exportSettings } from "../../lib/tauri-commands"; + +interface Props { + onClose: () => void; +} + +const MIN_PASSWORD_LENGTH = 8; + +/** + * Password entry for exporting global settings. The save dialog itself opens + * from Rust once a password is confirmed here — see the doc comment on + * `commands::settings_export_commands` for why the host path never + * round-trips through this component. + */ +export default function ExportSettingsModal({ onClose }: Props) { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const mismatch = confirmPassword.length > 0 && password !== confirmPassword; + const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH; + const canSubmit = password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword; + + const handleExport = async () => { + setError(null); + setBusy(true); + try { + const saved = await exportSettings(password); + if (saved) setDone(true); + // `false` means the save dialog was dismissed — close quietly, same as + // if the user had cancelled the modal itself. + else onClose(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + return ( + + Done + + ) : ( + <> + + + + ) + } + > + {done ? ( +

+ Settings exported. Keep the password somewhere safe — there is no way to recover + the file without it. +

+ ) : ( +
+ + {(id) => ( + setPassword(e.target.value)} + disabled={busy} + className={inputClass} + /> + )} + + + {(id) => ( + setConfirmPassword(e.target.value)} + disabled={busy} + className={inputClass} + /> + )} + + {tooShort && ( +

+ Use at least {MIN_PASSWORD_LENGTH} characters. +

+ )} + {mismatch &&

Passwords don't match.

} + {error &&

{error}

} +
+ )} +
+ ); +} diff --git a/app/src/components/settings/ImportSettingsModal.test.tsx b/app/src/components/settings/ImportSettingsModal.test.tsx new file mode 100644 index 0000000..345d916 --- /dev/null +++ b/app/src/components/settings/ImportSettingsModal.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ImportSettingsModal from "./ImportSettingsModal"; +import type { AppSettings, SettingsImportPreview } from "../../lib/types"; + +const previewSettingsImport = vi.fn(); +const applySettingsImport = vi.fn(); + +vi.mock("../../lib/tauri-commands", () => ({ + previewSettingsImport: (password: string) => previewSettingsImport(password), + applySettingsImport: (password: string) => applySettingsImport(password), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const samplePreview: SettingsImportPreview = { + exported_at: "2026-08-27T00:00:00Z", + app_version: "0.4.14", + custom_env_var_count: 2, + gateway_model_count: 0, + has_claude_code_settings: false, + has_claude_oauth_token: true, + has_gateway_api_key: false, + has_gateway_master_key: false, +}; + +describe("ImportSettingsModal", () => { + it("keeps 'Choose file' disabled until a password is entered", () => { + render(); + expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } }); + expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled(); + }); + + it("shows the preview and confirms with the same password used to open it", async () => { + previewSettingsImport.mockResolvedValue(samplePreview); + applySettingsImport.mockResolvedValue({} as AppSettings); + const onImported = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } }); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + + await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2")); + expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument(); + expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /^import$/i })); + await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2")); + await waitFor(() => expect(onImported).toHaveBeenCalledWith({})); + expect(await screen.findByText(/settings imported/i)).toBeInTheDocument(); + }); + + it("closes quietly when the file picker is dismissed", async () => { + previewSettingsImport.mockResolvedValue(null); + const onClose = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } }); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("shows an error when the password is wrong rather than a blank preview", async () => { + previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted."); + render(); + + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } }); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + + expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument(); + }); + + it("shows an error if applying the import fails, without claiming success", async () => { + previewSettingsImport.mockResolvedValue(samplePreview); + applySettingsImport.mockRejectedValue("Keychain write failed"); + render(); + + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } }); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + await screen.findByText(/2 global custom env vars/i); + + fireEvent.click(screen.getByRole("button", { name: /^import$/i })); + expect(await screen.findByText("Keychain write failed")).toBeInTheDocument(); + expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/ImportSettingsModal.tsx b/app/src/components/settings/ImportSettingsModal.tsx new file mode 100644 index 0000000..c8abb8d --- /dev/null +++ b/app/src/components/settings/ImportSettingsModal.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import Field, { inputClass } from "../ui/Field"; +import { applySettingsImport, previewSettingsImport } from "../../lib/tauri-commands"; +import { describeImport } from "../../lib/settingsImportPreview"; +import type { AppSettings, SettingsImportPreview } from "../../lib/types"; + +interface Props { + onClose: () => void; + /** Fired once the import is actually applied, so the caller can refresh + * whatever reads settings from the store. */ + onImported: (settings: AppSettings) => void; +} + +/** + * Two phases: enter the password and pick the file (backend resolves the + * file dialog itself — see `commands::settings_export_commands`), then + * confirm a preview before anything is actually applied. The same password + * is reused for the second call rather than asking again; nothing about + * that call needs a fresh secret; the backend just doesn't cache the + * *decrypted payload* between the two. + */ +export default function ImportSettingsModal({ onClose, onImported }: Props) { + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + const [applied, setApplied] = useState(false); + + const handleChooseFile = async () => { + setError(null); + setBusy(true); + try { + const result = await previewSettingsImport(password); + if (result) setPreview(result); + else onClose(); // File picker dismissed. + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const handleConfirm = async () => { + setError(null); + setBusy(true); + try { + const settings = await applySettingsImport(password); + setApplied(true); + onImported(settings); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + return ( + + Done + + ) : preview ? ( + <> + + + + ) : ( + <> + + + + ) + } + > + {applied ? ( +

Settings imported.

+ ) : preview ? ( +
+

+ Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "} + {preview.app_version}. +

+
+

This will replace:

+
    + {describeImport(preview).map((item) => ( +
  • {item}
  • + ))} +
+
+ {error &&

{error}

} +
+ ) : ( +
+ + {(id) => ( + setPassword(e.target.value)} + disabled={busy} + className={inputClass} + /> + )} + + {error &&

{error}

} +
+ )} +
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 062a9f4..ea11494 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -19,9 +19,11 @@ import WebTerminalSettings from "./WebTerminalSettings"; import SttSettings from "./SttSettings"; import SharedAuthSettings from "./SharedAuthSettings"; import CertificateSettings from "./CertificateSettings"; +import ExportSettingsModal from "./ExportSettingsModal"; +import ImportSettingsModal from "./ImportSettingsModal"; export default function SettingsPanel() { - const { appSettings, saveSettings } = useSettings(); + const { appSettings, saveSettings, setAppSettings } = useSettings(); const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates(); const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? ""); const [globalEnvVars, setGlobalEnvVars] = useState(appSettings?.global_custom_env_vars ?? []); @@ -33,6 +35,8 @@ export default function SettingsPanel() { const [showInstructionsModal, setShowInstructionsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); // Sync local state when appSettings change useEffect(() => { @@ -269,6 +273,39 @@ export default function SettingsPanel() { + +
+

+ Export your global settings and stored credentials (a shared Claude login, + gateway keys) to one password-encrypted file, or restore them on a new machine. + Project-specific settings and container data are never included. +

+
+ + +
+
+
+ + {showExportModal && setShowExportModal(false)} />} + + {showImportModal && ( + setShowImportModal(false)} + onImported={(settings) => setAppSettings(settings)} + /> + )} + {showInstructionsModal && ( = {}): SettingsImportPreview { + return { + exported_at: "2026-08-27T00:00:00Z", + app_version: "0.4.14", + custom_env_var_count: 0, + gateway_model_count: 0, + has_claude_code_settings: false, + has_claude_oauth_token: false, + has_gateway_api_key: false, + has_gateway_master_key: false, + ...overrides, + }; +} + +describe("describeImport", () => { + it("always names the settings replacement, even with nothing else set", () => { + expect(describeImport(preview())).toEqual([ + "Your global settings (all of them — this replaces what's here now)", + ]); + }); + + it("singularizes a count of exactly one", () => { + const items = describeImport(preview({ custom_env_var_count: 1, gateway_model_count: 1 })); + expect(items).toContain("1 global custom env var"); + expect(items).toContain("1 gateway model"); + }); + + it("pluralizes counts greater than one", () => { + const items = describeImport(preview({ custom_env_var_count: 3, gateway_model_count: 2 })); + expect(items).toContain("3 global custom env vars"); + expect(items).toContain("2 gateway models"); + }); + + it("names every present secret and setting without naming absent ones", () => { + const items = describeImport( + preview({ + has_claude_code_settings: true, + has_claude_oauth_token: true, + has_gateway_api_key: true, + has_gateway_master_key: true, + }), + ); + expect(items).toContain("Global Claude Code settings"); + expect(items).toContain("Your shared Claude login"); + expect(items).toContain("The gateway provider API key"); + expect(items).toContain("The gateway master key"); + // None of the count-based items, since both counts are 0. + expect(items.some((i) => i.includes("env var"))).toBe(false); + expect(items.some((i) => i.includes("gateway model"))).toBe(false); + }); +}); diff --git a/app/src/lib/settingsImportPreview.ts b/app/src/lib/settingsImportPreview.ts new file mode 100644 index 0000000..dde8d46 --- /dev/null +++ b/app/src/lib/settingsImportPreview.ts @@ -0,0 +1,20 @@ +import type { SettingsImportPreview } from "./types"; + +/** Named things a `SettingsImportPreview` says an import will change, for + * `ImportSettingsModal`'s confirmation list. */ +export function describeImport(preview: SettingsImportPreview): string[] { + const items: string[] = ["Your global settings (all of them — this replaces what's here now)"]; + if (preview.custom_env_var_count > 0) { + items.push( + `${preview.custom_env_var_count} global custom env var${preview.custom_env_var_count === 1 ? "" : "s"}`, + ); + } + if (preview.has_claude_code_settings) items.push("Global Claude Code settings"); + if (preview.gateway_model_count > 0) { + items.push(`${preview.gateway_model_count} gateway model${preview.gateway_model_count === 1 ? "" : "s"}`); + } + if (preview.has_claude_oauth_token) items.push("Your shared Claude login"); + if (preview.has_gateway_api_key) items.push("The gateway provider API key"); + if (preview.has_gateway_master_key) items.push("The gateway master key"); + return items; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 6389a4c..dbceeec 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types"; +import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -42,6 +42,15 @@ export const inspectCaCertPath = (path: string) => export const detectHostTimezone = () => invoke("detect_host_timezone"); +// Settings export/import — `false`/`null` mean the save/open dialog was +// dismissed, not an error. +export const exportSettings = (password: string) => + invoke("export_settings", { password }); +export const previewSettingsImport = (password: string) => + invoke("preview_settings_import", { password }); +export const applySettingsImport = (password: string) => + invoke("apply_settings_import", { password }); + // AWS export const awsSsoRefresh = (projectId: string) => invoke("aws_sso_refresh", { projectId }); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 9a6462c..baadc0b 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -292,6 +292,20 @@ export interface AppSettings { global_claude_code_settings: ClaudeCodeSettings | null; } +/** What `preview_settings_import` returns before anything is applied — + * counts and presence flags only, never a secret value itself. Built from + * this, not from the raw import file, which the frontend never sees. */ +export interface SettingsImportPreview { + exported_at: string; + app_version: string; + custom_env_var_count: number; + gateway_model_count: number; + has_claude_code_settings: boolean; + has_claude_oauth_token: boolean; + has_gateway_api_key: boolean; + has_gateway_master_key: boolean; +} + /** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride * in the payload rather than rejecting, so the field can render them inline * while the user is still typing. */