From 226edc3e6a1ac356ec39affed09bd88481531621 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 19:12:46 +0000 Subject: [PATCH 1/3] Scope macos-private-api to macOS so Linux/Windows builds tauri.macos.conf.json holds macOSPrivateApi + transparent, but the cargo feature was enabled unconditionally, so tauri-build rejected the mismatch everywhere else and cargo test has been failing on main since v0.2.0. Co-authored-by: Gao Yu --- src-tauri/Cargo.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bb03102..9a567a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset", "tray-icon", "macos-private-api"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon"] } base64 = "0.22" tauri-plugin-opener = "2" tauri-plugin-dialog = "2" @@ -39,7 +39,10 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } -# macOS 侧栏磨砂(NSVisualEffectView) +# macOS 侧栏磨砂(NSVisualEffectView)。透明窗口 + macOSPrivateApi 只在 +# tauri.macos.conf.json 里开启,所以对应的 cargo feature 也必须只在 macOS 打开: +# 否则 tauri-build 在 Linux/Windows 上会因 feature 与配置不符而报错。 [target."cfg(target_os = \"macos\")".dependencies] +tauri = { version = "2", features = ["macos-private-api"] } window-vibrancy = "0.5" From ca145f2fa9222a65237a0f9735e592c79099225a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 19:13:50 +0000 Subject: [PATCH 2/3] Encrypt auth.json credentials at rest behind an opt-in setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider API keys and the JuCode OAuth token pair are stored as ChaCha20-Poly1305 envelopes ("jcenc1:") keyed by a 32-byte random secret.key created 0600 in the app config dir. This is obfuscation against casual reads — backups, synced home directories, support bundles — not protection from anything running as the user; docs/secrets.md says so. Off by default via config.json's encrypt_secrets, because ~/.jucode/auth.json is shared with the jucode CLI engine: the desktop drives whatever CLI version the user has installed, and that CLI would read the envelope as an API key. Enabling it is for people who only reach the engine through Desktop. Reads are format-agnostic in both directions, so plaintext files load unchanged and the next save migrates them; an envelope that won't open is left intact so a lost key surfaces as "not logged in" rather than a corrupt save. Values stay JSON strings, and expiry timestamps stay in the clear so the login/refresh checks work without a key. auth.json itself is now chmod 0600 whether or not encryption is on. Co-authored-by: Gao Yu --- docs/secrets.md | 98 +++++++++ src-tauri/Cargo.lock | 97 ++++++++- src-tauri/Cargo.toml | 4 + src-tauri/src/lib.rs | 97 +++++++-- src-tauri/src/secrets.rs | 423 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 701 insertions(+), 18 deletions(-) create mode 100644 docs/secrets.md create mode 100644 src-tauri/src/secrets.rs diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..f7c0214 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,98 @@ +# 凭据静态加密(auth.json) + +桌面端会把 provider 的 API key 和 JuCode 的 OAuth token 写进 `~/.jucode/auth.json`。 +默认它们是明文。打开 `encrypt_secrets` 之后,桌面端在**下一次写入** `auth.json` 时 +会把这些字段就地加密。 + +## 安全边界(先读这一段) + +这是**防"顺手看一眼"的加密,不是威胁模型意义上的安全存储**。 + +密钥是一个 32 字节随机数,放在应用配置目录里的 `secret.key`(`0600`), +和密文躺在同一台机器上。任何以当前用户身份运行的进程都能读到密钥、解出明文。 + +它挡得住的是:备份文件、同步到网盘的 home 目录、录屏 / 共享屏幕时被瞄到、 +贴给别人的排查日志和支持包——也就是凭据以明文形式**离开**这台机器的那些路径。 + +它挡不住的是:本机恶意软件、拿到你用户身份的攻击者、有 root 的人。 +需要那一档的防护得接系统钥匙串(macOS Keychain / Windows DPAPI / libsecret), +这次没做。 + +## 默认关闭的原因 + +`~/.jucode/auth.json` 是和 `jucode` CLI 引擎**共享**的文件:引擎自己也会读里面的 +provider key 和 OAuth token,而且桌面端驱动的是用户机器上任意版本的 CLI +(应用不再内置引擎)。CLI 不认识这里的密文封装格式,所以默认开启会让引擎直接 +拿到一串密文当 API key 用,聊天和登录全断。 + +因此:**只有当你只通过桌面端使用引擎、不直接跑 `jucode` 命令行时,才建议打开。** +等 CLI 侧支持同一套封装格式后,这个开关可以改成默认开启。 + +## 打开 / 关闭 + +在 `~/.jucode/config.json` 里加一个顶层布尔字段: + +```json +{ + "encrypt_secrets": true +} +``` + +生效方式是"写时加密":改完设置后,下一次保存凭据(在设置里填写 / 清除某个 +provider 的 key、退出登录、或者 OAuth access token 到期自动续期)会把整份 +`auth.json` 里的凭据字段一起加密。想立刻生效就随便存一次 key。 + +改回 `false` 是对称的:下一次写入会把凭据以明文写回去。 + +读取永远是双向兼容的——明文值原样读出,密文值解密后读出,所以: + +- 升级前的老 `auth.json` 照常可用,不需要迁移步骤; +- CLI 在开关打开前后写进去的明文值也照常可用。 + +## 加密的字段 + +只加密凭据本身: + +- `providers.*` 的每一个字符串值(各家 API key) +- `jucode.access_token`、`jucode.refresh_token` + +`jucode.access_expires_at` 之类的记账字段保持明文,这样"登录状态""要不要续期" +这些判断不需要密钥也能做。 + +## 格式 + +密文值仍然是 JSON 字符串,只是带上前缀: + +``` +jcenc1: +``` + +- 算法:ChaCha20-Poly1305(AEAD,`chacha20poly1305` crate),每个值一个随机 + 12 字节 nonce; +- 密钥:`secret.key` 的 32 字节,首次使用时用 OS 随机源生成,以 `0600` 创建 + (不是先写再 chmod,避免中间有一瞬间是全局可读的); +- AEAD 的 tag 意味着被改过的密文会解密失败,而不是悄悄解出垃圾。 + +值保持"字符串"这个 JSON 形状是刻意的:即使某个只认明文的读者拿到加密后的文件, +它至少还能正常解析 JSON,而不是炸在类型上。 + +## 密钥所在目录 + +和 Tauri 的 `app_config_dir()` 一致(bundle identifier `com.jucode.desktop`): + +| 平台 | 路径 | +| ------- | ------------------------------------------------------------- | +| macOS | `~/Library/Application Support/com.jucode.desktop/secret.key` | +| Linux | `$XDG_CONFIG_HOME/com.jucode.desktop/secret.key`(默认 `~/.config/...`) | +| Windows | `%APPDATA%\com.jucode.desktop\secret.key` | + +`0600` 只在 Unix 上生效;Windows 依赖 `%APPDATA%` 本身的用户目录 ACL。 + +## 丢了密钥会怎样 + +解不开的值会被**原样保留**,不会被清空或覆盖,桌面端表现为"没配置 key / 未登录": +重新填一次 API key、重新登录一次即可。这么设计是为了让密钥丢失表现成一次重新登录, +而不是一次静默的文件损坏。 + +同理,`secret.key` 存在但长度不对时,代码会直接报错而不是重新生成一把新密钥—— +否则那些还在用旧密钥的密文就永久打不开了。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3aa7722..d72d29f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ 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 = "aho-corasick" version = "1.1.4" @@ -471,6 +481,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -483,6 +517,17 @@ 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", + "zeroize", +] + [[package]] name = "combine" version = "4.6.7" @@ -618,6 +663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -751,6 +797,8 @@ name = "desktop" version = "0.2.1" dependencies = [ "base64 0.22.1", + "chacha20poly1305", + "getrandom 0.2.17", "portable-pty", "serde", "serde_json", @@ -1836,6 +1884,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 = "ioctl-rs" version = "0.1.6" @@ -2613,6 +2670,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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.5" @@ -2872,6 +2935,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -3031,7 +3105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -3041,7 +3115,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -4860,6 +4943,16 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[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/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9a567a0..2665d31 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,10 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" portable-pty = "0.8" ureq = { version = "2", features = ["json"] } +# auth.json 凭据静态加密(见 docs/secrets.md)。纯 Rust、无 C 依赖, +# default-features 关掉以免多带一份 std/stream 支持。 +chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc", "getrandom"] } +getrandom = "0.2" # 仅桌面端的插件(updater / 单实例 / 进程重启) [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcf9a24..0af648f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod browser; mod capture; mod claude_history; mod installer; +mod secrets; mod shell_env; use backend::BackendKind; @@ -356,6 +357,56 @@ fn write_json(path: &std::path::Path, value: &serde_json::Value) -> Result<(), S std::fs::write(path, format!("{text}\n")).map_err(|error| error.to_string()) } +fn auth_path() -> PathBuf { + jucode_dir().join("auth.json") +} + +/// Whether new writes to auth.json encrypt credentials at rest. +/// +/// Off by default: auth.json is shared with the `jucode` CLI engine, which +/// reads the same keys and doesn't know the envelope format, so turning this on +/// is a deliberate choice for people who only drive the engine through Desktop. +/// See `docs/secrets.md`. +fn encrypt_secrets_enabled(config: &serde_json::Value) -> bool { + config + .get("encrypt_secrets") + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +/// Reads auth.json with credentials decrypted. Plaintext files (written before +/// this feature, or by the CLI) load unchanged. +fn read_auth() -> serde_json::Value { + let mut auth = read_json(&auth_path()); + reveal_secrets(&mut auth); + auth +} + +/// `read_auth` for read-modify-write callers — see `read_json_strict`. +fn read_auth_strict() -> Result { + let mut auth = read_json_strict(&auth_path())?; + reveal_secrets(&mut auth); + Ok(auth) +} + +fn reveal_secrets(auth: &mut serde_json::Value) { + if let Ok(store) = secrets::SecretStore::app_local() { + store.reveal(auth); + } +} + +/// Writes auth.json, encrypting credentials first when the setting is on. The +/// file is restricted to the owner either way. +fn write_auth(auth: &mut serde_json::Value) -> Result<(), String> { + let path = auth_path(); + if encrypt_secrets_enabled(&read_json(&jucode_dir().join("config.json"))) { + secrets::SecretStore::app_local()?.protect(auth)?; + } + write_json(&path, auth)?; + secrets::restrict_to_owner(&path); + Ok(()) +} + #[tauri::command] fn read_config() -> serde_json::Value { read_json(&jucode_dir().join("config.json")) @@ -381,7 +432,7 @@ fn write_config(patch: serde_json::Value) -> Result<(), String> { /// is present. #[tauri::command] fn read_auth_providers() -> Vec { - let auth = read_json(&jucode_dir().join("auth.json")); + let auth = read_auth(); let mut providers: Vec = auth .get("providers") .and_then(|v| v.as_object()) @@ -401,8 +452,7 @@ fn read_auth_providers() -> Vec { #[tauri::command] fn set_auth_key(provider: String, key: String) -> Result<(), String> { - let path = jucode_dir().join("auth.json"); - let mut current = read_json_strict(&path)?; + let mut current = read_auth_strict()?; let root = current .as_object_mut() .ok_or_else(|| "auth.json is not an object".to_string())?; @@ -412,7 +462,7 @@ fn set_auth_key(provider: String, key: String) -> Result<(), String> { if let Some(map) = providers.as_object_mut() { map.insert(provider, serde_json::Value::String(key)); } - write_json(&path, ¤t) + write_auth(&mut current) } /// Removes a provider's stored credential — logout (jucode) / clear key (others). @@ -420,8 +470,7 @@ fn set_auth_key(provider: String, key: String) -> Result<(), String> { /// itself can be revoked from the web console's 授权设备 page. #[tauri::command] fn remove_auth_key(provider: String) -> Result<(), String> { - let path = jucode_dir().join("auth.json"); - let mut current = read_json_strict(&path)?; + let mut current = read_auth_strict()?; if provider == "jucode" { if let Some(root) = current.as_object_mut() { root.remove("jucode"); @@ -433,7 +482,7 @@ fn remove_auth_key(provider: String) -> Result<(), String> { { map.remove(&provider); } - write_json(&path, ¤t) + write_auth(&mut current) } const DEFAULT_API_URL: &str = "https://api.jucode.cn"; @@ -459,8 +508,7 @@ fn unix_now() -> u64 { /// access token is missing or near expiry. The CLI engine owns login; this /// only keeps the Desktop's own API calls authenticated between logins. fn jucode_access_token() -> Result { - let path = jucode_dir().join("auth.json"); - let auth = read_json(&path); + let auth = read_auth(); let jucode = auth.get("jucode").cloned().unwrap_or_else(|| serde_json::json!({})); let access = jucode .get("access_token") @@ -488,7 +536,7 @@ fn jucode_access_token() -> Result { .lock() .map_err(|e| format!("lock poisoned: {e}"))?; // Re-read after acquiring the lock: another thread may have just refreshed. - let fresh = read_json(&path); + let fresh = read_auth(); let fresh_jucode = fresh.get("jucode").cloned().unwrap_or_else(|| serde_json::json!({})); let fresh_access = fresh_jucode .get("access_token") @@ -534,7 +582,7 @@ fn jucode_access_token() -> Result { .get("refresh_expires_in") .and_then(|v| v.as_u64()) .unwrap_or(90 * 24 * 3600); - let mut current = read_json(&path); + let mut current = read_auth(); if let Some(root) = current.as_object_mut() { root.insert( "jucode".to_string(), @@ -546,7 +594,7 @@ fn jucode_access_token() -> Result { }), ); } - let _ = write_json(&path, ¤t); + let _ = write_auth(&mut current); Ok(new_access) } @@ -567,7 +615,7 @@ fn jucode_get(path: &str) -> Result { #[tauri::command(async)] fn fetch_marketplace() -> Result { let url = format!("{}/v1/skills/marketplace", jucode_api_url()); - let key = read_json(&jucode_dir().join("auth.json")) + let key = read_auth() .get("jucode") .and_then(|j| j.get("access_token")) .and_then(|v| v.as_str()) @@ -604,7 +652,7 @@ fn fetch_usage_logs() -> Result { /// API key stored under providers.deepseek in auth.json. #[tauri::command(async)] fn fetch_deepseek_balance() -> Result { - let key = read_json(&jucode_dir().join("auth.json")) + let key = read_auth() .get("providers") .and_then(|p| p.get("deepseek")) .and_then(|v| v.as_str()) @@ -629,7 +677,7 @@ fn transcribe_audio( mime: Option, language: Option, ) -> Result { - let key = read_json(&jucode_dir().join("auth.json")) + let key = read_auth() .get("providers") .and_then(|p| p.get("mimo")) .and_then(|v| v.as_str()) @@ -685,7 +733,7 @@ fn generate_text( system: String, prompt: String, ) -> Result { - let key = read_json(&jucode_dir().join("auth.json")) + let key = read_auth() .get("providers") .and_then(|p| p.get(&provider)) .and_then(|v| v.as_str()) @@ -2599,6 +2647,23 @@ mod tests { let _ = std::fs::remove_file(&p); } + #[test] + fn secret_encryption_is_opt_in() { + use super::encrypt_secrets_enabled; + assert!(!encrypt_secrets_enabled(&serde_json::json!({}))); + assert!(!encrypt_secrets_enabled( + &serde_json::json!({ "encrypt_secrets": false }) + )); + // A non-bool value must not be read as "on" — a half-written config + // should never silently start encrypting what the CLI has to read. + assert!(!encrypt_secrets_enabled( + &serde_json::json!({ "encrypt_secrets": "yes" }) + )); + assert!(encrypt_secrets_enabled( + &serde_json::json!({ "encrypt_secrets": true }) + )); + } + #[test] fn corrupt_file_errors_instead_of_clobbering() { let p = tmp("corrupt.json"); diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs new file mode 100644 index 0000000..fe0cc52 --- /dev/null +++ b/src-tauri/src/secrets.rs @@ -0,0 +1,423 @@ +//! At-rest encryption for the credentials the desktop app writes into +//! `~/.jucode/auth.json` (provider API keys and the JuCode OAuth token pair). +//! +//! Threat model: this protects against *casual reads* — a backup, a synced +//! home directory, a screen share, a support bundle. The key sits next to the +//! app's own config in `app_config_dir/secret.key` with `0600`, so anything +//! running as the user can still decrypt. It is deliberately not an OS keychain +//! integration and is not a defence against local malware. +//! +//! `auth.json` is a shared contract with the `jucode` CLI engine, which reads +//! the same file and knows nothing about this envelope, so encryption is +//! opt-in (`encrypt_secrets` in `config.json`). See `docs/secrets.md`. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use base64::Engine as _; +use chacha20poly1305::aead::Aead; +use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce}; +use serde_json::Value; + +/// Prefix marking a JSON string as an encrypted envelope. Values keep their +/// JSON *shape* (still a string), so a file written by a newer desktop still +/// parses everywhere — it just carries ciphertext the reader can't use. +const ENVELOPE_PREFIX: &str = "jcenc1:"; +const KEY_FILE: &str = "secret.key"; +const KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; + +/// Tauri's bundle identifier, mirrored so the key lands in the same directory +/// `AppHandle::path().app_config_dir()` would pick without threading a handle +/// through every call site. +const APP_IDENTIFIER: &str = "com.jucode.desktop"; + +/// Serializes first-run key creation so two threads can't each generate a key +/// and have one silently overwrite the other's (which would strand secrets +/// encrypted under the discarded key). +static KEY_INIT: Mutex<()> = Mutex::new(()); + +pub struct SecretStore { + dir: PathBuf, +} + +impl SecretStore { + /// The store backing the running app: `app_config_dir/secret.key`. + pub fn app_local() -> Result { + Ok(Self { + dir: app_config_dir()?, + }) + } + + #[cfg(test)] + fn in_dir(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + fn key_path(&self) -> PathBuf { + self.dir.join(KEY_FILE) + } + + /// Reads the machine-local key, generating it on first use. A short or + /// unreadable key file is an error rather than a silent regeneration: the + /// caller must not overwrite still-encrypted secrets with a fresh key. + fn key(&self) -> Result<[u8; KEY_LEN], String> { + let path = self.key_path(); + if let Some(key) = read_key(&path)? { + return Ok(key); + } + let _guard = KEY_INIT + .lock() + .map_err(|e| format!("secret key lock poisoned: {e}"))?; + if let Some(key) = read_key(&path)? { + return Ok(key); + } + let mut key = [0u8; KEY_LEN]; + getrandom::getrandom(&mut key) + .map_err(|e| format!("failed to generate secret key: {e}"))?; + write_key(&path, &key)?; + Ok(key) + } + + fn cipher(&self) -> Result { + let key = self.key()?; + Ok(ChaCha20Poly1305::new(Key::from_slice(&key))) + } + + /// `jcenc1:`. + pub fn encrypt(&self, plaintext: &str) -> Result { + let cipher = self.cipher()?; + let mut nonce = [0u8; NONCE_LEN]; + getrandom::getrandom(&mut nonce).map_err(|e| format!("failed to generate nonce: {e}"))?; + let sealed = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext.as_bytes()) + .map_err(|_| "failed to encrypt secret".to_string())?; + let mut blob = Vec::with_capacity(NONCE_LEN + sealed.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&sealed); + Ok(format!( + "{ENVELOPE_PREFIX}{}", + base64::engine::general_purpose::STANDARD.encode(&blob) + )) + } + + pub fn decrypt(&self, envelope: &str) -> Result { + let body = envelope + .strip_prefix(ENVELOPE_PREFIX) + .ok_or_else(|| "value is not an encrypted envelope".to_string())?; + let blob = base64::engine::general_purpose::STANDARD + .decode(body.as_bytes()) + .map_err(|e| format!("malformed secret envelope: {e}"))?; + if blob.len() <= NONCE_LEN { + return Err("malformed secret envelope: truncated".to_string()); + } + let (nonce, sealed) = blob.split_at(NONCE_LEN); + let plain = self + .cipher()? + .decrypt(Nonce::from_slice(nonce), sealed) + .map_err(|_| "failed to decrypt secret (wrong key or tampered file)".to_string())?; + String::from_utf8(plain).map_err(|e| format!("decrypted secret is not utf-8: {e}")) + } + + /// Encrypts every plaintext credential in an `auth.json` value in place. + /// Values that are already envelopes are left alone, so re-saving a file + /// whose key went missing can't double-encrypt it. + pub fn protect(&self, auth: &mut Value) -> Result<(), String> { + let mut result = Ok(()); + for_each_secret(auth, |slot| { + if result.is_err() || slot.trim().is_empty() || is_envelope(slot) { + return; + } + match self.encrypt(slot) { + Ok(envelope) => *slot = envelope, + Err(e) => result = Err(e), + } + }); + result + } + + /// Decrypts every envelope in an `auth.json` value in place. Plaintext + /// values (pre-encryption files, or files the CLI wrote) pass through + /// untouched, and an envelope we can't open is left as-is so a lost key + /// surfaces as "not logged in" instead of a corrupted save. + pub fn reveal(&self, auth: &mut Value) { + for_each_secret(auth, |slot| { + if !is_envelope(slot) { + return; + } + if let Ok(plain) = self.decrypt(slot) { + *slot = plain; + } + }); + } +} + +pub fn is_envelope(value: &str) -> bool { + value.starts_with(ENVELOPE_PREFIX) +} + +/// Visits every string in `auth` that holds a credential: each entry of the +/// `providers` map plus the JuCode OAuth token pair. Expiry timestamps and any +/// other bookkeeping stay in the clear so `read_auth_providers` and the refresh +/// check still work without a key. +fn for_each_secret(auth: &mut Value, mut visit: impl FnMut(&mut String)) { + if let Some(providers) = auth.get_mut("providers").and_then(Value::as_object_mut) { + for (_, value) in providers.iter_mut() { + if let Value::String(s) = value { + visit(s); + } + } + } + if let Some(jucode) = auth.get_mut("jucode").and_then(Value::as_object_mut) { + for field in ["access_token", "refresh_token"] { + if let Some(Value::String(s)) = jucode.get_mut(field) { + visit(s); + } + } + } +} + +fn read_key(path: &Path) -> Result, String> { + match std::fs::read(path) { + Ok(bytes) if bytes.len() == KEY_LEN => { + let mut key = [0u8; KEY_LEN]; + key.copy_from_slice(&bytes); + Ok(Some(key)) + } + Ok(_) => Err(format!( + "{} is not a {KEY_LEN}-byte key; refusing to replace it", + path.display() + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("failed to read {}: {e}", path.display())), + } +} + +fn write_key(path: &Path, key: &[u8; KEY_LEN]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {e}", parent.display()))?; + } + // Create with the restrictive mode up front: a chmod after the write would + // leave the key world-readable for an instant. + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|e| format!("failed to create {}: {e}", path.display()))?; + use std::io::Write as _; + file.write_all(key) + .map_err(|e| format!("failed to write {}: {e}", path.display())) +} + +/// Restricts an existing file to owner-only. Best effort: no-op on Windows, +/// where the app config directory already lives under the user's profile. +pub fn restrict_to_owner(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + #[cfg(not(unix))] + let _ = path; +} + +/// Mirrors `AppHandle::path().app_config_dir()` for the bundle identifier. +fn app_config_dir() -> Result { + let base = if cfg!(windows) { + std::env::var_os("APPDATA").map(PathBuf::from) + } else if cfg!(target_os = "macos") { + home_dir().map(|h| h.join("Library").join("Application Support")) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| home_dir().map(|h| h.join(".config"))) + }; + base.map(|b| b.join(APP_IDENTIFIER)) + .ok_or_else(|| "could not locate the app config directory".to_string()) +} + +fn home_dir() -> Option { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(name: &str) -> Self { + let dir = + std::env::temp_dir().join(format!("jucode-secrets-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn store(&self) -> SecretStore { + SecretStore::in_dir(&self.0) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn encrypt_decrypt_roundtrip() { + let dir = TempDir::new("roundtrip"); + let store = dir.store(); + let envelope = store.encrypt("sk-secret-key").unwrap(); + assert!(is_envelope(&envelope)); + assert!(!envelope.contains("sk-secret-key")); + assert_eq!(store.decrypt(&envelope).unwrap(), "sk-secret-key"); + } + + #[test] + fn same_plaintext_encrypts_to_different_envelopes() { + let dir = TempDir::new("nonce"); + let store = dir.store(); + assert_ne!( + store.encrypt("same").unwrap(), + store.encrypt("same").unwrap() + ); + } + + #[test] + fn tampered_envelope_is_rejected() { + let dir = TempDir::new("tamper"); + let store = dir.store(); + let envelope = store.encrypt("sk-secret-key").unwrap(); + let body = envelope.strip_prefix(ENVELOPE_PREFIX).unwrap(); + let mut blob = base64::engine::general_purpose::STANDARD + .decode(body.as_bytes()) + .unwrap(); + let last = blob.len() - 1; + blob[last] ^= 0x01; + let forged = format!( + "{ENVELOPE_PREFIX}{}", + base64::engine::general_purpose::STANDARD.encode(&blob) + ); + assert!(store.decrypt(&forged).is_err()); + } + + #[test] + fn plaintext_value_is_not_treated_as_an_envelope() { + let dir = TempDir::new("plain"); + assert!(!is_envelope("sk-plain")); + assert!(dir.store().decrypt("sk-plain").is_err()); + } + + #[test] + fn key_is_reused_across_stores() { + let dir = TempDir::new("reuse"); + let envelope = dir.store().encrypt("token").unwrap(); + assert_eq!(dir.store().decrypt(&envelope).unwrap(), "token"); + } + + #[cfg(unix)] + #[test] + fn key_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new("perms"); + let store = dir.store(); + store.encrypt("x").unwrap(); + let mode = std::fs::metadata(store.key_path()) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn truncated_key_file_errors_instead_of_regenerating() { + let dir = TempDir::new("short-key"); + let store = dir.store(); + std::fs::write(store.key_path(), b"too short").unwrap(); + assert!(store.encrypt("x").is_err()); + } + + #[test] + fn protect_then_reveal_roundtrips_auth() { + let dir = TempDir::new("auth-roundtrip"); + let store = dir.store(); + let original = json!({ + "providers": { "deepseek": "sk-deepseek", "mimo": "sk-mimo" }, + "jucode": { + "access_token": "at-1", + "refresh_token": "rt-1", + "access_expires_at": 1234, + } + }); + let mut auth = original.clone(); + store.protect(&mut auth).unwrap(); + + let on_disk = serde_json::to_string(&auth).unwrap(); + assert!(!on_disk.contains("sk-deepseek")); + assert!(!on_disk.contains("rt-1")); + // Non-secret bookkeeping stays readable so the refresh check works + // without touching the key. + assert_eq!(auth["jucode"]["access_expires_at"], json!(1234)); + + store.reveal(&mut auth); + assert_eq!(auth, original); + } + + #[test] + fn plaintext_auth_still_loads_and_migrates_on_save() { + let dir = TempDir::new("migrate"); + let store = dir.store(); + // A file written before this feature existed: nothing is an envelope. + let mut auth = json!({ "providers": { "deepseek": "sk-legacy" } }); + store.reveal(&mut auth); + assert_eq!(auth["providers"]["deepseek"], json!("sk-legacy")); + + store.protect(&mut auth).unwrap(); + assert!(is_envelope(auth["providers"]["deepseek"].as_str().unwrap())); + store.reveal(&mut auth); + assert_eq!(auth["providers"]["deepseek"], json!("sk-legacy")); + } + + #[test] + fn protect_leaves_existing_envelopes_alone() { + let dir = TempDir::new("no-double"); + let store = dir.store(); + let mut auth = json!({ "providers": { "deepseek": "sk-1" } }); + store.protect(&mut auth).unwrap(); + let once = auth["providers"]["deepseek"].as_str().unwrap().to_string(); + store.protect(&mut auth).unwrap(); + assert_eq!(auth["providers"]["deepseek"].as_str().unwrap(), once); + } + + #[test] + fn protect_skips_empty_values() { + let dir = TempDir::new("empty"); + let store = dir.store(); + let mut auth = json!({ "providers": { "deepseek": "" } }); + store.protect(&mut auth).unwrap(); + assert_eq!(auth["providers"]["deepseek"], json!("")); + } + + #[test] + fn envelope_from_another_key_is_left_intact() { + let mine = TempDir::new("key-a"); + let theirs = TempDir::new("key-b"); + let foreign = theirs.store().encrypt("sk-theirs").unwrap(); + let mut auth = json!({ "providers": { "deepseek": foreign.clone() } }); + mine.store().reveal(&mut auth); + assert_eq!(auth["providers"]["deepseek"], json!(foreign)); + } +} From acb6fc5665da7834c7c2152bd12078d52d7df6c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 19:15:48 +0000 Subject: [PATCH 3/3] Cover the auth.json encryption switch with tests over temp paths Bundle the credential file, the config holding encrypt_secrets, and the key store into an AuthStore so the migration path can be exercised without mutating $HOME for the whole test process: plaintext loads and the next save encrypts it, flipping the switch off writes plaintext back, the default leaves the CLI-shared file in the clear, and saves land at 0600. Co-authored-by: Gao Yu --- src-tauri/src/lib.rs | 195 +++++++++++++++++++++++++++++++++------ src-tauri/src/secrets.rs | 2 +- 2 files changed, 168 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0af648f..7069ff0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -357,10 +357,6 @@ fn write_json(path: &std::path::Path, value: &serde_json::Value) -> Result<(), S std::fs::write(path, format!("{text}\n")).map_err(|error| error.to_string()) } -fn auth_path() -> PathBuf { - jucode_dir().join("auth.json") -} - /// Whether new writes to auth.json encrypt credentials at rest. /// /// Off by default: auth.json is shared with the `jucode` CLI engine, which @@ -374,37 +370,71 @@ fn encrypt_secrets_enabled(config: &serde_json::Value) -> bool { .unwrap_or(false) } -/// Reads auth.json with credentials decrypted. Plaintext files (written before -/// this feature, or by the CLI) load unchanged. -fn read_auth() -> serde_json::Value { - let mut auth = read_json(&auth_path()); - reveal_secrets(&mut auth); - auth +/// The credential file plus the two things needed to interpret it: the config +/// holding the `encrypt_secrets` switch, and the key store. Bundled so the +/// migration behavior can be tested against temp paths instead of `$HOME`. +struct AuthStore { + auth: PathBuf, + config: PathBuf, + keys: Option, } -/// `read_auth` for read-modify-write callers — see `read_json_strict`. -fn read_auth_strict() -> Result { - let mut auth = read_json_strict(&auth_path())?; - reveal_secrets(&mut auth); - Ok(auth) -} +impl AuthStore { + fn app_local() -> Self { + let dir = jucode_dir(); + Self { + auth: dir.join("auth.json"), + config: dir.join("config.json"), + keys: secrets::SecretStore::app_local().ok(), + } + } + + /// Reads auth.json with credentials decrypted. Plaintext files (written + /// before this feature, or by the CLI) load unchanged. + fn read(&self) -> serde_json::Value { + let mut auth = read_json(&self.auth); + self.reveal(&mut auth); + auth + } + + /// `read` for read-modify-write callers — see `read_json_strict`. + fn read_strict(&self) -> Result { + let mut auth = read_json_strict(&self.auth)?; + self.reveal(&mut auth); + Ok(auth) + } + + fn reveal(&self, auth: &mut serde_json::Value) { + if let Some(keys) = &self.keys { + keys.reveal(auth); + } + } -fn reveal_secrets(auth: &mut serde_json::Value) { - if let Ok(store) = secrets::SecretStore::app_local() { - store.reveal(auth); + /// Writes auth.json, encrypting credentials first when the setting is on. + /// The file is restricted to the owner either way. + fn write(&self, auth: &mut serde_json::Value) -> Result<(), String> { + if encrypt_secrets_enabled(&read_json(&self.config)) { + self.keys + .as_ref() + .ok_or_else(|| "could not locate the app config directory".to_string())? + .protect(auth)?; + } + write_json(&self.auth, auth)?; + secrets::restrict_to_owner(&self.auth); + Ok(()) } } -/// Writes auth.json, encrypting credentials first when the setting is on. The -/// file is restricted to the owner either way. +fn read_auth() -> serde_json::Value { + AuthStore::app_local().read() +} + +fn read_auth_strict() -> Result { + AuthStore::app_local().read_strict() +} + fn write_auth(auth: &mut serde_json::Value) -> Result<(), String> { - let path = auth_path(); - if encrypt_secrets_enabled(&read_json(&jucode_dir().join("config.json"))) { - secrets::SecretStore::app_local()?.protect(auth)?; - } - write_json(&path, auth)?; - secrets::restrict_to_owner(&path); - Ok(()) + AuthStore::app_local().write(auth) } #[tauri::command] @@ -2647,6 +2677,115 @@ mod tests { let _ = std::fs::remove_file(&p); } + // --- auth.json credential encryption --- + + /// An `AuthStore` over throwaway paths, with the switch preset. + fn auth_store(name: &str, encrypt: bool) -> (super::AuthStore, std::path::PathBuf) { + let dir = std::env::temp_dir() + .join(format!("jucode-authstore-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("config.json"), + serde_json::json!({ "encrypt_secrets": encrypt }).to_string(), + ) + .unwrap(); + let store = super::AuthStore { + auth: dir.join("auth.json"), + config: dir.join("config.json"), + keys: Some(super::secrets::SecretStore::in_dir(&dir)), + }; + (store, dir) + } + + /// An auth.json as the CLI leaves it after `/login` plus a manually entered + /// provider key: everything in the clear. + fn plaintext_auth() -> serde_json::Value { + serde_json::json!({ + "providers": { "deepseek": "sk-legacy" }, + "jucode": { + "access_token": "at-1", + "refresh_token": "rt-1", + "access_expires_at": 9, + } + }) + } + + #[test] + fn plaintext_auth_loads_then_next_save_encrypts_it() { + let (store, dir) = auth_store("migrate", true); + std::fs::write(&store.auth, plaintext_auth().to_string()).unwrap(); + // Nothing is re-written on read, so the pre-existing file still loads. + assert_eq!(store.read(), plaintext_auth()); + + let mut current = store.read_strict().unwrap(); + current["providers"]["mimo"] = serde_json::json!("sk-mimo"); + store.write(&mut current).unwrap(); + + let on_disk = std::fs::read_to_string(&store.auth).unwrap(); + assert!(!on_disk.contains("sk-legacy"), "{on_disk}"); + assert!(!on_disk.contains("sk-mimo"), "{on_disk}"); + assert!(!on_disk.contains("rt-1"), "{on_disk}"); + // Expiry stays readable: the refresh check must work without a key. + assert!(on_disk.contains("\"access_expires_at\": 9"), "{on_disk}"); + + let back = store.read(); + assert_eq!(back["providers"]["deepseek"], serde_json::json!("sk-legacy")); + assert_eq!(back["providers"]["mimo"], serde_json::json!("sk-mimo")); + assert_eq!(back["jucode"]["refresh_token"], serde_json::json!("rt-1")); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn disabling_encryption_writes_plaintext_again() { + let (store, dir) = auth_store("disable", true); + let mut current = plaintext_auth(); + store.write(&mut current).unwrap(); + assert!(!std::fs::read_to_string(&store.auth).unwrap().contains("sk-legacy")); + + let mut current = store.read_strict().unwrap(); + std::fs::write(&store.config, r#"{"encrypt_secrets":false}"#).unwrap(); + store.write(&mut current).unwrap(); + + let on_disk = std::fs::read_to_string(&store.auth).unwrap(); + assert!(on_disk.contains("sk-legacy"), "{on_disk}"); + assert!(on_disk.contains("rt-1"), "{on_disk}"); + + let _ = std::fs::remove_dir_all(dir); + } + + /// The CLI engine reads this same file, so leaving the switch off has to + /// keep it byte-for-byte readable to anything that only knows plaintext. + #[test] + fn default_settings_leave_auth_in_the_clear() { + let (store, dir) = auth_store("default-off", false); + let mut current = plaintext_auth(); + store.write(&mut current).unwrap(); + + let on_disk = std::fs::read_to_string(&store.auth).unwrap(); + assert!(on_disk.contains("sk-legacy"), "{on_disk}"); + assert!(on_disk.contains("rt-1"), "{on_disk}"); + assert_eq!(store.read(), plaintext_auth()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[test] + fn auth_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let (store, dir) = auth_store("perms", false); + std::fs::write(&store.auth, "{}").unwrap(); + std::fs::set_permissions(&store.auth, std::fs::Permissions::from_mode(0o644)).unwrap(); + + store.write(&mut plaintext_auth()).unwrap(); + + let mode = std::fs::metadata(&store.auth).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn secret_encryption_is_opt_in() { use super::encrypt_secrets_enabled; diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index fe0cc52..d6a5896 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -50,7 +50,7 @@ impl SecretStore { } #[cfg(test)] - fn in_dir(dir: impl Into) -> Self { + pub fn in_dir(dir: impl Into) -> Self { Self { dir: dir.into() } }