diff --git a/CLAUDE.md b/CLAUDE.md
index ab75119..c03fb36 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -552,6 +552,131 @@ 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` 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.
+
+- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
+ caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
+ credential for a server that binds every interface — exporting `AppSettings` wholesale would
+ have carried it along as if it were as inert as a port number, and importing it would have
+ applied `web_terminal.enabled` and the token together with no more warning than any other
+ setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
+ `export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
+ instead, with the same "only overwrite what the import actually has" treatment as the other
+ three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
+ unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
+ wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
+ and the token are independent fields, and "this turns on a listening service" must not hide
+ inside a generic "settings replaced" summary. Read this as the standing example of the class of
+ thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
+ like config but is actually a live credential would have the same problem.
+- **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. It also pins a hash of the file's ciphertext next to that
+ path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
+ otherwise confirming a preview would not actually be binding on what gets applied, which matters
+ given this feature's own threat model: a file shared between people may sit in a synced or
+ otherwise shared directory that changes between the two calls.
+- **The decrypted payload is not cached between preview and apply — only the password is reused.**
+ The frontend holds the password in React state and passes it to both calls; nothing in Rust
+ holds decrypted plaintext — secrets included — in memory for longer than one command's
+ execution, so `apply_settings_import` always re-decrypts rather than reusing anything
+ `preview_settings_import` computed. `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). Secrets are restored *before* the settings replace runs, not after — replacing
+ settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
+ window where a gateway recreation happens against the destination's old keys.
+- **A restored gateway secret nudges a running gateway container to recreate itself, even when
+ nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
+ compares port/provider/base URL/models — deliberately, since that's what's rendered into the
+ container's config — so a secret-only change (same shape, new key) is invisible to it. Left
+ alone, a running container would keep serving the old key material indefinitely after an import
+ that restored a new one. `apply_settings_import` tracks whether either gateway secret was
+ actually written and, if the gateway is enabled and its container both exists and is running,
+ calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
+ includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
+ exactly when it should and no more.
+- **A keychain write failing during import is reported back, not only logged.** Each of the three
+ `secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
+ in addition to logging it — an import that silently restores two of three secrets but not the
+ third must not read as unqualified success just because the settings half of the import (which
+ runs after, and is validated before any of this) went through. `apply_settings_import` returns
+ `SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
+ this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
+- **The imported settings are validated *before* any secret is written, not just before the
+ settings replace.** `apply_settings_import` calls
+ `settings_commands::validate_settings_update(¤t, &settings)` — the same checks
+ `update_settings` runs internally, pulled out into its own function specifically so this caller
+ can run them first — and only proceeds to the three keychain writes if that passes. A review
+ caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
+ disallowed host path) still left the keychain overwritten with the file's secrets while the
+ settings themselves stayed unchanged, a silently half-applied state the error message gave no
+ hint of.
+- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
+ after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
+ for, and parsing the full struct first would fail on the shape mismatch before the version check
+ ever ran. Neither error path interpolates what `serde_json` actually says into the message
+ shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
+ here can hold a live credential.
+- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
+ export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
+ weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
+ can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
+ rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
+ `.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
+ derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
+ recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
+ in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
+ transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
+- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
+ `global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
+ a different server is visible in the confirmation dialog rather than discovered later — these are
+ endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
+ URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
+ terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
+ token when none is already set, so a planted token would otherwise activate silently the next
+ time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
+- **The preview also discloses a custom Docker image, and warns on one every time — not just on
+ change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
+ but a review pointed out they're a sharper version of the same problem: this is the image *every*
+ project container is created from (`models::container_config::resolve_image_name`), so a crafted
+ export pointing it at an attacker-controlled image is a path to running arbitrary code with
+ whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
+ `describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
+ it differs from the destination's current value, since re-importing the same risky configuration
+ is still worth surfacing every time a user confirms an import.
+- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
+ `SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
+ at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
+ review noted that, unlike the count- and boolean-derived fields the preview started with, these
+ are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
+ confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
+ newlines) could push the security warnings above the scroll fold in the dialog that exists
+ specifically to make them unmissable — the frontend's `
`/warning boxes also get `break-all`
+ as a second layer against the same failure mode.
+
## 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..d663f61 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",
@@ -5174,6 +5307,7 @@ dependencies = [
"tokio",
"tower-http",
"uuid",
+ "zeroize",
]
[[package]]
@@ -5287,6 +5421,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..ebc9648 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -36,6 +36,9 @@ 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"
+zeroize = "1"
[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_commands.rs b/app/src-tauri/src/commands/settings_commands.rs
index 5a8b0c6..13309bb 100644
--- a/app/src-tauri/src/commands/settings_commands.rs
+++ b/app/src-tauri/src/commands/settings_commands.rs
@@ -10,19 +10,24 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result,
-) -> Result {
- let before = state.settings_store.get();
-
+/// Everything `update_settings` refuses a save over, run against the store's
+/// *current* value and the incoming one.
+///
+/// Pulled out so a caller that does other, harder-to-undo work alongside a
+/// settings save — `settings_export_commands::apply_settings_import`
+/// restores three keychain secrets in the same command — can run this
+/// *first* and bail before touching anything, rather than discovering the
+/// rejection only when `update_settings` itself runs partway through.
+pub fn validate_settings_update(
+ before: &AppSettings,
+ incoming: &AppSettings,
+) -> Result<(), String> {
// The global half of the same rule the project half gets in
// `update_project`: a global custom env var is merged into every project's
// container environment, so an unchecked name here reaches all of them.
crate::models::validate_env_vars_update(
&before.global_custom_env_vars,
- &settings.global_custom_env_vars,
+ &incoming.global_custom_env_vars,
)?;
// The same for the two host paths this struct owns. `update_project`
@@ -40,14 +45,37 @@ pub async fn update_settings(
crate::commands::project_commands::validate_mounted_host_path(
"SSH key path",
before.default_ssh_key_path.as_deref(),
- settings.default_ssh_key_path.as_deref(),
+ incoming.default_ssh_key_path.as_deref(),
)?;
crate::commands::project_commands::validate_mounted_host_path(
"CA certificate path",
before.ca_cert_path.as_deref(),
- settings.ca_cert_path.as_deref(),
+ incoming.ca_cert_path.as_deref(),
)?;
+ // Third host path this struct owns, same reasoning: any project with
+ // `allow_docker_access` bind-mounts this path in as the Docker socket
+ // (`project_commands.rs`'s container creation), so an unchecked value
+ // here is a read-write bind mount of whatever it names into every such
+ // project's container.
+ crate::commands::project_commands::validate_mounted_host_path(
+ "Docker socket path",
+ before.docker_socket_path.as_deref(),
+ incoming.docker_socket_path.as_deref(),
+ )?;
+
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn update_settings(
+ settings: AppSettings,
+ state: State<'_, AppState>,
+) -> Result {
+ let before = state.settings_store.get();
+
+ validate_settings_update(&before, &settings)?;
+
let saved = state.settings_store.update(settings)?;
// Persisting a setting is not the same as applying it. The gateway is the
@@ -122,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
GatewayAction::StopIfRunning => {
log::info!("Model gateway disabled in settings — stopping the container");
if let Err(e) = docker::gateway::stop_gateway_container().await {
- log::error!("Failed to stop the model gateway after it was disabled: {}", e);
+ log::error!(
+ "Failed to stop the model gateway after it was disabled: {}",
+ e
+ );
}
}
GatewayAction::RestartIfRunning => {
@@ -138,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
}
#[tauri::command]
-pub async fn pull_image(
- image_name: String,
- app_handle: tauri::AppHandle,
-) -> Result<(), String> {
+pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
use tauri::Emitter;
docker::pull_image(&image_name, move |msg| {
let _ = app_handle.emit("image-pull-progress", msg);
@@ -334,7 +362,10 @@ mod tests {
let before = enabled_gateway();
let mut after = before.clone();
after.enabled = false;
- assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
+ assert_eq!(
+ gateway_action(&before, &after),
+ GatewayAction::StopIfRunning
+ );
// Still true when it was already off — a stray running container is
// still a container that shouldn't be up.
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
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..1bdd35b
--- /dev/null
+++ b/app/src-tauri/src/commands/settings_export_commands.rs
@@ -0,0 +1,654 @@
+//! 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 *decrypted payload* is not cached between preview and apply — the
+//! password the frontend passes to each call is what it already held for
+//! the first, not a fresh secret extracted from the user, but nothing here
+//! keeps the plaintext itself — export/import secrets included — around for
+//! longer than one command's execution; `apply_settings_import` re-decrypts
+//! the file rather than reusing anything `preview_settings_import` computed.
+//!
+//! **This is new attack surface**: a settings export is a file one person
+//! can hand another and ask them to import, together with a password, and
+//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
+//! wholesale — see the module doc on `models::settings_export` for the
+//! `web_terminal.access_token` carve-out a review of this feature found,
+//! and treat that as the standing example of the class of thing to keep
+//! checking for here, not a one-off fixed bug.
+
+#[cfg(test)]
+use std::path::Path;
+use std::path::PathBuf;
+
+use sha2::{Digest, Sha256};
+use tauri::State;
+use tauri_plugin_dialog::DialogExt;
+use zeroize::Zeroizing;
+
+use crate::models::{
+ AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
+ SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
+};
+use crate::storage::{secure, settings_crypto};
+use crate::AppState;
+
+/// What `preview_settings_import` pins so `apply_settings_import` can tell
+/// whether the file it's about to re-read is the same one the user actually
+/// saw a preview of. Confirming a preview is only meaningful if it's binding
+/// on what gets applied — without this, a file replaced on disk between the
+/// two calls (this app's own stated threat model is a file shared between
+/// people, which may sit in a synced or shared directory) would decrypt and
+/// apply silently different content than what the confirmation dialog showed.
+#[derive(Debug, Clone)]
+pub struct PendingSettingsImport {
+ path: PathBuf,
+ ciphertext_hash: [u8; 32],
+}
+
+fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
+ Sha256::digest(data).into()
+}
+
+const FILE_EXTENSION: &str = "triplec";
+
+/// Enforced here, not only in the export modal: the frontend's minimum is a
+/// UX nudge, but `export_settings` is the actual boundary a weak password
+/// has to cross, and Argon2id's memory-hardness buys little against an
+/// attacker who can just try a three-character password directly.
+const MIN_PASSWORD_LEN: usize = 8;
+
+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, and hand back the `AppSettings` to
+/// export with the web-terminal token blanked out of it — see the module
+/// doc comment on `models::settings_export` for why that field cannot
+/// travel through `settings` like the rest of this struct.
+///
+/// A missing keychain 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 split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
+ let mut settings = current;
+ let web_terminal_access_token = settings.web_terminal.access_token.take();
+
+ let secrets = 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(),
+ web_terminal_access_token,
+ };
+
+ (settings, secrets)
+}
+
+/// 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 {
+ // `.chars().count()` — Unicode scalar values, not bytes — to stay as
+ // close as this pair of languages allows to the frontend's `.length`
+ // check (UTF-16 code units); the two only diverge on astral-plane
+ // characters, which no reasonable password touches.
+ if password.chars().count() < MIN_PASSWORD_LEN {
+ return Err(format!(
+ "Use a password of at least {} characters.",
+ MIN_PASSWORD_LEN
+ ));
+ }
+
+ let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
+ return Ok(false);
+ };
+
+ let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
+ 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,
+ secrets,
+ };
+
+ let plaintext = Zeroizing::new(
+ 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 *and a hash of the file's ciphertext* in
+/// `AppState::pending_settings_import` for `apply_settings_import` to check
+/// against — 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. The hash exists so it also can't be
+/// swapped out from under that commitment: `apply_settings_import` refuses to
+/// proceed if the file on disk no longer matches what was just previewed.
+#[tauri::command]
+pub async fn preview_settings_import(
+ password: String,
+ window: tauri::Window,
+ state: State<'_, AppState>,
+) -> Result