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 bb03102..2665d31 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" @@ -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] @@ -39,7 +43,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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcf9a24..7069ff0 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,86 @@ 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()) } +/// 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) +} + +/// 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, +} + +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); + } + } + + /// 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(()) + } +} + +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> { + AuthStore::app_local().write(auth) +} + #[tauri::command] fn read_config() -> serde_json::Value { read_json(&jucode_dir().join("config.json")) @@ -381,7 +462,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 +482,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 +492,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 +500,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 +512,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 +538,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 +566,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 +612,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 +624,7 @@ fn jucode_access_token() -> Result { }), ); } - let _ = write_json(&path, ¤t); + let _ = write_auth(&mut current); Ok(new_access) } @@ -567,7 +645,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 +682,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 +707,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 +763,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 +2677,132 @@ 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; + 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..d6a5896 --- /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)] + pub 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)); + } +}