diff --git a/CLAUDE.md b/CLAUDE.md
index 55eafd2..c03fb36 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -588,11 +588,18 @@ deliberately out of scope — this is not a project backup.
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.
+ 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
@@ -601,6 +608,23 @@ deliberately out of scope — this is not a project backup.
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
@@ -634,6 +658,24 @@ deliberately out of scope — this is not a project backup.
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
diff --git a/app/src-tauri/src/commands/settings_commands.rs b/app/src-tauri/src/commands/settings_commands.rs
index 060469d..13309bb 100644
--- a/app/src-tauri/src/commands/settings_commands.rs
+++ b/app/src-tauri/src/commands/settings_commands.rs
@@ -53,6 +53,17 @@ pub fn validate_settings_update(
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(())
}
diff --git a/app/src-tauri/src/commands/settings_export_commands.rs b/app/src-tauri/src/commands/settings_export_commands.rs
index cf0860a..1bdd35b 100644
--- a/app/src-tauri/src/commands/settings_export_commands.rs
+++ b/app/src-tauri/src/commands/settings_export_commands.rs
@@ -17,9 +17,12 @@
//! (`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.
+//! 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
@@ -29,19 +32,39 @@
//! and treat that as the standing example of the class of thing to keep
//! checking for here, not a one-off fixed bug.
-use std::path::{Path, PathBuf};
+#[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, SettingsImportPreview,
- SETTINGS_EXPORT_FORMAT_VERSION,
+ 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
@@ -166,10 +189,13 @@ pub async fn export_settings(
/// 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.
+/// 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,
@@ -184,10 +210,14 @@ pub async fn preview_settings_import(
return Ok(None);
};
- let payload = read_and_decrypt(&path, &password)?;
+ let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
+ let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let preview = SettingsImportPreview::from_payload(&payload);
- *state.pending_settings_import.lock().await = Some(path);
+ *state.pending_settings_import.lock().await = Some(PendingSettingsImport {
+ path,
+ ciphertext_hash: hash_ciphertext(&encrypted),
+ });
Ok(Some(preview))
}
@@ -196,7 +226,14 @@ pub async fn preview_settings_import(
/// 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.
+/// what an import is about to touch before it touches it. That requirement
+/// is only real if the file can't change out from under it, so this also
+/// refuses to proceed if the file's ciphertext no longer matches the hash
+/// `preview_settings_import` pinned — a file replaced on disk between the
+/// two calls (this feature's own threat model is a file shared between
+/// people, which may sit in a synced or shared directory) must not be able
+/// to apply silently different content than what the confirmation dialog
+/// showed.
///
/// Global settings are replaced wholesale — an import is "restore this
/// environment," not a field-by-field merge. Global secrets are handled
@@ -227,30 +264,48 @@ pub async fn preview_settings_import(
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
/// the final key material rather than racing it — restoring the other way
/// round left a real window where the running gateway and the keychain
-/// briefly disagreed.
+/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
+/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
+/// nudges a running gateway container to recreate itself whenever a secret
+/// this import carried was actually written — otherwise the running
+/// container keeps serving the old key material indefinitely while every
+/// project container is handed the new one.
///
-/// The pending path is only cleared on success. A failure here (rejected by
-/// the validation above, or some other error) leaves the import pending so
-/// the frontend can let the user retry `apply` without making them pick the
-/// file and re-enter the password again — the preview's job was confirming
-/// *what* to import, not spending the one attempt at applying it.
+/// A keychain write failing is reported back rather than only logged: an
+/// import that silently restores two of three secrets but not the third
+/// must not read as unqualified success.
+///
+/// The pending import is only cleared on success. A failure here (rejected
+/// by the validation above, a stale-file mismatch, or some other error)
+/// leaves it pending so the frontend can let the user retry `apply` without
+/// making them pick the file and re-enter the password again — the
+/// preview's job was confirming *what* to import, not spending the one
+/// attempt at applying it.
#[tauri::command]
pub async fn apply_settings_import(
password: String,
state: State<'_, AppState>,
-) -> Result {
+) -> Result {
if password.is_empty() {
return Err("A password is required to import settings.".to_string());
}
- let path = state
+ let pending = state
.pending_settings_import
.lock()
.await
.clone()
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
- let payload = read_and_decrypt(&path, &password)?;
+ let encrypted = std::fs::read(&pending.path)
+ .map_err(|e| format!("Failed to read export file: {}", e))?;
+ if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
+ return Err(
+ "This file changed since you reviewed it — choose it again to see an up-to-date preview."
+ .to_string(),
+ );
+ }
+ let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let current = state.settings_store.get();
@@ -266,37 +321,81 @@ pub async fn apply_settings_import(
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
+ let mut secret_restore_warnings = Vec::new();
+ let mut gateway_secret_changed = false;
+
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
);
+ secret_restore_warnings
+ .push(format!("Could not restore your 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
- );
+ match secure::store_gateway_api_key(&key) {
+ Ok(()) => gateway_secret_changed = true,
+ Err(e) => {
+ log::warn!(
+ "Settings import: could not restore the gateway provider API key: {}",
+ e
+ );
+ secret_restore_warnings.push(format!(
+ "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
- );
+ match secure::store_gateway_master_key(&key) {
+ Ok(()) => gateway_secret_changed = true,
+ Err(e) => {
+ log::warn!(
+ "Settings import: could not restore the gateway master key: {}",
+ e
+ );
+ secret_restore_warnings
+ .push(format!("Could not restore the gateway master key: {}", e));
+ }
}
}
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
+ // `reconcile_gateway` (inside `update_settings`) only reacts to a changed
+ // *shape* — port, provider, base URL, models — because that's what's
+ // rendered into the container's config. A secret changing with the shape
+ // held constant is invisible to it, so a running gateway container would
+ // otherwise keep serving the old key material forever after an import
+ // that restored a new one, while `docker::gateway`'s own fingerprint
+ // (which does include the secret rotation id) means the *next* unrelated
+ // settings save would suddenly and confusingly recreate it instead.
+ if gateway_secret_changed && saved.gateway.enabled {
+ match crate::docker::gateway::gateway_container_presence().await {
+ Ok((true, true)) => {
+ if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
+ {
+ log::error!(
+ "Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
+ e
+ );
+ }
+ }
+ Ok(_) => {}
+ Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
+ }
+ }
+
state.pending_settings_import.lock().await.take();
- Ok(saved)
+ Ok(SettingsImportOutcome {
+ settings: saved,
+ secret_restore_warnings,
+ })
}
fn non_blank(value: Option) -> Option {
@@ -310,8 +409,22 @@ struct FormatVersionProbe {
format_version: u32,
}
-/// Decrypt and parse an export file, checking the format version **before**
-/// attempting to deserialize the full payload.
+/// Read and decrypt an export file at `path`, then parse it — see
+/// `read_and_decrypt_bytes` for why the format-version check runs before the
+/// full parse. Every real caller already has the file's bytes in hand by the
+/// time it needs this (`preview_settings_import`/`apply_settings_import`
+/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
+/// directly to avoid reading the file twice; this path-based wrapper only
+/// exists now for tests that don't need that.
+#[cfg(test)]
+fn read_and_decrypt(path: &Path, password: &str) -> Result {
+ let encrypted =
+ std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
+ read_and_decrypt_bytes(&encrypted, password)
+}
+
+/// Decrypt and parse an already-read export file's bytes, checking the
+/// format version **before** attempting to deserialize the full payload.
///
/// That ordering is not just tidiness: a version bump that isn't
/// deserialize-compatible (a field's type changes, not just a new
@@ -324,10 +437,8 @@ struct FormatVersionProbe {
/// password), but the plaintext it decrypts to can hold a live credential,
/// so neither error path below ever interpolates what `serde_json`
/// actually says — only a fixed, generic message.
-fn read_and_decrypt(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)?;
+fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result {
+ let plaintext = settings_crypto::decrypt(encrypted, password)?;
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
@@ -356,6 +467,21 @@ mod tests {
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
}
+ #[test]
+ fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
+ // What `apply_settings_import` compares against the pinned hash from
+ // `preview_settings_import` to detect a file swapped out from under a
+ // pending import — this only defends anything if identical bytes
+ // always hash identically and any change to those bytes changes the
+ // hash.
+ let bytes = b"pretend this is an encrypted export file";
+ assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
+
+ let mut tampered = bytes.to_vec();
+ tampered[0] ^= 0xFF;
+ assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
+ }
+
fn write_export(
dir: &std::path::Path,
name: &str,
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index 679c3df..b789ad8 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -37,7 +37,13 @@ pub struct AppState {
/// 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>>,
+ ///
+ /// Also pins a hash of the file's ciphertext at preview time, so
+ /// `apply_settings_import` can refuse to proceed if the file on disk
+ /// changed underneath the pending import — otherwise confirming a
+ /// preview is not actually binding on what gets applied.
+ pub pending_settings_import:
+ Arc>>,
}
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/app/src-tauri/src/models/settings_export.rs b/app/src-tauri/src/models/settings_export.rs
index 550d163..cf6c5d4 100644
--- a/app/src-tauri/src/models/settings_export.rs
+++ b/app/src-tauri/src/models/settings_export.rs
@@ -27,7 +27,7 @@
use serde::{Deserialize, Serialize};
-use super::AppSettings;
+use super::{AppSettings, ImageSource};
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
@@ -60,13 +60,26 @@ pub struct ExportedSecrets {
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()
- && self.web_terminal_access_token.is_none()
+ let blank = |s: &Option| s.as_deref().is_none_or(|v| v.trim().is_empty());
+ blank(&self.claude_oauth_token)
+ && blank(&self.gateway_api_key)
+ && blank(&self.gateway_master_key)
+ && blank(&self.web_terminal_access_token)
}
}
+/// What `apply_settings_import` hands back: the settings that were actually
+/// saved, plus a human-readable note for each keychain secret this import
+/// carried but could not be restored. A keychain write failing partway
+/// through must not read as unqualified success just because the settings
+/// half of the import went through.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SettingsImportOutcome {
+ pub settings: AppSettings,
+ #[serde(default)]
+ pub secret_restore_warnings: Vec,
+}
+
/// 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`.
@@ -122,12 +135,49 @@ pub struct SettingsImportPreview {
pub openai_compatible_base_url: Option,
#[serde(default)]
pub gateway_api_base: Option,
+ /// Whether the import sets a custom Docker image, and its name if so —
+ /// disclosed for the same reason as the base URLs above, and arguably
+ /// more sharply: 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 (the Docker socket, an SSH key, project files) —
+ /// not merely a redirected API endpoint.
+ #[serde(default)]
+ pub image_source: ImageSource,
+ #[serde(default)]
+ pub custom_image_name: Option,
+}
+
+/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
+/// into a preview a user reads and a UI renders without truncation of its
+/// own. Applied to every field above that carries free-form text straight
+/// from the import file rather than a count or a boolean — a base URL or an
+/// image name a hostile export author controls has had no validation done
+/// on it yet at preview time, and nothing stops it from being pathological
+/// (embedded control characters, or long enough to blow out the confirmation
+/// dialog and push the security warnings below it off screen).
+const MAX_PREVIEW_STRING_LEN: usize = 100;
+
+fn sanitize_for_preview(value: &str) -> String {
+ let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
+ let trimmed = cleaned.trim();
+ if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
+ let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
+ format!("{}…", truncated)
+ } else {
+ trimmed.to_string()
+ }
}
impl SettingsImportPreview {
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
let non_blank = |s: &Option| s.as_deref().is_some_and(|v| !v.trim().is_empty());
- let non_blank_value = |s: &Option| s.clone().filter(|v| !v.trim().is_empty());
+ let sanitized_non_blank = |s: &Option| {
+ s.as_deref()
+ .map(sanitize_for_preview)
+ .filter(|v| !v.is_empty())
+ };
Self {
exported_at: payload.exported_at.clone(),
app_version: payload.app_version.clone(),
@@ -139,12 +189,14 @@ impl SettingsImportPreview {
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
enables_web_terminal: payload.settings.web_terminal.enabled,
- ollama_base_url: non_blank_value(&payload.settings.global_ollama.base_url),
- llamacpp_base_url: non_blank_value(&payload.settings.global_llamacpp.base_url),
- openai_compatible_base_url: non_blank_value(
+ ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
+ llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
+ openai_compatible_base_url: sanitized_non_blank(
&payload.settings.global_openai_compatible.base_url,
),
- gateway_api_base: non_blank_value(&payload.settings.gateway.api_base),
+ gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
+ image_source: payload.settings.image_source.clone(),
+ custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
}
}
}
@@ -155,17 +207,19 @@ mod tests {
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(),
- },
- ];
+ let settings = AppSettings {
+ 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(),
+ },
+ ],
+ ..AppSettings::default()
+ };
SettingsExportPayload {
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
exported_at: "2026-08-27T00:00:00Z".to_string(),
@@ -264,4 +318,49 @@ mod tests {
}
.is_empty());
}
+
+ #[test]
+ fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
+ // Matches the "blank counts as absent" rule every other consumer of
+ // these fields applies (`has_claude_oauth_token` and friends above) —
+ // a keychain entry that exists but holds only whitespace carries
+ // nothing usable, so the export-time "nothing to export" log line
+ // must still fire for it.
+ assert!(ExportedSecrets {
+ claude_oauth_token: Some(" ".to_string()),
+ ..Default::default()
+ }
+ .is_empty());
+ }
+
+ #[test]
+ fn a_custom_docker_image_is_surfaced() {
+ let mut payload = payload_with(ExportedSecrets::default());
+ payload.settings.image_source = crate::models::ImageSource::Custom;
+ payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
+
+ let preview = SettingsImportPreview::from_payload(&payload);
+ assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
+ assert_eq!(
+ preview.custom_image_name.as_deref(),
+ Some("ghcr.io/attacker/triple-c:latest")
+ );
+ }
+
+ #[test]
+ fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
+ let mut payload = payload_with(ExportedSecrets::default());
+ payload.settings.global_ollama.base_url =
+ Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
+
+ let preview = SettingsImportPreview::from_payload(&payload);
+ let shown = preview.ollama_base_url.expect("non-blank base url");
+ assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
+ // +1 for the trailing ellipsis appended when truncated.
+ assert!(
+ shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
+ "preview string was not capped: {} chars",
+ shown.chars().count()
+ );
+ }
}
diff --git a/app/src-tauri/src/storage/settings_crypto.rs b/app/src-tauri/src/storage/settings_crypto.rs
index 37d40df..385edf1 100644
--- a/app/src-tauri/src/storage/settings_crypto.rs
+++ b/app/src-tauri/src/storage/settings_crypto.rs
@@ -19,8 +19,14 @@
//! 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`].
+//!
+//! The whole header (magic + salt + nonce) is passed to AES-GCM as
+//! associated data, not just placed alongside the ciphertext — free to do,
+//! and it makes tampering with any header byte fail the same authentication
+//! check the ciphertext gets, by construction rather than as a side effect
+//! of the salt/nonce also feeding key derivation and the cipher.
-use aes_gcm::aead::{Aead, KeyInit};
+use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
@@ -70,16 +76,23 @@ pub fn encrypt(plaintext: &[u8], password: &str) -> Result, String> {
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
+ let mut header = Vec::with_capacity(HEADER_LEN);
+ header.extend_from_slice(MAGIC);
+ header.extend_from_slice(&salt);
+ header.extend_from_slice(&nonce_bytes);
+
let cipher = Aes256Gcm::new_from_slice(&*key)
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
+ // The header (magic + salt + nonce) is authenticated as associated data
+ // even though none of it is secret: it costs nothing extra here, and it
+ // means tampering with any header byte is caught by the same tag check
+ // that already covers the ciphertext, by construction rather than as a
+ // side effect of the header also feeding key/nonce derivation.
let ciphertext = cipher
- .encrypt(nonce, plaintext)
+ .encrypt(nonce, Payload { msg: plaintext, aad: &header })
.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);
+ let mut out = header;
out.extend_from_slice(&ciphertext);
Ok(out)
}
@@ -102,6 +115,7 @@ pub fn decrypt(data: &[u8], password: &str) -> Result>, String
if &data[..MAGIC.len()] != MAGIC {
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
}
+ let header = &data[..HEADER_LEN];
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
let ciphertext = &data[HEADER_LEN..];
@@ -111,7 +125,7 @@ pub fn decrypt(data: &[u8], password: &str) -> Result>, String
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
let nonce = Nonce::from_slice(nonce_bytes);
cipher
- .decrypt(nonce, ciphertext)
+ .decrypt(nonce, Payload { msg: ciphertext, aad: header })
.map(Zeroizing::new)
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
}
diff --git a/app/src/components/settings/ImportSettingsModal.test.tsx b/app/src/components/settings/ImportSettingsModal.test.tsx
index 2d9807e..9a56f02 100644
--- a/app/src/components/settings/ImportSettingsModal.test.tsx
+++ b/app/src/components/settings/ImportSettingsModal.test.tsx
@@ -1,7 +1,7 @@
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";
+import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
const previewSettingsImport = vi.fn();
const applySettingsImport = vi.fn();
@@ -30,8 +30,14 @@ const samplePreview: SettingsImportPreview = {
llamacpp_base_url: null,
openai_compatible_base_url: null,
gateway_api_base: null,
+ image_source: "registry",
+ custom_image_name: null,
};
+function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
+ return { settings, secret_restore_warnings: secretRestoreWarnings };
+}
+
describe("ImportSettingsModal", () => {
it("keeps 'Choose file' disabled until a password is entered", () => {
render();
@@ -43,7 +49,7 @@ describe("ImportSettingsModal", () => {
it("shows the preview and confirms with the same password used to open it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
- applySettingsImport.mockResolvedValue({} as AppSettings);
+ applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
const onImported = vi.fn();
render();
@@ -70,6 +76,38 @@ describe("ImportSettingsModal", () => {
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
});
+ it("warns about a custom Docker image every time, not just on change", async () => {
+ previewSettingsImport.mockResolvedValue({
+ ...samplePreview,
+ image_source: "custom",
+ custom_image_name: "ghcr.io/attacker/triple-c:latest",
+ });
+ render();
+
+ fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
+ fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
+
+ expect(
+ await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
+ ).toBeInTheDocument();
+ });
+
+ it("shows a secret-restore warning alongside success rather than hiding it", async () => {
+ previewSettingsImport.mockResolvedValue(samplePreview);
+ applySettingsImport.mockResolvedValue(
+ outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
+ );
+ 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(/settings imported/i)).toBeInTheDocument();
+ expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
+ });
+
it("closes quietly when the file picker is dismissed", async () => {
previewSettingsImport.mockResolvedValue(null);
const onClose = vi.fn();
diff --git a/app/src/components/settings/ImportSettingsModal.tsx b/app/src/components/settings/ImportSettingsModal.tsx
index c0b79f4..c914c3b 100644
--- a/app/src/components/settings/ImportSettingsModal.tsx
+++ b/app/src/components/settings/ImportSettingsModal.tsx
@@ -27,6 +27,7 @@ export default function ImportSettingsModal({ onClose, onImported }: Props) {
const [error, setError] = useState(null);
const [preview, setPreview] = useState(null);
const [applied, setApplied] = useState(false);
+ const [secretWarnings, setSecretWarnings] = useState([]);
const handleChooseFile = async () => {
setError(null);
@@ -46,9 +47,10 @@ export default function ImportSettingsModal({ onClose, onImported }: Props) {
setError(null);
setBusy(true);
try {
- const settings = await applySettingsImport(password);
+ const outcome = await applySettingsImport(password);
setApplied(true);
- onImported(settings);
+ setSecretWarnings(outcome.secret_restore_warnings);
+ onImported(outcome.settings);
} catch (e) {
setError(String(e));
} finally {
@@ -99,29 +101,46 @@ export default function ImportSettingsModal({ onClose, onImported }: Props) {
}
>
{applied ? (
- Settings imported.
+
+
Settings imported.
+ {secretWarnings.map((warning) => (
+
+ {warning}
+
+ ))}
+
) : preview ? (
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
{preview.app_version}.
-
-
This will replace:
-
- {describeImport(preview).map((item) => (
- - {item}
- ))}
-
-
+ {/* Warnings render before the replace list, deliberately: the list
+ * below can run long, and the one thing here that most needs to
+ * stay above the fold while scrolling is "this turns on a
+ * network-listening service" or "this runs a different image" —
+ * not a bullet buried among ordinary settings. */}
{describeImportWarnings(preview).map((warning) => (
{warning}
))}
+
+
This will replace:
+
+ {describeImport(preview).map((item) => (
+ -
+ {item}
+
+ ))}
+
+
{error &&
{error}
}
) : (
diff --git a/app/src/lib/settingsImportPreview.test.ts b/app/src/lib/settingsImportPreview.test.ts
index 8124888..aa94e9d 100644
--- a/app/src/lib/settingsImportPreview.test.ts
+++ b/app/src/lib/settingsImportPreview.test.ts
@@ -18,6 +18,8 @@ function preview(overrides: Partial = {}): SettingsImport
llamacpp_base_url: null,
openai_compatible_base_url: null,
gateway_api_base: null,
+ image_source: "registry",
+ custom_image_name: null,
...overrides,
};
}
@@ -76,6 +78,18 @@ describe("describeImport", () => {
expect(items.some((i) => i.includes("llama.cpp"))).toBe(false);
expect(items.some((i) => i.includes("OpenAI-compatible"))).toBe(false);
});
+
+ it("names a custom Docker image when set, falling back to a placeholder if unnamed", () => {
+ expect(
+ describeImport(preview({ image_source: "custom", custom_image_name: "ghcr.io/me/triple-c" })),
+ ).toContain("Docker image: ghcr.io/me/triple-c");
+ expect(describeImport(preview({ image_source: "custom", custom_image_name: null }))).toContain(
+ "Docker image: (no image name set)",
+ );
+ expect(describeImport(preview({ image_source: "registry" })).some((i) => i.includes("Docker image"))).toBe(
+ false,
+ );
+ });
});
describe("describeImportWarnings", () => {
@@ -101,4 +115,11 @@ describe("describeImportWarnings", () => {
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
]);
});
+
+ it("warns about a custom Docker image every time, not only when it changes", () => {
+ expect(
+ describeImportWarnings(preview({ image_source: "custom", custom_image_name: "evil:latest" })),
+ ).toEqual(["Runs every project container from a custom Docker image: evil:latest."]);
+ expect(describeImportWarnings(preview({ image_source: "registry" }))).toEqual([]);
+ });
});
diff --git a/app/src/lib/settingsImportPreview.ts b/app/src/lib/settingsImportPreview.ts
index 6e9c099..c8e05e6 100644
--- a/app/src/lib/settingsImportPreview.ts
+++ b/app/src/lib/settingsImportPreview.ts
@@ -25,6 +25,9 @@ export function describeImport(preview: SettingsImportPreview): string[] {
items.push(`OpenAI-compatible server: ${preview.openai_compatible_base_url}`);
}
if (preview.gateway_api_base) items.push(`Gateway upstream: ${preview.gateway_api_base}`);
+ if (preview.image_source === "custom") {
+ items.push(`Docker image: ${preview.custom_image_name ?? "(no image name set)"}`);
+ }
return items;
}
@@ -41,6 +44,10 @@ export function describeImport(preview: SettingsImportPreview): string[] {
* would silently become live the next time someone flips the terminal on
* through the UI, with no import-time signal that it wasn't freshly
* generated.
+ *
+ * A custom Docker image gets a warning every time, not just on change: it's
+ * the image every project container is created from, so it's worth calling
+ * out regardless of what was configured before the import.
*/
export function describeImportWarnings(preview: SettingsImportPreview): string[] {
const warnings: string[] = [];
@@ -51,5 +58,10 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
);
}
+ if (preview.image_source === "custom") {
+ warnings.push(
+ `Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
+ );
+ }
return warnings;
}
diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts
index dbceeec..c9a2b1f 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, 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";
+import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, 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");
@@ -49,7 +49,7 @@ export const exportSettings = (password: string) =>
export const previewSettingsImport = (password: string) =>
invoke("preview_settings_import", { password });
export const applySettingsImport = (password: string) =>
- invoke("apply_settings_import", { password });
+ invoke("apply_settings_import", { password });
// AWS
export const awsSsoRefresh = (projectId: string) =>
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
index 3ddb549..ae2250c 100644
--- a/app/src/lib/types.ts
+++ b/app/src/lib/types.ts
@@ -316,6 +316,20 @@ export interface SettingsImportPreview {
llamacpp_base_url: string | null;
openai_compatible_base_url: string | null;
gateway_api_base: string | null;
+ /** Whether the import sets a custom Docker image, and its name if so —
+ * this is the image every project container is created from, so worth
+ * more attention than an ordinary setting. */
+ image_source: ImageSource;
+ custom_image_name: string | null;
+}
+
+/** What `apply_settings_import` returns: the settings that were actually
+ * saved, plus a note for each keychain secret the import carried but could
+ * not be restored (a partial keychain failure must not read as unqualified
+ * success just because the settings half went through). */
+export interface SettingsImportOutcome {
+ settings: AppSettings;
+ secret_restore_warnings: string[];
}
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride