diff --git a/docs/encrypted-config-plan.md b/docs/encrypted-config-plan.md new file mode 100644 index 0000000..84b255f --- /dev/null +++ b/docs/encrypted-config-plan.md @@ -0,0 +1,193 @@ +# CC Switch 加密配置与切换重构方案(V1) + +## 1. 目标与范围 + +- 目标:将 `~/.cc-switch/config.json` 作为单一真实来源(SSOT),改为“加密落盘”;切换时从解密后的内存配置写入目标应用主配置(Claude/Codex)。 +- 范围: + - 后端(Rust/Tauri)新增加密模块与读写改造。 + - 调整切换逻辑为“内存 → 主配置”,切换前回填 live 配置到当前供应商,避免用户外部手改丢失。 + - 新增“旧文件清理与归档”能力:默认仅归档不删除,并在迁移成功后提醒用户执行;可在设置页手动触发。 + - 兼容旧明文配置(v1/v2),首次保存迁移为加密文件。 + +## 2. 背景现状(简述) + +- 当前: + - 全局配置:`~/.cc-switch/config.json`(v2:`MultiAppConfig`,含多个 `ProviderManager`)。 + - 切换:依赖“供应商副本文件”(Claude:`~/.claude/settings-.json`;Codex:`~/.codex/auth-.json`、`config-.toml`)→ 恢复到主配置。 + - 启动:若检测到现有主配置,自动导入为 `default` 供应商。 +- 问题:存在“副本 ↔ 总配置”双来源,可能不一致;明文落盘有泄露风险。 + +## 3. 总体方案 + +- 以加密文件 `~/.cc-switch/config.enc.json` 替代明文存储;进程启动时解密一次加载到内存,后续以内存为准;保存时加密写盘。 +- 切换时:直接从内存 `Provider.settings_config` 写入目标应用主配置;切换前回填当前 live 配置到 `current` 供应商,保留外部修改。 +- 明文兼容:若无加密文件,读取旧 `config.json`(含 v1→v2 迁移),首次保存写加密文件,并备份旧明文。 +- 旧文件清理:提供“可回滚归档”而非删除。扫描 `~/.cc-switch/config.json`(v1/v2)与 Claude/Codex 的历史副本文件,用户确认后移动到 `~/.cc-switch/archive//`,生成 `manifest.json` 以便恢复;默认不做静默清理。 + +## 4. 密钥管理 + +- 存储:系统级凭据管家(keyring crate)。 + - Service:`cc-switch`;Account:`config-key-v1`;内容:Base64 编码的 32 字节随机密钥(AES-256)。 +- 首次运行:生成随机密钥,写入 Keychain。 +- 进程内缓存:启动加载后缓存密钥,避免重复 IO。 +- 轮换(后续):支持命令触发“旧密钥解密 → 新密钥加密”的原子迁移。 +- 回退策略:Keychain 不可用时进入“只读模式”并提示用户(不建议将密钥落盘)。 + +## 5. 加密封装格式 + +- 文件:`~/.cc-switch/config.enc.json` +- 结构(JSON 封装,便于演进): + ```json + { + "v": 1, + "alg": "AES-256-GCM", + "nonce": "", + "ct": "" + } + ``` +- 明文:`serde_json::to_vec(MultiAppConfig)`;加密:AES-GCM(12 字节随机 nonce);每次保存生成新 nonce。 + +## 6. 模块与改造点 + +- 新增 `src-tauri/src/secure_store.rs`: + - `get_or_create_key() -> Result<[u8;32], String>`:从 Keychain 获取/生成密钥。 + - `encrypt_bytes(key, plaintext) -> (nonce, ciphertext)`;`decrypt_bytes(key, nonce, ciphertext)`。 + - `read_encrypted_config() -> Result`:读取 `config.enc.json`、解析封装、解密、反序列化。 + - `write_encrypted_config(cfg: &MultiAppConfig) -> Result<(), String>`:序列化→加密→原子写入。 +- 新增 `src-tauri/src/legacy_cleanup.rs`(旧文件清理/归档): + - `scan_legacy_files() -> LegacyScanReport`:扫描旧 `config.json`(v1/v2)与 Claude/Codex 副本文件(`settings-*.json`、`auth-*.json`、`config-*.toml`),返回分组清单、大小、mtime;永不将 live 文件(`settings.json`、`auth.json`、`config.toml`、`config.enc.json`)列为可归档。 + - `archive_legacy_files(selection) -> ArchiveResult`:将选中文件移动到 `~/.cc-switch/archive//` 下对应子目录(`cc-switch/`、`claude/`、`codex/`),生成 `manifest.json`(记录原路径、归档路径、大小、mtime、sha256、类别);同分区 `rename`,跨分区“copy + fsync + remove”。 + - `restore_from_archive(manifest_path, items?) -> RestoreResult`:从归档恢复选中文件;若原路径已有同名文件则中止并提示冲突。 + - 可选:`purge_archived(before_days)` 仅删除 `archive/` 内的过期归档;默认关闭。 + - 安全护栏:操作前后做 mtime/hash 复核(CAS);发生变化中止并提示“外部已修改”。 +- 调整 `src-tauri/src/app_config.rs`: + - `MultiAppConfig::load()`:优先 `read_encrypted_config()`;若无则读旧明文: + - 若检测到 v1(`ProviderManager`)→ 迁移到 v2(原有逻辑保留)。 + - `MultiAppConfig::save()`:统一调用 `write_encrypted_config()`;若检测到旧 `config.json`,首次保存时备份为 `config.v1.backup..json`(或保留为只读,视实现选择)。 +- 调整 `src-tauri/src/commands.rs::switch_provider`: + - Claude: + 1. 回填:若 `~/.claude/settings.json` 存在且 `current` 非空 → 读取 JSON,写回 `manager.providers[current].settings_config`。 + 2. 切换:从目标 `provider.settings_config` 直接写 `~/.claude/settings.json`(确保父目录存在)。 + - Codex: + 1. 回填:读取 `~/.codex/auth.json`(JSON)与 `~/.codex/config.toml`(字符串;非空做 TOML 校验)→ 合成为 `{auth, config}` → 写回 `manager.providers[current].settings_config`。 + 2. 切换:从目标 `provider.settings_config` 中取 `auth`(必需)与 `config`(可空)写入对应主配置(非空 `config` 校验 TOML)。 + - 更新 `manager.current = id`,`state.save()` → 触发加密保存。 +- 保留/清理: + - 阶段一保留 `codex_config.rs` 与 `config.rs` 的副本读写函数(减少改动面),但切换不再依赖“副本恢复”。 + - 阶段二可移除 add/update 时的“副本写入”,转为仅更新内存并保存加密配置。 + +## 7. 数据流与时序 + +- 启动:`AppState::new()` → `MultiAppConfig::load()`(优先加密)→ 进程内持有解密后的配置。 +- 添加/编辑/删除:更新内存中的 `ProviderManager` → `state.save()`(加密写盘)。 +- 切换:回填 live → 以目标供应商内存配置写入主配置 → 更新 `current` → `state.save()`。 +- 迁移后提醒:若首次从旧明文迁移成功,弹出“发现旧配置,可归档”提示;用户可进入“存储与清理”页面查看并执行归档。 + +## 8. 迁移策略 + +- 读取顺序:`config.enc.json`(新)→ `config.json`(旧)。 +- 旧版支持: + - v1 明文(单 `ProviderManager`)→ 自动迁移为 v2(已有逻辑)。 + - v2 明文 → 直接加载。 +- 首次保存:写 `config.enc.json`;若存在旧 `config.json`,备份为 `config.v1.backup..json`(或保留为只读)。 +- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。 +- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive//`,保留 `manifest.json` 以支持恢复。 + +## 9. 回滚策略 + +- 加密回滚:保留 `config.v1.backup..json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。 +- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。 + +## 10. 安全与性能 + +- 算法:AES-256-GCM(AEAD);随机 12 字节 nonce;每次保存新 nonce。 +- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 1–20ms,可缓存。 +- 可靠性:原子写入(临时文件 + rename);写入失败不破坏现有文件。 +- 可选增强:`zeroize` 清理密钥与明文;Claude 配置 JSON Schema 校验。 +- 清理安全:归档而非删除;不触及 live 文件;归档/恢复采用 CAS 校验与错误回滚;归档路径冲突加后缀去重(如 `-2`、`-3`)。 + +## 11. API 与 UX 影响 + +- 前端 API:现有行为不变;新增清理相关命令(Tauri)供 UI 调用:`scan_legacy_files`、`archive_legacy_files`、`restore_from_archive`(`purge_archived` 可选)。 +- UI 提示:在“配置文件位置”旁提示“已加密存储”。 +- 清理入口:设置页新增“存储与清理”面板,展示扫描结果、支持归档与从归档恢复;首次迁移成功后弹出提醒(可稍后再说)。 +- 文案约定:明确“仅归档、不删除;删除需二次确认且默认关闭自动删除”。 + +## 12. 开发任务拆解(阶段一为本次交付) + +- 阶段一(核心改造 + 清理能力最小闭环) + - 新增模块 `secure_store.rs`:Keychain 与加解密工具函数。 + - 改造 `app_config.rs`:`load()/save()` 支持加密文件与旧明文迁移、原子写入、备份。 + - 改造 `commands.rs::switch_provider`: + - 回填 live 配置 → 写入目标主配置(Claude/Codex)。 + - 去除对“副本恢复”的依赖(保留函数以便回退)。 + - 旧文件清理:新增 `legacy_cleanup.rs` 与对应 Tauri 命令,完成“扫描→归档→恢复”;首次迁移成功后在 UI 弹提醒,指向“设置 > 存储与清理”。 + - 保持 `import_default_config`、`get_config_status` 行为不变。 +- 阶段二(清理与增强) + - 移除 add/update 对“副本文件”的写入,完全以内存+加密文件为中心。 + - Claude settings 的 JSON Schema 校验;导出明文快照;只读模式显式开关。 +- 阶段三(安全升级) + - 密钥轮换;可选 passphrase(KDF: Argon2id + salt)。 + +## 14. 验收标准 + +- 功能: + - 无加密明文文件也能启动并正确读写; + - 切换成功将内存配置写入主配置; + - 外部手改在下一次切换前被回填保存; + - 旧配置自动迁移并生成加密文件; + - Keychain/解密异常时不损坏已有文件,给出可理解错误。 + - 清理:扫描能准确识别旧明文与副本文件;执行归档后原路径不再存在文件、归档目录生成 `manifest.json`;从归档恢复可还原到原路径(不覆盖已存在文件)。 +- 质量: + - 关键路径加错误处理与日志; + - 写入采用原子替换; + - 代码变更集中、最小侵入,与现有风格一致。 + - 清理操作具备 CAS 校验、错误回滚、绝不触及 live 文件与 `config.enc.json`。 + +## 15. 风险与对策 + +- Keychain 不可用或权限受限: + - 对策:只读模式 + 明确提示;不覆盖落盘;允许手动恢复明文备份。 +- 加密文件损坏: + - 对策:严格校验与错误分支;保留旧文件;不做“盲目重置”。 +- 与“副本文件”并存导致混淆: + - 对策:阶段一保留但不依赖;阶段二移除写入,文档化行为变更。 +- 清理误删或不可逆: + - 对策:默认仅归档不删除;删除需二次确认且仅作用于 `archive/`;提供 `manifest.json` 恢复;归档/恢复全程 CAS 校验与回滚。 + +## 16. 发布与回退 + +- 发布:随 Tauri 应用正常发布,无需前端变更。 +- 回退:保留旧明文备份;将切换逻辑临时改回“副本恢复”路径可快速回退。 + +## 17. 旧文件清理与归档(新增) + +- 归档对象: + - `~/.cc-switch/config.json`(v1/v2,迁移成功后) + - `~/.claude/settings-*.json`(保留 `settings.json`) + - `~/.codex/auth-*.json`、`~/.codex/config-*.toml`(保留 `auth.json`、`config.toml`) +- 归档位置与结构:`~/.cc-switch/archive//{cc-switch,claude,codex}/...` +- `manifest.json`:记录原路径、归档路径、大小、mtime、sha256、类别(v1/v2/claude/codex);用于恢复与可视化。 +- 提醒策略:首次迁移成功后弹窗提醒;设置页“存储与清理”提供扫描、归档、恢复操作;默认不自动删除,可选“删除归档 >N 天”开关(默认关闭)。 +- 护栏:永不移动/删除 live 文件与 `config.enc.json`;执行前后 CAS 校验;跨分区采用“copy+fsync+remove”;失败即时回滚并提示。 + +## 18. 变更点清单(代码) + +- 新增:`src-tauri/src/secure_store.rs` +- 修改: + - `src-tauri/src/app_config.rs`(load/save 加密化、迁移与原子写入) + - `src-tauri/src/commands.rs`(switch_provider 改为内存 → 主配置,并回填 live) + - `src-tauri/src/legacy_cleanup.rs`(扫描/归档/恢复旧文件) +- 保持: + - `src-tauri/src/config.rs`、`src-tauri/src/codex_config.rs`(读写工具与校验,阶段一不大动) + - 前端 `src/lib/tauri-api.ts` 与 UI 逻辑 + +## 19. 开放问题(待确认) + +- Keychain 失败时是否提供“本地明文密钥文件(600 权限)”的应急模式(当前建议:不提供,保持只读)。 +- 加密文件名固定为 `config.enc.json` 是否满足预期,或需隐藏(如 `.config.enc`)。 +- 是否需要提供“自动删除归档 >N 天”的开关(默认关闭,建议 N=30)。 + +--- + +以上方案为“阶段一”可落地版本,能在保持前端无感的前提下完成“加密存储 + 内存驱动切换”的核心目标。如需,我可以继续补充任务看板(Issue 列表)与实施顺序的 PR 规划。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 68bb71d..3073a89 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -550,7 +550,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.0.0" +version = "3.1.1" dependencies = [ "dirs 5.0.1", "log", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 67c588b..fd3f300 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,7 +6,7 @@ use tauri_plugin_opener::OpenerExt; use crate::app_config::AppType; use crate::codex_config; -use crate::config::{ConfigStatus, get_claude_settings_path, import_current_config_as_default}; +use crate::config::{ConfigStatus, get_claude_settings_path}; use crate::provider::Provider; use crate::store::AppState; @@ -269,54 +269,89 @@ pub async fn switch_provider( .ok_or_else(|| format!("供应商不存在: {}", id))? .clone(); - // 根据应用类型执行切换 + // SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置 match app_type { AppType::Codex => { - // 备份当前配置(如果存在) + use serde_json::Value; + + // 回填:读取 live(auth.json + config.toml)写回当前供应商 settings_config if !manager.current.is_empty() { - if let Some(current_provider) = manager.providers.get(&manager.current) { - codex_config::backup_codex_config(&manager.current, ¤t_provider.name)?; - log::info!("已备份当前 Codex 供应商配置: {}", current_provider.name); + let auth_path = codex_config::get_codex_auth_path(); + let config_path = codex_config::get_codex_config_path(); + if auth_path.exists() { + let auth: Value = crate::config::read_json_file(&auth_path)?; + let config_str = if config_path.exists() { + std::fs::read_to_string(&config_path) + .map_err(|e| format!("读取 config.toml 失败: {}", e))? + } else { + String::new() + }; + + let live = serde_json::json!({ + "auth": auth, + "config": config_str, + }); + + if let Some(cur) = manager.providers.get_mut(&manager.current) { + cur.settings_config = live; + } } } - // 恢复目标供应商配置 - codex_config::restore_codex_provider_config(&id, &provider.name)?; + // 切换:从目标供应商 settings_config 写入主配置 + let auth_path = codex_config::get_codex_auth_path(); + let config_path = codex_config::get_codex_config_path(); + if let Some(parent) = auth_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建 Codex 目录失败: {}", e))?; + } + + // 写 auth.json(必需) + let auth = provider + .settings_config + .get("auth") + .ok_or_else(|| "目标供应商缺少 auth 配置".to_string())?; + crate::config::write_json_file(&auth_path, auth)?; + + // 写 config.toml(可选) + if let Some(cfg) = provider.settings_config.get("config") { + if let Some(cfg_str) = cfg.as_str() { + if !cfg_str.trim().is_empty() { + toml::from_str::(cfg_str) + .map_err(|e| format!("config.toml 格式错误: {}", e))?; + } + std::fs::write(&config_path, cfg_str) + .map_err(|e| format!("写入 config.toml 失败: {}", e))?; + } else { + // 非字符串时,写空 + std::fs::write(&config_path, "") + .map_err(|e| format!("写入空的 config.toml 失败: {}", e))?; + } + } else { + // 缺失则写空 + std::fs::write(&config_path, "") + .map_err(|e| format!("写入空的 config.toml 失败: {}", e))?; + } } AppType::Claude => { - // 使用原有的 Claude 切换逻辑 - use crate::config::{ - backup_config, copy_file, get_claude_settings_path, get_provider_config_path, - }; + use crate::config::{read_json_file, write_json_file}; let settings_path = get_claude_settings_path(); - let provider_config_path = get_provider_config_path(&id, Some(&provider.name)); - // 检查供应商配置文件是否存在 - if !provider_config_path.exists() { - return Err(format!( - "供应商配置文件不存在: {}", - provider_config_path.display() - )); - } - - // 如果当前有配置,先备份到当前供应商 + // 回填:读取 live settings.json 写回当前供应商 settings_config if settings_path.exists() && !manager.current.is_empty() { - if let Some(current_provider) = manager.providers.get(&manager.current) { - let current_provider_path = - get_provider_config_path(&manager.current, Some(¤t_provider.name)); - backup_config(&settings_path, ¤t_provider_path)?; - log::info!("已备份当前供应商配置: {}", current_provider.name); + if let Ok(live) = read_json_file::(&settings_path) { + if let Some(cur) = manager.providers.get_mut(&manager.current) { + cur.settings_config = live; + } } } - // 确保主配置父目录存在 + // 切换:从目标供应商 settings_config 写入主配置 if let Some(parent) = settings_path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; } - - // 复制新供应商配置到主配置 - copy_file(&provider_config_path, &settings_path)?; + write_json_file(&settings_path, &provider.settings_config)?; } } @@ -360,9 +395,35 @@ pub async fn import_default_config( } // 根据应用类型导入配置 + // 读取当前主配置为默认供应商(不再写入副本文件) let settings_config = match app_type { - AppType::Codex => codex_config::import_current_codex_config()?, - AppType::Claude => import_current_config_as_default()?, + AppType::Codex => { + let auth_path = codex_config::get_codex_auth_path(); + let config_path = codex_config::get_codex_config_path(); + if !auth_path.exists() { + return Err("Codex 配置文件不存在".to_string()); + } + let auth: serde_json::Value = crate::config::read_json_file::(&auth_path)?; + let config_str = if config_path.exists() { + let s = std::fs::read_to_string(&config_path) + .map_err(|e| format!("读取 config.toml 失败: {}", e))?; + if !s.trim().is_empty() { + toml::from_str::(&s) + .map_err(|e| format!("config.toml 语法错误: {}", e))?; + } + s + } else { + String::new() + }; + serde_json::json!({ "auth": auth, "config": config_str }) + } + AppType::Claude => { + let settings_path = get_claude_settings_path(); + if !settings_path.exists() { + return Err("Claude Code 配置文件不存在".to_string()); + } + crate::config::read_json_file::(&settings_path)? + } }; // 创建默认供应商 @@ -383,21 +444,7 @@ pub async fn import_default_config( .get_manager_mut(&app_type) .ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?; - // 根据应用类型保存配置文件 - match app_type { - AppType::Codex => { - codex_config::save_codex_provider_config( - &provider.id, - &provider.name, - &provider.settings_config, - )?; - } - AppType::Claude => { - use crate::config::{get_provider_config_path, write_json_file}; - let config_path = get_provider_config_path(&provider.id, Some(&provider.name)); - write_json_file(&config_path, &provider.settings_config)?; - } - } + // 不再写入副本文件,仅更新内存配置 manager.providers.insert(provider.id.clone(), provider);