From 8df2169f4cd39b1c63ce1d7dd593472b224b86c0 Mon Sep 17 00:00:00 2001 From: G2Bent <944921374@qq.com> Date: Wed, 25 Mar 2026 11:28:15 +0800 Subject: [PATCH] security: harden file permissions, fix timing attack and API key exposure - Add `constant_time_eq` crate and use constant-time comparison in `is_authorized()` to prevent timing side-channel attacks on the proxy API key - Redact API key from proxyd stdout (only print first 8 chars) to prevent the full key from being captured in systemd journal / logs - Add `private_create_new_options()` helper in utils.rs that opens files with O_CREAT | mode 0o600 on Unix atomically, eliminating the TOCTOU window between file creation and `chmod` - Use `private_create_new_options()` when writing auth.json, accounts store, api-proxy.key, and SSH private key temp files - Add `write_private_file()` helper in store.rs for shadow/corrupt backup writes that also sets 0o600 from the initial open - Improve `set_private_permissions()` on Windows to call `icacls` and restrict access to the current user (previously a no-op) --- src-tauri/Cargo.lock | 7 +++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/auth.rs | 5 ++--- src-tauri/src/proxy_daemon.rs | 4 +++- src-tauri/src/proxy_service.rs | 11 +++++----- src-tauri/src/remote_service.rs | 22 ++++++++++---------- src-tauri/src/store.rs | 36 ++++++++++++++++++++++++--------- src-tauri/src/utils.rs | 36 ++++++++++++++++++++++++++++++++- 8 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 75ebae2..c5259ab 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "async-stream", "axum", "base64 0.22.1", + "constant_time_eq", "dirs 6.0.0", "if-addrs", "log", @@ -766,6 +767,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e48a5b1..4d7bd4b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,3 +43,4 @@ async-stream = "0.3" rfd = "0.15" if-addrs = "0.13" zip = { version = "0.6", default-features = false, features = ["deflate"] } +constant_time_eq = "0.3" diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 09f254b..bdb3aa0 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -16,6 +16,7 @@ use time::OffsetDateTime; use crate::models::ExtractedAuth; use crate::models::PreparedOauthLogin; +use crate::utils::private_create_new_options; use crate::utils::set_private_permissions; use crate::utils::truncate_for_error; @@ -656,9 +657,7 @@ fn write_auth_file_atomically(path: &Path, contents: &[u8]) -> Result<(), String )); let write_result = (|| -> Result<(), String> { - let mut temp_file = fs::OpenOptions::new() - .create_new(true) - .write(true) + let mut temp_file = private_create_new_options() .open(&temp_path) .map_err(|e| format!("创建临时 auth.json 失败 {}: {e}", temp_path.display()))?; temp_file diff --git a/src-tauri/src/proxy_daemon.rs b/src-tauri/src/proxy_daemon.rs index 7e69f0f..d5b43a6 100644 --- a/src-tauri/src/proxy_daemon.rs +++ b/src-tauri/src/proxy_daemon.rs @@ -56,7 +56,9 @@ pub async fn run_proxy_daemon(options: ProxyDaemonOptions) -> Result<(), String> println!("data_dir={}", options.data_dir.display()); println!("listen=http://{}:{port}/v1", options.host); if let Some(api_key) = status.api_key.as_deref() { - println!("api_key={api_key}"); + // 仅打印前 8 位,避免完整 API Key 写入 systemd journal / 日志文件 + let preview = &api_key[..api_key.len().min(8)]; + println!("api_key_preview={preview}... (full key stored in data_dir/api-proxy.key)"); } println!("upstream=codex"); diff --git a/src-tauri/src/proxy_service.rs b/src-tauri/src/proxy_service.rs index b5eaaaa..b1a0ae0 100644 --- a/src-tauri/src/proxy_service.rs +++ b/src-tauri/src/proxy_service.rs @@ -53,6 +53,7 @@ use crate::store::load_store_from_path; use crate::store::save_store_to_path; use crate::usage::resolve_chatgpt_base_origin; use crate::utils::now_unix_seconds; +use crate::utils::private_create_new_options; use crate::utils::set_private_permissions; use crate::utils::truncate_for_error; @@ -1634,7 +1635,7 @@ fn is_authorized(headers: &HeaderMap, api_key: &str) -> bool { .get("x-api-key") .and_then(|value| value.to_str().ok()) { - if value == api_key { + if constant_time_eq::constant_time_eq(value.as_bytes(), api_key.as_bytes()) { return true; } } @@ -1644,7 +1645,9 @@ fn is_authorized(headers: &HeaderMap, api_key: &str) -> bool { .and_then(|value| value.to_str().ok()) { if let Some(token) = value.strip_prefix("Bearer ") { - return token == api_key; + if constant_time_eq::constant_time_eq(token.as_bytes(), api_key.as_bytes()) { + return true; + } } } @@ -1776,9 +1779,7 @@ fn write_private_file_atomically(path: &Path, contents: &[u8]) -> Result<(), Str )); let write_result = (|| -> Result<(), String> { - let mut temp_file = fs::OpenOptions::new() - .create_new(true) - .write(true) + let mut temp_file = private_create_new_options() .open(&temp_path) .map_err(|error| { format!("创建 API Key 临时文件失败 {}: {error}", temp_path.display()) diff --git a/src-tauri/src/remote_service.rs b/src-tauri/src/remote_service.rs index 0f6a053..4984586 100644 --- a/src-tauri/src/remote_service.rs +++ b/src-tauri/src/remote_service.rs @@ -1494,18 +1494,18 @@ impl PreparedAuth { sanitize_service_fragment(&server.id), now_unix_seconds() )); - fs::write(&temp_path, private_key).map_err(|error| { - format!("写入临时 SSH 私钥文件失败 {}: {error}", temp_path.display()) - })?; - #[cfg(unix)] + // 使用 private_create_new_options 确保文件从创建时就是 0o600 权限, + // 消除 fs::write + set_permissions 两步之间的 TOCTOU 窗口 { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&temp_path) - .map_err(|error| format!("读取临时 SSH 私钥文件权限失败: {error}"))? - .permissions(); - perms.set_mode(0o600); - fs::set_permissions(&temp_path, perms) - .map_err(|error| format!("设置临时 SSH 私钥文件权限失败: {error}"))?; + use std::io::Write as _; + let mut f = crate::utils::private_create_new_options() + .open(&temp_path) + .map_err(|error| { + format!("写入临时 SSH 私钥文件失败 {}: {error}", temp_path.display()) + })?; + f.write_all(private_key.as_bytes()).map_err(|error| { + format!("写入临时 SSH 私钥文件失败 {}: {error}", temp_path.display()) + })?; } Ok(Self { temp_identity_file: Some(temp_path), diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index f3a5d6f..7d45c46 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -17,6 +17,7 @@ use crate::models::dedupe_account_variants; use crate::models::AccountsStore; use crate::models::StoredAccount; use crate::utils::now_unix_seconds; +use crate::utils::private_create_new_options; use crate::utils::set_private_permissions; use crate::utils::short_account; @@ -245,9 +246,7 @@ fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<(), String> { )); let write_result = (|| -> Result<(), String> { - let mut temp_file = fs::OpenOptions::new() - .create_new(true) - .write(true) + let mut temp_file = private_create_new_options() .open(&temp_path) .map_err(|e| format!("创建临时存储文件失败 {}: {e}", temp_path.display()))?; temp_file @@ -315,14 +314,12 @@ fn write_store_shadow_backups(path: &Path, contents: &[u8]) -> Result<(), String if latest_backup.exists() { let latest_contents = fs::read(&latest_backup) .map_err(|e| format!("读取最新备份失败 {}: {e}", latest_backup.display()))?; - fs::write(&previous_backup, latest_contents) + write_private_file(&previous_backup, &latest_contents) .map_err(|e| format!("写入上一个备份失败 {}: {e}", previous_backup.display()))?; - set_private_permissions(&previous_backup); } - fs::write(&latest_backup, contents) + write_private_file(&latest_backup, contents) .map_err(|e| format!("写入最新备份失败 {}: {e}", latest_backup.display()))?; - set_private_permissions(&latest_backup); Ok(()) } @@ -457,6 +454,28 @@ fn is_store_backup_candidate(path: &Path) -> bool { || name.starts_with(".accounts.json.tmp-") } +/// 以 0o600 权限(Unix)原子写入文件,替换 fs::write + set_private_permissions 的两步操作, +/// 消除文件短暂对其他用户可读的窗口。 +fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), std::io::Error> { + use std::io::Write as _; + + // 若文件已存在,先截断再写;不存在则新建,均以受限权限打开 + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(path)?; + f.write_all(contents)?; + + #[cfg(windows)] + set_private_permissions(path); + + Ok(()) +} + fn file_modified_at(path: &Path) -> i64 { fs::metadata(path) .ok() @@ -477,9 +496,8 @@ fn backup_corrupted_store_file(path: &Path, raw: &str) -> Result fs::OpenOptions { + let mut opts = fs::OpenOptions::new(); + opts.create_new(true).write(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + opts +} + pub(crate) fn prepare_process_path() { let mut merged = preferred_executable_dirs(); if let Some(current_path) = env::var_os("PATH") {