From d6f69290e768e86c56ffa41d9f6f156ec02a9da5 Mon Sep 17 00:00:00 2001 From: jiangzeyang Date: Thu, 13 Aug 2026 11:52:50 +0800 Subject: [PATCH] =?UTF-8?q?fix=20=E5=8A=A0=E5=AF=86=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E7=9A=84=E6=9C=AC=E5=9C=B0=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: GPT-5 --- .gitignore | 2 + Cargo.lock | 53 ++- apps/src-tauri/Cargo.lock | 31 +- apps/src-tauri/src/app_storage/migration.rs | 56 ++- crates/core/Cargo.toml | 7 + crates/core/src/storage/accounts.rs | 61 +-- crates/core/src/storage/accounts_tests.rs | 11 + crates/core/src/storage/mod.rs | 16 + crates/core/src/storage/token_crypto.rs | 413 ++++++++++++++++++ crates/core/src/storage/tokens.rs | 181 ++++++-- crates/core/src/storage/tokens_tests.rs | 101 +++++ .../env_overrides/catalog/items.rs | 12 +- docs/en/SECURITY.md | 5 +- .../report/environment-and-runtime-config.md | 13 + docs/zh-CN/SECURITY.md | 5 +- ...15\347\275\256\350\257\264\346\230\216.md" | 13 + 16 files changed, 916 insertions(+), 64 deletions(-) create mode 100644 crates/core/src/storage/token_crypto.rs diff --git a/.gitignore b/.gitignore index 69c7e008a..c30038c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ *.db *.sqlite codexmanager.rpc-token +*.token-key +account-token.key codex resume*.txt .tmp_stage_ps/ .tmp_stage_sh/ diff --git a/Cargo.lock b/Cargo.lock index 0d4347861..2409f3fff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,6 +270,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" @@ -351,7 +357,9 @@ version = "0.5.3" dependencies = [ "base64", "chrono", + "keyring", "rand 0.8.5", + "ring", "rusqlite", "serde", "serde_json", @@ -495,6 +503,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1587,6 +1605,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "security-framework 2.11.1", + "security-framework 3.0.1", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "libc" version = "0.2.180" @@ -1713,7 +1745,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -2587,7 +2619,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1415a607e92bec364ea2cf9264646dcce0f91e6d65281bd6f2819cca3bf39c8" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2974,7 +3019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3558,7 +3603,7 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" dependencies = [ - "core-foundation", + "core-foundation 0.9.4", "home", "jni", "log", diff --git a/apps/src-tauri/Cargo.lock b/apps/src-tauri/Cargo.lock index a5244b241..bdff4f2b5 100644 --- a/apps/src-tauri/Cargo.lock +++ b/apps/src-tauri/Cargo.lock @@ -885,7 +885,9 @@ version = "0.5.3" dependencies = [ "base64 0.22.1", "chrono", + "keyring", "rand 0.8.5", + "ring", "rusqlite", "serde", "serde_json", @@ -2966,6 +2968,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3244,7 +3260,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -4882,6 +4898,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/apps/src-tauri/src/app_storage/migration.rs b/apps/src-tauri/src/app_storage/migration.rs index fb204cf4f..b1fd67fe1 100644 --- a/apps/src-tauri/src/app_storage/migration.rs +++ b/apps/src-tauri/src/app_storage/migration.rs @@ -3,6 +3,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; +use codexmanager_core::storage::default_token_encryption_key_file_path; + const PRIMARY_APP_IDENTIFIER: &str = "com.codexmanager.desktop"; const QA_APP_IDENTIFIER: &str = "com.codexmanager.desktop.qa"; @@ -86,6 +88,7 @@ pub(super) fn maybe_migrate_legacy_db(current_db: &Path) { /// # 返回 /// 返回函数执行结果 fn copy_db_snapshot(source: &Path, target: &Path) -> Result<(), String> { + copy_fallback_token_key(source, target)?; remove_db_sidecars(target); if target.is_file() { fs::remove_file(target).map_err(|err| { @@ -127,6 +130,31 @@ fn copy_db_snapshot(source: &Path, target: &Path) -> Result<(), String> { Ok(()) } +fn copy_fallback_token_key(source_db: &Path, target_db: &Path) -> Result<(), String> { + let source_key = default_token_encryption_key_file_path(source_db); + let target_key = default_token_encryption_key_file_path(target_db); + if source_key == target_key || !source_key.is_file() { + return Ok(()); + } + + if let Some(parent) = target_key.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "create token key target directory {} failed: {err}", + parent.display() + ) + })?; + } + fs::copy(&source_key, &target_key).map_err(|err| { + format!( + "copy account token key {} -> {} failed: {err}", + source_key.display(), + target_key.display() + ) + })?; + Ok(()) +} + pub(crate) fn create_pre_migration_backup( current_db: &Path, app_version: &str, @@ -343,7 +371,7 @@ mod tests { create_pre_migration_backup, maybe_migrate_legacy_db, profile_db_candidates, PRIMARY_APP_IDENTIFIER, QA_APP_IDENTIFIER, }; - use codexmanager_core::storage::{now_ts, Account, Storage}; + use codexmanager_core::storage::{now_ts, Account, Storage, Token}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -395,6 +423,16 @@ mod tests { }) .expect("insert account"); storage + .insert_token(&Token { + account_id: "acc-1".to_string(), + id_token: "id-token".to_string(), + access_token: "access-token".to_string(), + refresh_token: "refresh-token".to_string(), + api_key_access_token: Some("api-key-access-token".to_string()), + last_refresh: now_ts(), + }) + .expect("insert token"); + storage } /// 函数 `profile_db_candidates_only_seed_qa_profile_from_primary_profile` @@ -459,6 +497,14 @@ mod tests { let migrated = Storage::open(&qa_db).expect("open migrated qa storage"); migrated.init().expect("init migrated qa storage"); assert_eq!(migrated.account_count().expect("count accounts"), 1); + assert_eq!( + migrated + .find_token_by_account_id("acc-1") + .expect("find migrated token") + .expect("migrated token") + .access_token, + "access-token" + ); let _ = std::fs::remove_dir_all(&root); } @@ -498,6 +544,14 @@ mod tests { let backup_storage = Storage::open(&backup_path).expect("open backup"); assert_eq!(backup_storage.account_count().expect("count accounts"), 1); + assert_eq!( + backup_storage + .find_token_by_account_id("acc-1") + .expect("find backup token") + .expect("backup token") + .refresh_token, + "refresh-token" + ); let _ = std::fs::remove_dir_all(&root); } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 69215d020..329695435 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -10,6 +10,13 @@ rusqlite = { path = "../rusqlite", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +ring = "0.17" url = "2" urlencoding = "2" chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] } diff --git a/crates/core/src/storage/accounts.rs b/crates/core/src/storage/accounts.rs index e7a548c7a..a8dad1637 100644 --- a/crates/core/src/storage/accounts.rs +++ b/crates/core/src/storage/accounts.rs @@ -7,7 +7,7 @@ use super::agent_identities::delete_account_agent_identity_for_account_sql; use super::conversation_bindings::delete_conversation_bindings_for_account_sql; use super::events::delete_events_for_account_sql; use super::key_id_filters::{normalize_text_ids, text_id_in_clause, SQLITE_IN_CLAUSE_BATCH_SIZE}; -use super::tokens::delete_token_for_account_sql; +use super::tokens::{decrypt_token_field, delete_token_for_account_sql}; use super::usage::delete_usage_snapshots_for_account_sql; use super::{ @@ -99,6 +99,8 @@ impl Storage { )); } + let encrypted_token = self.encrypt_account_token_for_storage(token)?; + let tx = self.conn.unchecked_transaction()?; tx.execute( "INSERT INTO accounts ( @@ -192,12 +194,12 @@ impl Storage { api_key_access_token = excluded.api_key_access_token, last_refresh = excluded.last_refresh", ( - &token.account_id, - &token.id_token, - &token.access_token, - &token.refresh_token, - &token.api_key_access_token, - token.last_refresh, + &encrypted_token.account_id, + &encrypted_token.id_token, + &encrypted_token.access_token, + &encrypted_token.refresh_token, + &encrypted_token.api_key_access_token, + encrypted_token.last_refresh, ), )?; if let Some(identity) = agent_identity { @@ -1585,7 +1587,7 @@ impl Storage { let mut stmt = self.conn.prepare(&sql)?; let mut rows = stmt.query([identity])?; if let Some(row) = rows.next()? { - Ok(Some(map_gateway_candidate_row(row)?)) + Ok(Some(map_gateway_candidate_row(self, row)?)) } else { Ok(None) } @@ -1922,14 +1924,7 @@ fn list_account_usage_refresh_token_targets_by_statuses_chunk( AccountUsageRefreshTokenTarget { account_id, workspace_id: row.get(1)?, - token: Token { - account_id: row.get(2)?, - id_token: row.get(3)?, - access_token: row.get(4)?, - refresh_token: row.get(5)?, - api_key_access_token: row.get(6)?, - last_refresh: row.get(7)?, - }, + token: map_token_row_from_offset(storage, row, 2)?, }, row.get(8)?, row.get(9)?, @@ -2179,7 +2174,7 @@ fn list_gateway_candidates_filtered( let mut rows = stmt.query(params_from_iter(params))?; let mut out = Vec::new(); while let Some(row) = rows.next()? { - out.push(map_gateway_candidate_row(row)?); + out.push(map_gateway_candidate_row(storage, row)?); } Ok(out) } @@ -2420,14 +2415,30 @@ fn map_account_row_from_offset(row: &Row<'_>, offset: usize) -> Result /// /// # 返回 /// 返回函数执行结果 -fn map_token_row_from_offset(row: &Row<'_>, offset: usize) -> Result { +fn map_token_row_from_offset(storage: &Storage, row: &Row<'_>, offset: usize) -> Result { + let account_id: String = row.get(offset)?; Ok(Token { - account_id: row.get(offset)?, - id_token: row.get(offset + 1)?, - access_token: row.get(offset + 2)?, - refresh_token: row.get(offset + 3)?, - api_key_access_token: row.get(offset + 4)?, + id_token: decrypt_token_field(storage, &account_id, "id_token", row.get(offset + 1)?)?, + access_token: decrypt_token_field( + storage, + &account_id, + "access_token", + row.get(offset + 2)?, + )?, + refresh_token: decrypt_token_field( + storage, + &account_id, + "refresh_token", + row.get(offset + 3)?, + )?, + api_key_access_token: row + .get::<_, Option>(offset + 4)? + .map(|value| { + decrypt_token_field(storage, &account_id, "api_key_access_token", value) + }) + .transpose()?, last_refresh: row.get(offset + 5)?, + account_id, }) } @@ -2442,9 +2453,9 @@ fn map_token_row_from_offset(row: &Row<'_>, offset: usize) -> Result { /// /// # 返回 /// 返回函数执行结果 -fn map_gateway_candidate_row(row: &Row<'_>) -> Result<(Account, Token)> { +fn map_gateway_candidate_row(storage: &Storage, row: &Row<'_>) -> Result<(Account, Token)> { let account = map_account_row_from_offset(row, 0)?; - let token = map_token_row_from_offset(row, 10)?; + let token = map_token_row_from_offset(storage, row, 10)?; Ok((account, token)) } diff --git a/crates/core/src/storage/accounts_tests.rs b/crates/core/src/storage/accounts_tests.rs index 14e557ec3..737da9644 100644 --- a/crates/core/src/storage/accounts_tests.rs +++ b/crates/core/src/storage/accounts_tests.rs @@ -191,6 +191,17 @@ fn upsert_imported_account_bundle_merges_metadata_and_token_in_one_call() { .expect("token exists"); assert_eq!(found_token.access_token, "imported-access"); assert_eq!(found_token.refresh_token, "imported-refresh"); + + let raw_access: String = storage + .conn + .query_row( + "SELECT access_token FROM tokens WHERE account_id = ?1", + [&updated.id], + |row| row.get(0), + ) + .expect("read raw imported token"); + assert!(raw_access.starts_with("cmenc:v1:")); + assert!(!raw_access.contains("imported-access")); } #[test] diff --git a/crates/core/src/storage/mod.rs b/crates/core/src/storage/mod.rs index 974a734f5..0d17d168a 100644 --- a/crates/core/src/storage/mod.rs +++ b/crates/core/src/storage/mod.rs @@ -35,6 +35,7 @@ mod request_log_query; mod request_logs; mod request_token_stats; mod settings; +mod token_crypto; mod tokens; mod usage; @@ -47,6 +48,10 @@ pub use model_catalog_v2::{ }; pub use proxy_profiles::derive_proxy_profile_url_metadata; +pub fn default_token_encryption_key_file_path(database_path: impl AsRef) -> std::path::PathBuf { + token_crypto::default_key_file_path(database_path.as_ref()) +} + #[derive(Debug, Clone)] pub struct Account { pub id: String, @@ -1620,6 +1625,7 @@ pub struct ModelCatalogStorageSnapshot { pub struct Storage { conn: Connection, applied_migrations: RefCell>>, + token_cipher: token_crypto::TokenCipher, } impl Storage { @@ -1654,11 +1660,15 @@ impl Storage { /// # 返回 /// 返回函数执行结果 pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); let conn = Connection::open(path)?; Self::configure_file_connection(&conn)?; + let token_cipher = token_crypto::TokenCipher::for_database(path, &conn) + .map_err(token_crypto::to_sql_error)?; Ok(Self { conn, applied_migrations: RefCell::new(None), + token_cipher, }) } @@ -1676,9 +1686,12 @@ impl Storage { pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory()?; Self::configure_connection(&conn)?; + let token_cipher = + token_crypto::TokenCipher::ephemeral().map_err(token_crypto::to_sql_error)?; Ok(Self { conn, applied_migrations: RefCell::new(None), + token_cipher, }) } @@ -2274,6 +2287,9 @@ impl Storage { "130_accounts_subject_identity", include_str!("../../migrations/130_accounts_subject_identity.sql"), )?; + self.apply_compat_migration("131_encrypt_account_tokens_at_rest", |storage| { + storage.encrypt_plaintext_account_tokens() + })?; self.ensure_api_key_rotation_columns()?; self.ensure_api_key_account_group_filter_column()?; self.ensure_aggregate_apis_table()?; diff --git a/crates/core/src/storage/token_crypto.rs b/crates/core/src/storage/token_crypto.rs new file mode 100644 index 000000000..3d5e4bf42 --- /dev/null +++ b/crates/core/src/storage/token_crypto.rs @@ -0,0 +1,413 @@ +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN}; +use ring::rand::{SecureRandom, SystemRandom}; +use rusqlite::Connection; +use std::error::Error; +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const ENV_TOKEN_KEY: &str = "CODEXMANAGER_TOKEN_ENCRYPTION_KEY"; +const ENV_TOKEN_KEY_FILE: &str = "CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE"; +const KEYRING_SERVICE: &str = "CodexManager"; +const KEYRING_USER: &str = "account-token-encryption-v1"; +const ENCRYPTED_PREFIX: &str = "cmenc:v1:"; +const DEFAULT_KEY_FILENAME: &str = "codexmanager.token-key"; +const KEY_LEN: usize = 32; + +#[derive(Debug)] +pub(super) struct TokenCryptoError { + message: String, +} + +impl TokenCryptoError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for TokenCryptoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl Error for TokenCryptoError {} + +pub(super) fn to_sql_error(error: TokenCryptoError) -> rusqlite::Error { + rusqlite::Error::ToSqlConversionFailure(Box::new(error)) +} + +#[derive(Debug)] +pub(super) struct TokenCipher { + key: LessSafeKey, +} + +impl TokenCipher { + pub(super) fn for_database( + database_path: &Path, + conn: &Connection, + ) -> Result { + let has_ciphertext = database_has_ciphertext(conn)?; + let key = load_database_key(database_path, has_ciphertext)?; + Self::from_key(key) + } + + pub(super) fn ephemeral() -> Result { + Self::from_key(generate_key()?) + } + + fn from_key(mut key: [u8; KEY_LEN]) -> Result { + let unbound_key = UnboundKey::new(&AES_256_GCM, &key); + key.fill(0); + let key = unbound_key + .map_err(|_| TokenCryptoError::new("invalid account token encryption key"))?; + Ok(Self { + key: LessSafeKey::new(key), + }) + } + + pub(super) fn encrypt( + &self, + account_id: &str, + field: &str, + plaintext: &str, + ) -> Result { + if plaintext.trim().is_empty() { + return Ok(plaintext.to_string()); + } + if plaintext.starts_with(ENCRYPTED_PREFIX) { + self.decrypt(account_id, field, plaintext)?; + return Ok(plaintext.to_string()); + } + if plaintext.starts_with("cmenc:") { + return Err(TokenCryptoError::new( + "unsupported account token ciphertext version", + )); + } + + let mut nonce_bytes = [0_u8; NONCE_LEN]; + SystemRandom::new() + .fill(&mut nonce_bytes) + .map_err(|_| TokenCryptoError::new("failed to generate account token nonce"))?; + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + let mut ciphertext = plaintext.as_bytes().to_vec(); + self.key + .seal_in_place_append_tag(nonce, Aad::from(aad(account_id, field)), &mut ciphertext) + .map_err(|_| TokenCryptoError::new("failed to encrypt account token"))?; + + let mut payload = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + payload.extend_from_slice(&nonce_bytes); + payload.extend_from_slice(&ciphertext); + Ok(format!( + "{ENCRYPTED_PREFIX}{}", + URL_SAFE_NO_PAD.encode(payload) + )) + } + + pub(super) fn decrypt( + &self, + account_id: &str, + field: &str, + stored: &str, + ) -> Result { + if stored.trim().is_empty() || !stored.starts_with("cmenc:") { + return Ok(stored.to_string()); + } + let encoded = stored.strip_prefix(ENCRYPTED_PREFIX).ok_or_else(|| { + TokenCryptoError::new("unsupported account token ciphertext version") + })?; + let payload = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| TokenCryptoError::new("invalid account token ciphertext encoding"))?; + if payload.len() <= NONCE_LEN { + return Err(TokenCryptoError::new( + "account token ciphertext is truncated", + )); + } + + let mut nonce_bytes = [0_u8; NONCE_LEN]; + nonce_bytes.copy_from_slice(&payload[..NONCE_LEN]); + let nonce = Nonce::assume_unique_for_key(nonce_bytes); + let mut ciphertext = payload[NONCE_LEN..].to_vec(); + let plaintext_len = self + .key + .open_in_place(nonce, Aad::from(aad(account_id, field)), &mut ciphertext) + .map_err(|_| { + TokenCryptoError::new( + "account token decryption failed; restore the original encryption key", + ) + })? + .len(); + ciphertext.truncate(plaintext_len); + String::from_utf8(ciphertext) + .map_err(|_| TokenCryptoError::new("decrypted account token is not valid UTF-8")) + } +} + +fn aad(account_id: &str, field: &str) -> Vec { + format!("codexmanager:account-token:v1\0{account_id}\0{field}").into_bytes() +} + +fn generate_key() -> Result<[u8; KEY_LEN], TokenCryptoError> { + let mut key = [0_u8; KEY_LEN]; + SystemRandom::new() + .fill(&mut key) + .map_err(|_| TokenCryptoError::new("failed to generate account token encryption key"))?; + Ok(key) +} + +fn load_database_key( + database_path: &Path, + has_ciphertext: bool, +) -> Result<[u8; KEY_LEN], TokenCryptoError> { + if let Some(value) = std::env::var_os(ENV_TOKEN_KEY) { + let value = value.to_string_lossy(); + return decode_key(value.trim(), ENV_TOKEN_KEY); + } + + if let Some(path) = std::env::var_os(ENV_TOKEN_KEY_FILE) { + return read_key_file(Path::new(&path)); + } + + let fallback_path = default_key_file_path(database_path); + if fallback_path.is_file() { + return read_key_file(&fallback_path); + } + + if let Some(key) = load_or_create_platform_key(has_ciphertext)? { + return Ok(key); + } + + if has_ciphertext { + return Err(TokenCryptoError::new( + "account token encryption key is unavailable; restore the OS credential or set CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE", + )); + } + create_or_read_key_file(&fallback_path) +} + +pub(super) fn default_key_file_path(database_path: &Path) -> PathBuf { + database_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(DEFAULT_KEY_FILENAME) +} + +fn decode_key(value: &str, source: &str) -> Result<[u8; KEY_LEN], TokenCryptoError> { + let mut decoded = STANDARD + .decode(value) + .or_else(|_| URL_SAFE_NO_PAD.decode(value)) + .map_err(|_| { + TokenCryptoError::new(format!( + "{source} must contain a base64-encoded 32-byte key" + )) + })?; + let result = key_from_bytes(&decoded, source); + decoded.fill(0); + result +} + +fn key_from_bytes(bytes: &[u8], source: &str) -> Result<[u8; KEY_LEN], TokenCryptoError> { + bytes.try_into().map_err(|_| { + TokenCryptoError::new(format!( + "{source} must contain exactly {KEY_LEN} key bytes" + )) + }) +} + +fn read_key_file(path: &Path) -> Result<[u8; KEY_LEN], TokenCryptoError> { + let bytes = fs::read(path).map_err(|err| { + TokenCryptoError::new(format!( + "failed to read account token encryption key file {}: {err}", + path.display() + )) + })?; + if bytes.len() == KEY_LEN { + return key_from_bytes(&bytes, "account token encryption key file"); + } + let text = std::str::from_utf8(&bytes).map_err(|_| { + TokenCryptoError::new("account token encryption key file is neither raw bytes nor base64") + })?; + decode_key(text.trim(), "account token encryption key file") +} + +fn create_or_read_key_file(path: &Path) -> Result<[u8; KEY_LEN], TokenCryptoError> { + let mut key = generate_key()?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + match options.open(path) { + Ok(mut file) => { + if let Err(err) = file.write_all(&key).and_then(|_| file.sync_all()) { + key.fill(0); + drop(file); + let _ = fs::remove_file(path); + return Err(TokenCryptoError::new(format!( + "failed to write account token encryption key file {}: {err}", + path.display() + ))); + } + Ok(key) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + key.fill(0); + read_key_file(path) + } + Err(err) => { + key.fill(0); + Err(TokenCryptoError::new(format!( + "failed to create account token encryption key file {}: {err}", + path.display() + ))) + } + } +} + +fn database_has_ciphertext(conn: &Connection) -> Result { + let has_tokens_table = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tokens')", + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|err| TokenCryptoError::new(format!("failed to inspect token storage: {err}")))?; + if !has_tokens_table { + return Ok(false); + } + + let has_api_key_column = conn + .query_row( + "SELECT EXISTS( + SELECT 1 FROM pragma_table_info('tokens') + WHERE name = 'api_key_access_token' + )", + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|err| { + TokenCryptoError::new(format!("failed to inspect token columns: {err}")) + })?; + let sql = if has_api_key_column { + "SELECT EXISTS( + SELECT 1 FROM tokens + WHERE id_token LIKE 'cmenc:%' + OR access_token LIKE 'cmenc:%' + OR refresh_token LIKE 'cmenc:%' + OR api_key_access_token LIKE 'cmenc:%' + )" + } else { + "SELECT EXISTS( + SELECT 1 FROM tokens + WHERE id_token LIKE 'cmenc:%' + OR access_token LIKE 'cmenc:%' + OR refresh_token LIKE 'cmenc:%' + )" + }; + + conn.query_row( + sql, + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|err| TokenCryptoError::new(format!("failed to inspect encrypted tokens: {err}"))) +} + +#[cfg(any( + target_os = "macos", + target_os = "windows" +))] +fn load_or_create_platform_key( + has_ciphertext: bool, +) -> Result, TokenCryptoError> { + let entry = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) { + Ok(entry) => entry, + Err(_) if !has_ciphertext => return Ok(None), + Err(err) => { + return Err(TokenCryptoError::new(format!( + "failed to open OS credential store for encrypted account tokens: {err}" + ))) + } + }; + + match entry.get_secret() { + Ok(mut secret) => { + let result = key_from_bytes(&secret, "OS credential store entry").map(Some); + secret.fill(0); + result + } + Err(keyring::Error::NoEntry) if has_ciphertext => Err(TokenCryptoError::new( + "OS credential store no longer contains the account token encryption key", + )), + Err(keyring::Error::NoEntry) => { + let mut key = generate_key()?; + match entry.set_secret(&key) { + Ok(()) => Ok(Some(key)), + Err(_) => { + key.fill(0); + Ok(None) + } + } + } + Err(err) if has_ciphertext => Err(TokenCryptoError::new(format!( + "failed to read OS credential store for encrypted account tokens: {err}" + ))), + Err(_) => Ok(None), + } +} + +#[cfg(not(any( + target_os = "macos", + target_os = "windows" +)))] +fn load_or_create_platform_key( + _has_ciphertext: bool, +) -> Result, TokenCryptoError> { + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_binds_ciphertext_to_account_and_field() { + let cipher = TokenCipher::ephemeral().expect("cipher"); + let encrypted = cipher + .encrypt("account-a", "access_token", "secret-token") + .expect("encrypt"); + + assert!(encrypted.starts_with(ENCRYPTED_PREFIX)); + assert_ne!(encrypted, "secret-token"); + assert_eq!( + cipher + .decrypt("account-a", "access_token", &encrypted) + .expect("decrypt"), + "secret-token" + ); + assert!(cipher + .decrypt("account-b", "access_token", &encrypted) + .is_err()); + assert!(cipher + .decrypt("account-a", "refresh_token", &encrypted) + .is_err()); + } + + #[test] + fn empty_values_remain_empty_for_sql_presence_checks() { + let cipher = TokenCipher::ephemeral().expect("cipher"); + assert_eq!( + cipher.encrypt("account-a", "access_token", " ").unwrap(), + " " + ); + } +} diff --git a/crates/core/src/storage/tokens.rs b/crates/core/src/storage/tokens.rs index 243690f94..4e139938c 100644 --- a/crates/core/src/storage/tokens.rs +++ b/crates/core/src/storage/tokens.rs @@ -1,4 +1,4 @@ -use rusqlite::{params_from_iter, Result, Row}; +use rusqlite::{params, params_from_iter, Result, Row}; use super::key_id_filters::{normalize_text_ids, text_id_in_clause, SQLITE_IN_CLAUSE_BATCH_SIZE}; use super::{AccountImportTokenSubject, AccountTokenCandidate, AccountTokenPlan, Storage, Token}; @@ -21,6 +21,7 @@ impl Storage { /// # 返回 /// 返回函数执行结果 pub fn insert_token(&self, token: &Token) -> Result<()> { + let encrypted = self.encrypt_account_token_for_storage(token)?; self.conn.execute( "INSERT INTO tokens (account_id, id_token, access_token, refresh_token, api_key_access_token, last_refresh) VALUES (?1, ?2, ?3, ?4, ?5, ?6) @@ -31,17 +32,45 @@ impl Storage { api_key_access_token = excluded.api_key_access_token, last_refresh = excluded.last_refresh", ( - &token.account_id, - &token.id_token, - &token.access_token, - &token.refresh_token, - &token.api_key_access_token, - token.last_refresh, + &encrypted.account_id, + &encrypted.id_token, + &encrypted.access_token, + &encrypted.refresh_token, + &encrypted.api_key_access_token, + encrypted.last_refresh, ), )?; Ok(()) } + pub(super) fn encrypt_account_token_for_storage(&self, token: &Token) -> Result { + Ok(Token { + account_id: token.account_id.clone(), + id_token: self + .token_cipher + .encrypt(&token.account_id, "id_token", &token.id_token) + .map_err(super::token_crypto::to_sql_error)?, + access_token: self + .token_cipher + .encrypt(&token.account_id, "access_token", &token.access_token) + .map_err(super::token_crypto::to_sql_error)?, + refresh_token: self + .token_cipher + .encrypt(&token.account_id, "refresh_token", &token.refresh_token) + .map_err(super::token_crypto::to_sql_error)?, + api_key_access_token: token + .api_key_access_token + .as_deref() + .map(|value| { + self.token_cipher + .encrypt(&token.account_id, "api_key_access_token", value) + }) + .transpose() + .map_err(super::token_crypto::to_sql_error)?, + last_refresh: token.last_refresh, + }) + } + /// 函数 `list_tokens_due_for_refresh` /// /// 作者: gaohongshun @@ -71,7 +100,7 @@ impl Storage { let mut rows = stmt.query((refresh_due_cutoff_ts, access_exp_cutoff_ts, limit as i64))?; let mut out = Vec::new(); while let Some(row) = rows.next()? { - out.push(map_token_row(row)?); + out.push(map_token_row(self, row)?); } Ok(out) } @@ -165,7 +194,7 @@ impl Storage { let mut rows = stmt.query([])?; let mut out = Vec::new(); while let Some(row) = rows.next()? { - out.push(map_token_row(row)?); + out.push(map_token_row(self, row)?); } Ok(out) } @@ -233,11 +262,22 @@ impl Storage { let mut rows = stmt.query([])?; let mut out = Vec::new(); while let Some(row) = rows.next()? { + let account_id: String = row.get(0)?; out.push(AccountImportTokenSubject { - account_id: row.get(0)?, - id_token: row.get(1)?, - access_token: row.get(2)?, - refresh_token: row.get(3)?, + id_token: decrypt_token_field(self, &account_id, "id_token", row.get(1)?)?, + access_token: decrypt_token_field( + self, + &account_id, + "access_token", + row.get(2)?, + )?, + refresh_token: decrypt_token_field( + self, + &account_id, + "refresh_token", + row.get(3)?, + )?, + account_id, }); } Ok(out) @@ -290,7 +330,7 @@ impl Storage { let mut stmt = self.conn.prepare(token_by_account_sql())?; let mut rows = stmt.query([account_id])?; if let Some(row) = rows.next()? { - Ok(Some(map_token_row(row)?)) + Ok(Some(map_token_row(self, row)?)) } else { Ok(None) } @@ -344,6 +384,76 @@ impl Storage { )?; Ok(()) } + + pub(super) fn encrypt_plaintext_account_tokens(&self) -> Result<()> { + let stored_tokens = { + let mut stmt = self.conn.prepare( + "SELECT account_id, id_token, access_token, refresh_token, api_key_access_token + FROM tokens + ORDER BY account_id ASC", + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredTokenFields { + account_id: row.get(0)?, + id_token: row.get(1)?, + access_token: row.get(2)?, + refresh_token: row.get(3)?, + api_key_access_token: row.get(4)?, + }) + })?; + rows.collect::>>()? + }; + + let tx = self.conn.unchecked_transaction()?; + for stored in stored_tokens { + let id_token = self + .token_cipher + .encrypt(&stored.account_id, "id_token", &stored.id_token) + .map_err(super::token_crypto::to_sql_error)?; + let access_token = self + .token_cipher + .encrypt(&stored.account_id, "access_token", &stored.access_token) + .map_err(super::token_crypto::to_sql_error)?; + let refresh_token = self + .token_cipher + .encrypt(&stored.account_id, "refresh_token", &stored.refresh_token) + .map_err(super::token_crypto::to_sql_error)?; + let api_key_access_token = stored + .api_key_access_token + .as_deref() + .map(|value| { + self.token_cipher + .encrypt(&stored.account_id, "api_key_access_token", value) + }) + .transpose() + .map_err(super::token_crypto::to_sql_error)?; + + tx.execute( + "UPDATE tokens + SET id_token = ?1, + access_token = ?2, + refresh_token = ?3, + api_key_access_token = ?4 + WHERE account_id = ?5", + params![ + id_token, + access_token, + refresh_token, + api_key_access_token, + stored.account_id + ], + )?; + } + tx.commit() + } +} + +struct StoredTokenFields { + account_id: String, + id_token: String, + access_token: String, + refresh_token: String, + api_key_access_token: Option, } fn token_count_sql() -> &'static str { @@ -453,25 +563,44 @@ fn tokens_due_for_refresh_sql() -> &'static str { /// /// # 返回 /// 返回函数执行结果 -fn map_token_row(row: &Row<'_>) -> Result { +fn map_token_row(storage: &Storage, row: &Row<'_>) -> Result { + let account_id: String = row.get(0)?; Ok(Token { - account_id: row.get(0)?, - id_token: row.get(1)?, - access_token: row.get(2)?, - refresh_token: row.get(3)?, - api_key_access_token: row.get(4)?, + id_token: decrypt_token_field(storage, &account_id, "id_token", row.get(1)?)?, + access_token: decrypt_token_field(storage, &account_id, "access_token", row.get(2)?)?, + refresh_token: decrypt_token_field(storage, &account_id, "refresh_token", row.get(3)?)?, + api_key_access_token: row + .get::<_, Option>(4)? + .map(|value| { + decrypt_token_field(storage, &account_id, "api_key_access_token", value) + }) + .transpose()?, last_refresh: row.get(5)?, + account_id, }) } -fn map_account_token_plan_row(row: &Row<'_>) -> Result { +fn map_account_token_plan_row(storage: &Storage, row: &Row<'_>) -> Result { + let account_id: String = row.get(0)?; Ok(AccountTokenPlan { - account_id: row.get(0)?, - id_token: row.get(1)?, - access_token: row.get(2)?, + id_token: decrypt_token_field(storage, &account_id, "id_token", row.get(1)?)?, + access_token: decrypt_token_field(storage, &account_id, "access_token", row.get(2)?)?, + account_id, }) } +pub(super) fn decrypt_token_field( + storage: &Storage, + account_id: &str, + field: &str, + value: String, +) -> Result { + storage + .token_cipher + .decrypt(account_id, field, &value) + .map_err(super::token_crypto::to_sql_error) +} + fn map_account_token_candidate_row(row: &Row<'_>) -> Result { Ok(AccountTokenCandidate { account_id: row.get(0)?, @@ -490,7 +619,7 @@ fn list_tokens_for_accounts_chunk(storage: &Storage, account_ids: &[String]) -> let mut rows = stmt.query(params_from_iter(params))?; let mut out = Vec::new(); while let Some(row) = rows.next()? { - out.push(map_token_row(row)?); + out.push(map_token_row(storage, row)?); } Ok(out) } @@ -575,7 +704,7 @@ fn list_account_token_plans_for_accounts_chunk( let mut rows = stmt.query(params_from_iter(params))?; let mut out = Vec::new(); while let Some(row) = rows.next()? { - out.push(map_account_token_plan_row(row)?); + out.push(map_account_token_plan_row(storage, row)?); } Ok(out) } diff --git a/crates/core/src/storage/tokens_tests.rs b/crates/core/src/storage/tokens_tests.rs index 79a4feeed..95a42269d 100644 --- a/crates/core/src/storage/tokens_tests.rs +++ b/crates/core/src/storage/tokens_tests.rs @@ -27,6 +27,107 @@ fn sample_token(account_id: &str, now: i64) -> Token { } } +#[test] +fn insert_token_encrypts_database_fields_and_reads_plaintext() { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let now = now_ts(); + let token = sample_token("acc-encrypted", now); + + storage + .insert_account(&sample_account("acc-encrypted", now)) + .expect("insert account"); + storage.insert_token(&token).expect("insert token"); + + let stored = storage + .conn + .query_row( + "SELECT id_token, access_token, refresh_token, api_key_access_token + FROM tokens WHERE account_id = ?1", + ["acc-encrypted"], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .expect("read raw token row"); + + for value in [&stored.0, &stored.1, &stored.2, &stored.3] { + assert!(value.starts_with("cmenc:v1:")); + assert!(!value.contains("acc-encrypted")); + } + + let loaded = storage + .find_token_by_account_id("acc-encrypted") + .expect("find token") + .expect("token exists"); + assert_eq!(loaded.id_token, token.id_token); + assert_eq!(loaded.access_token, token.access_token); + assert_eq!(loaded.refresh_token, token.refresh_token); + assert_eq!(loaded.api_key_access_token, token.api_key_access_token); +} + +#[test] +fn init_migrates_legacy_plaintext_token_rows() { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let now = now_ts(); + + storage + .insert_account(&sample_account("acc-legacy", now)) + .expect("insert account"); + storage + .conn + .execute( + "INSERT INTO tokens ( + account_id, id_token, access_token, refresh_token, api_key_access_token, last_refresh + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ( + "acc-legacy", + "legacy-id", + "legacy-access", + "legacy-refresh", + "legacy-api", + now, + ), + ) + .expect("insert legacy token"); + storage + .conn + .execute( + "DELETE FROM schema_migrations WHERE version = '131_encrypt_account_tokens_at_rest'", + [], + ) + .expect("reset data migration"); + *storage.applied_migrations.borrow_mut() = None; + + storage.init().expect("migrate tokens"); + + let raw_access: String = storage + .conn + .query_row( + "SELECT access_token FROM tokens WHERE account_id = 'acc-legacy'", + [], + |row| row.get(0), + ) + .expect("read migrated row"); + assert!(raw_access.starts_with("cmenc:v1:")); + assert!(!raw_access.contains("legacy-access")); + + let loaded = storage + .find_token_by_account_id("acc-legacy") + .expect("find token") + .expect("token exists"); + assert_eq!(loaded.id_token, "legacy-id"); + assert_eq!(loaded.access_token, "legacy-access"); + assert_eq!(loaded.refresh_token, "legacy-refresh"); + assert_eq!(loaded.api_key_access_token.as_deref(), Some("legacy-api")); +} + fn collect_query_plan(storage: &Storage, sql: &str) -> String { let mut stmt = storage.conn.prepare(sql).expect("prepare explain"); let mut rows = stmt.query([]).expect("query explain"); diff --git a/crates/service/src/app_settings/env_overrides/catalog/items.rs b/crates/service/src/app_settings/env_overrides/catalog/items.rs index 4d182364f..62f342691 100644 --- a/crates/service/src/app_settings/env_overrides/catalog/items.rs +++ b/crates/service/src/app_settings/env_overrides/catalog/items.rs @@ -10,11 +10,13 @@ const ENV_OVERRIDE_SCOPE_WEB: &str = "web"; const ENV_OVERRIDE_APPLY_MODE_RUNTIME: &str = "runtime"; const ENV_OVERRIDE_APPLY_MODE_RESTART: &str = "restart"; -pub(crate) const APP_SETTINGS_ENV_UNSUPPORTED_KEYS: &[&str] = &[ - "CODEXMANAGER_DB_PATH", - "CODEXMANAGER_RPC_TOKEN", - "CODEXMANAGER_RPC_TOKEN_FILE", -]; +pub(crate) const APP_SETTINGS_ENV_UNSUPPORTED_KEYS: &[&str] = &[ + "CODEXMANAGER_DB_PATH", + "CODEXMANAGER_RPC_TOKEN", + "CODEXMANAGER_RPC_TOKEN_FILE", + "CODEXMANAGER_TOKEN_ENCRYPTION_KEY", + "CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE", +]; pub(crate) const APP_SETTINGS_ENV_RESERVED_KEYS: &[&str] = &[ "CODEXMANAGER_ACCOUNT_MAX_INFLIGHT", diff --git a/docs/en/SECURITY.md b/docs/en/SECURITY.md index da7d974d9..96935da48 100644 --- a/docs/en/SECURITY.md +++ b/docs/en/SECURITY.md @@ -63,6 +63,9 @@ Before submitting logs, screenshots, and configurations, please desensitize the ## Current known boundaries +- Account tokens are encrypted in SQLite with AES-256-GCM. On macOS and Windows the master key prefers the operating-system credential store, and it can also be supplied through `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE` or `CODEXMANAGER_TOKEN_ENCRYPTION_KEY`. +- The `codexmanager.token-key` fallback used when no credential store is available only reduces the risk of the database file being copied by itself. It does not protect against compromise of the whole data directory or logged-in OS account. Production service and Docker deployments should use a separate secret manager. +- Existing tokens cannot be recovered if the master key is lost. Never commit the key or include it in logs or the same unprotected backup as the database. - The project defaults to local deployment and self-hosted usage scenarios, and does not promise fully automatic security hardening when exposed to the public network. - If `0.0.0.0` monitoring is enabled or Web/service is exposed to the LAN or public network, the deployer needs to bear the risk of network exposure at its own risk and cooperate with: - Strong password @@ -73,4 +76,4 @@ Before submitting logs, screenshots, and configurations, please desensitize the - Issues that can be identified will be reproduced as much as possible and their impact levels assessed. - After the repair is completed, it will be released in an appropriate version, and sensitive details will not be exposed in advance through public channels. -- If the problem does not belong to the repository itself, but to improper deployment configuration, a boundary description will also be given. \ No newline at end of file +- If the problem does not belong to the repository itself, but to improper deployment configuration, a boundary description will also be given. diff --git a/docs/en/report/environment-and-runtime-config.md b/docs/en/report/environment-and-runtime-config.md index 05a68bed7..8ac63181d 100644 --- a/docs/en/report/environment-and-runtime-config.md +++ b/docs/en/report/environment-and-runtime-config.md @@ -68,6 +68,8 @@ 以下变量属于 bootstrap 配置,不能依赖启动后再补: - `CODEXMANAGER_DB_PATH` +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY` +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE` - `CODEXMANAGER_RPC_TOKEN` - `CODEXMANAGER_RPC_TOKEN_FILE` @@ -152,10 +154,19 @@ Notes: ### 存储与鉴权 - `CODEXMANAGER_DB_PATH` +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY`: a Base64-encoded 32-byte master key for account tokens. Supply it only through the process environment or a secret manager; never commit it. +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE`: path to a secret file containing either 32 raw key bytes or their Base64 encoding. This is the recommended service and Docker configuration. - `CODEXMANAGER_RPC_TOKEN` - `CODEXMANAGER_RPC_TOKEN_FILE` - `CODEXMANAGER_NO_SERVICE` +Account `id_token`, `access_token`, `refresh_token`, and account API access-token values are stored in SQLite as AES-256-GCM ciphertext. Existing plaintext rows are migrated automatically during startup: + +- macOS and Windows desktop environments prefer the operating-system credential store for the master key. +- On Linux, or if no credential store is available and no key is configured, CodexManager creates a permission-restricted `codexmanager.token-key` fallback file in the database directory. +- Service and Docker deployments should set `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE` explicitly and mount the same read-only secret into every process that accesses the database. +- Losing or replacing the master key makes existing tokens undecryptable. Back up the matching credential or secret, but do not put it in the same unprotected archive as the database. + ### 更新与发布辅助 - `CODEXMANAGER_UPDATE_PRERELEASE` @@ -228,6 +239,7 @@ codexmanager-service-bundle/ CODEXMANAGER_SERVICE_ADDR=0.0.0.0:48760 CODEXMANAGER_WEB_ADDR=0.0.0.0:48761 CODEXMANAGER_DB_PATH=./data/codexmanager.db +CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE=./secrets/account-token.key CODEXMANAGER_RPC_TOKEN_FILE=./data/codexmanager.rpc-token CODEXMANAGER_UPSTREAM_PROXY_URL=http://127.0.0.1:7890 ``` @@ -250,6 +262,7 @@ codexmanager-service-bundle/ 补充说明: - `CODEXMANAGER_DB_PATH=./data/codexmanager.db` 这种相对路径,会按“可执行文件所在目录”解析 +- Prefer an absolute `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE` path. Relative key-file paths are resolved from the process working directory. - `CODEXMANAGER_RPC_TOKEN_FILE` 也是同样规则 - 如果你不写 `CODEXMANAGER_DB_PATH`,Service 版默认会把数据库放到程序目录下的 `codexmanager.db` diff --git a/docs/zh-CN/SECURITY.md b/docs/zh-CN/SECURITY.md index 072654003..c748942ba 100644 --- a/docs/zh-CN/SECURITY.md +++ b/docs/zh-CN/SECURITY.md @@ -63,6 +63,9 @@ CodexManager 当前仍在快速迭代,但会尽量处理合理范围内的安 ## 当前已知边界 +- 账号令牌在 SQLite 中使用 AES-256-GCM 加密;macOS / Windows 的主密钥优先存放在操作系统凭据库,也可通过 `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE` 或 `CODEXMANAGER_TOKEN_ENCRYPTION_KEY` 注入。 +- 凭据库不可用时创建的 `codexmanager.token-key` 文件只能降低“数据库文件单独泄露”的风险,不能抵御整个数据目录或已登录操作系统账户被攻破。生产 service / Docker 应使用独立 secret 管理。 +- 主密钥丢失后无法恢复已有令牌。不要把主密钥提交到仓库、日志或与数据库相同的不受保护备份中。 - 项目默认面向本地部署与自托管使用场景,不承诺公网暴露下的全自动安全加固。 - 如果启用 `0.0.0.0` 监听或将 Web / service 暴露到局域网或公网,部署者需要自行承担网络暴露风险,并配合: - 强密码 @@ -73,4 +76,4 @@ CodexManager 当前仍在快速迭代,但会尽量处理合理范围内的安 - 能确认的问题会尽量复现并评估影响等级。 - 修复完成后,会在合适版本中发布,不会在公开渠道提前暴露敏感细节。 -- 如问题不属于仓库本身,而是部署配置不当,也会给出边界说明。 \ No newline at end of file +- 如问题不属于仓库本身,而是部署配置不当,也会给出边界说明。 diff --git "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" index 0fcc4952d..c89b0d810 100644 --- "a/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/report/\347\216\257\345\242\203\345\217\230\351\207\217\344\270\216\350\277\220\350\241\214\351\205\215\347\275\256\350\257\264\346\230\216.md" @@ -68,6 +68,8 @@ 以下变量属于 bootstrap 配置,不能依赖启动后再补: - `CODEXMANAGER_DB_PATH` +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY` +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE` - `CODEXMANAGER_RPC_TOKEN` - `CODEXMANAGER_RPC_TOKEN_FILE` @@ -153,6 +155,8 @@ ### 存储与鉴权 - `CODEXMANAGER_DB_PATH` +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY`:Base64 编码的 32 字节账号令牌主密钥。只建议通过进程环境或 secret 管理器提供,不要写入仓库。 +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE`:包含 32 字节原始密钥或其 Base64 文本的 secret 文件路径;service / Docker 推荐使用该方式。 - `CODEXMANAGER_RPC_TOKEN` - `CODEXMANAGER_RPC_TOKEN_FILE` - `CODEXMANAGER_NO_SERVICE` @@ -160,6 +164,13 @@ - `CODEXMANAGER_STORAGE_MAX_IDLE_CONNECTIONS`:SQLite 存储连接池每个数据库路径保留的空闲连接上限,默认 `16`,不会超过总连接上限。 - `CODEXMANAGER_STORAGE_ACQUIRE_TIMEOUT_MS`:连接池耗尽时等待可用连接的最长时间,默认 `30000` 毫秒。 +账号的 `id_token`、`access_token`、`refresh_token` 和账号 API access token 会以 AES-256-GCM 密文写入 SQLite。启动时会自动迁移历史明文记录: + +- macOS / Windows 桌面环境优先把主密钥保存在操作系统凭据库中。 +- Linux 或无可用凭据库且未显式配置密钥时,会在数据库目录内创建权限受限的 `codexmanager.token-key` 回退文件。 +- Docker / service 部署建议显式设置 `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE`,并让所有访问同一数据库的进程挂载同一个只读 secret 文件。 +- 丢失或替换主密钥后,已有令牌无法解密;备份数据库时必须同时备份对应凭据或 secret,但不要把两者放入同一不受保护的归档。 + ### 更新与发布辅助 - `CODEXMANAGER_UPDATE_PRERELEASE` @@ -235,6 +246,7 @@ codexmanager-service-bundle/ CODEXMANAGER_SERVICE_ADDR=0.0.0.0:48760 CODEXMANAGER_WEB_ADDR=0.0.0.0:48761 CODEXMANAGER_DB_PATH=./data/codexmanager.db +CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE=./secrets/account-token.key CODEXMANAGER_RPC_TOKEN_FILE=./data/codexmanager.rpc-token CODEXMANAGER_UPSTREAM_PROXY_URL=http://127.0.0.1:7890 ``` @@ -257,6 +269,7 @@ codexmanager-service-bundle/ 补充说明: - `CODEXMANAGER_DB_PATH=./data/codexmanager.db` 这种相对路径,会按“可执行文件所在目录”解析 +- `CODEXMANAGER_TOKEN_ENCRYPTION_KEY_FILE` 建议使用绝对路径;若使用相对路径,则按进程当前工作目录解析 - `CODEXMANAGER_RPC_TOKEN_FILE` 也是同样规则 - 如果你不写 `CODEXMANAGER_DB_PATH`,Service 版默认会把数据库放到程序目录下的 `codexmanager.db`