Closes#35. Designed together in the issue's comments: global settings only (no docker volumes), the user's password is the lock/key, and the export is portable as one file.
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, the model gateway's provider API key and master key) — to one password-encrypted file, and restores it on another machine. Per-project settings, per-project secrets, and Docker volumes are deliberately out of scope; this is not a project backup.
Crypto (storage/settings_crypto.rs): Argon2id derives a 256-bit key from the 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. Salt and nonce are random per export and stored in the clear in the file header — their job is uniqueness, not secrecy.
Dialogs opened from Rust, matching the boundary file_commands.rs's pick_save_path/pick_files_to_upload already establish and document: a frontend-driven dialog handing Rust a host path is the exact shape of bug that produced this app's past criticals. preview_settings_import resolves the chosen import path itself and remembers it (AppState::pending_settings_import) so apply_settings_import re-reads the same file without a path crossing back over IPC.
Nothing lingers in memory: the password is re-entered (not cached) between preview and apply, so nothing holds decrypted plaintext — secrets included — for longer than one command's execution. The preview returned to the frontend carries counts and presence flags only, never a secret value.
Import semantics: settings are replaced wholesale (an import is "restore this environment," not a field-by-field merge), but only secrets actually present in the file are written — an absent secret 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.
Also added storage::secure::store_gateway_master_key and get_gateway_master_key (read-only, unlike get_or_create_gateway_master_key which mints one as a side effect) — neither existed, and import needs to restore an exact captured value rather than mint a new random one.
Test plan
cargo check / cargo clippy — clean, no new warnings
cargo test — 514 passed (16 new: crypto round-trip/tamper/wrong-password tests, model tests confirming the preview never carries a secret value)
npx tsc --noEmit / npm run test — clean, 624 passed (13 new: export/import modal flows, preview-description pure function)
Not tested against a real second machine — the actual "restore on a fresh install" scenario needs manual verification
## Summary
Closes #35. Designed together in the issue's comments: global settings only (no docker volumes), the user's password is the lock/key, and the export is portable as one file.
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, the model gateway's provider API key and master key) — to one password-encrypted file, and restores it on another machine. Per-project settings, per-project secrets, and Docker volumes are deliberately out of scope; this is not a project backup.
**Crypto** (`storage/settings_crypto.rs`): Argon2id derives a 256-bit key from the 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. Salt and nonce are random per export and stored in the clear in the file header — their job is uniqueness, not secrecy.
**Dialogs opened from Rust**, matching the boundary `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already establish and document: a frontend-driven dialog handing Rust a host path is the exact shape of bug that produced this app's past criticals. `preview_settings_import` resolves the chosen import path itself and remembers it (`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without a path crossing back over IPC.
**Nothing lingers in memory**: the password is re-entered (not cached) between preview and apply, so nothing holds decrypted plaintext — secrets included — for longer than one command's execution. The preview returned to the frontend carries counts and presence flags only, never a secret value.
**Import semantics**: settings are replaced wholesale (an import is "restore this environment," not a field-by-field merge), but only secrets actually present in the file are written — an absent secret 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.
Also added `storage::secure::store_gateway_master_key` and `get_gateway_master_key` (read-only, unlike `get_or_create_gateway_master_key` which mints one as a side effect) — neither existed, and import needs to restore an exact captured value rather than mint a new random one.
## Test plan
- [x] `cargo check` / `cargo clippy` — clean, no new warnings
- [x] `cargo test` — 514 passed (16 new: crypto round-trip/tamper/wrong-password tests, model tests confirming the preview never carries a secret value)
- [x] `npx tsc --noEmit` / `npm run test` — clean, 624 passed (13 new: export/import modal flows, preview-description pure function)
- [ ] Not tested against a real second machine — the actual "restore on a fresh install" scenario needs manual verification
Closes#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,
the model gateway's provider API key and master key) — to one
password-encrypted file, and restores it on another machine.
Per-project settings, per-project secrets, and Docker volumes are
deliberately out of scope; this is not a project backup.
Designed with the user in issue #35's comments: global settings only, no
docker volumes, the password is the lock/key, and the export is portable
as one file.
Crypto (storage/settings_crypto.rs): 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),
AES-256-GCM does the actual encryption. A wrong password fails GCM's
authentication tag rather than producing silent garbage. Salt and nonce
are random per export and stored in the clear in the file header — their
job is uniqueness, not secrecy.
The save/open dialogs are opened from Rust, matching the boundary
file_commands.rs's pick_save_path/pick_files_to_upload already establish:
a frontend-driven dialog handing Rust a host path is the exact shape of
bug that produced this app's past criticals. preview_settings_import
resolves the chosen import path itself and remembers it
(AppState::pending_settings_import) so apply_settings_import re-reads the
same file without a path crossing back over IPC. The password is
re-entered rather than cached between preview and apply, so nothing here
holds decrypted plaintext in memory for longer than one command's
execution; the preview returned to the frontend carries counts and
presence flags only, never a secret value.
Import replaces settings wholesale (an import is "restore this
environment"), but only writes secrets actually present in the file — an
absent secret means "the source machine never had this configured," not
"delete this on import."
Added storage::secure::store_gateway_master_key and get_gateway_master_key
(read-only, unlike get_or_create_gateway_master_key which mints one as a
side effect) since neither existed and import needs to restore an exact
captured value rather than mint a new random one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
The headline finding: WebTerminalSettings::access_token is a live bearer
credential for a server that binds every interface, stored as a plain
field on AppSettings — which this feature was exporting and importing
wholesale as if it were as inert as a port number. A crafted export file
could set web_terminal.enabled and access_token together, and importing
it (with no more warning than any other setting change) would silently
stand up a LAN-listening terminal server with an attacker-known token on
the victim's next launch.
Fixed by carving the token out into ExportedSecrets, same as the other
three global secrets, with the same "only overwrite what the import
actually has" treatment — except that has to be done by hand here, since
this one lives inside the AppSettings blob that gets replaced wholesale
rather than in the keychain. Added SettingsImportPreview::
enables_web_terminal so "this turns on a listening service" gets its own
visible warning in the confirmation modal rather than hiding inside a
generic "settings replaced" bullet list.
Also fixed:
- read_and_decrypt checked format_version only after attempting to parse
the full payload, so a future version bump that isn't
deserialize-compatible would fail on the shape mismatch before the
version check ever ran — and serde's type-mismatch errors quote the
offending value inline, which is a real leak path since the plaintext
here can hold a live credential. Now probes just the version field
first, and neither error path interpolates the underlying serde message
into what the user sees.
- apply_settings_import cleared the pending-import path before it could
fail, so a rejected import (an invalid host path, anything
update_settings validates) dead-ended the modal with no way back except
cancelling and reopening the file picker. The path is now only cleared
on success.
- Secrets are restored before the settings replace runs, not after —
replacing settings is what triggers reconcile_gateway, and restoring
secrets afterward left a real window where a gateway recreation
happened against the destination's stale keys.
- The 8-character password minimum was frontend-only; export_settings now
enforces it too, since that's the actual boundary a weak password has
to cross. The derived key and decrypted plaintext are wrapped in
zeroize::Zeroizing (already in the tree via aes-gcm).
Added test coverage the review named as missing: format-version
ordering, the generic-error-message guarantee, non_blank's blank-vs-
absent handling, and the new web-terminal preview/warning behavior on
both sides of the IPC boundary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
A rejected import (bad env var name, disallowed host path) used to leave
keychain secrets already overwritten while the settings themselves stayed
unchanged. apply_settings_import now runs update_settings's validation
(extracted into validate_settings_update) before any secret write.
Also from this review round: sharpened two format-version tests that
previously passed against the pre-fix code too, added a direct test for
split_settings_and_secrets, warned on a dormant web terminal token even
when the terminal import leaves it off, matched the password-length check
to the frontend's unit of measure, zeroized the export plaintext buffer,
and surfaced non-blank Ollama/llama.cpp/OpenAI-compatible/gateway base
URLs in the import preview so a traffic redirect isn't silent.
Round 4 review findings:
- Disclose and warn on a custom Docker image the import would set (HIGH):
it's the image every project container is created from, so an
undisclosed change here was a sharper version of the redirected-base-URL
problem round 3 already flagged for the model backends.
- Recreate a running gateway container when an import restores a new
secret with the shape unchanged (MEDIUM): reconcile_gateway's shape
comparison can't see a secret-only change, so the container would
otherwise keep serving old key material indefinitely.
- Report keychain write failures back to the caller instead of only
logging them (MEDIUM): apply_settings_import now returns
SettingsImportOutcome with secret_restore_warnings so a partial restore
can't read as unqualified success.
- Pin a hash of the previewed file's ciphertext and refuse to apply if it
changed on disk (MEDIUM): closes a TOCTOU between preview and apply.
- Sanitize and cap every free-form string a preview surfaces, and move the
warning boxes above the replace list in the UI (MEDIUM): an unbounded
base URL or image name could otherwise push the security warnings below
the scroll fold.
- Validate the Docker socket path on import the same as the SSH key and CA
cert paths (LOW): it was the one mounted host path validate_settings_update
didn't cover.
- Fix ExportedSecrets::is_empty() to treat whitespace-only as blank, like
every other secret-presence check in this feature (LOW).
- Authenticate the file header as AEAD associated data (LOW, defense in
depth) and correct two doc comments that overstated the password not
being cached.
jknapp
merged commit dd48baac8a into main2026-08-27 21:53:42 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Closes #35. Designed together in the issue's comments: global settings only (no docker volumes), the user's password is the lock/key, and the export is portable as one file.
Exports the host environment — global
AppSettings(already the non-secret shape persisted tosettings.json) plus the global secrets that live in the OS keychain instead (the shared Claude Code OAuth login, the model gateway's provider API key and master key) — to one password-encrypted file, and restores it on another machine. Per-project settings, per-project secrets, and Docker volumes are deliberately out of scope; this is not a project backup.Crypto (
storage/settings_crypto.rs): Argon2id derives a 256-bit key from the 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. Salt and nonce are random per export and stored in the clear in the file header — their job is uniqueness, not secrecy.Dialogs opened from Rust, matching the boundary
file_commands.rs'spick_save_path/pick_files_to_uploadalready establish and document: a frontend-driven dialog handing Rust a host path is the exact shape of bug that produced this app's past criticals.preview_settings_importresolves the chosen import path itself and remembers it (AppState::pending_settings_import) soapply_settings_importre-reads the same file without a path crossing back over IPC.Nothing lingers in memory: the password is re-entered (not cached) between preview and apply, so nothing holds decrypted plaintext — secrets included — for longer than one command's execution. The preview returned to the frontend carries counts and presence flags only, never a secret value.
Import semantics: settings are replaced wholesale (an import is "restore this environment," not a field-by-field merge), but only secrets actually present in the file are written — an absent secret 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.
Also added
storage::secure::store_gateway_master_keyandget_gateway_master_key(read-only, unlikeget_or_create_gateway_master_keywhich mints one as a side effect) — neither existed, and import needs to restore an exact captured value rather than mint a new random one.Test plan
cargo check/cargo clippy— clean, no new warningscargo test— 514 passed (16 new: crypto round-trip/tamper/wrong-password tests, model tests confirming the preview never carries a secret value)npx tsc --noEmit/npm run test— clean, 624 passed (13 new: export/import modal flows, preview-description pure function)