From 13ccf85a48b73b489f4e3f204929414344ca5213 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 08:42:18 +0800 Subject: [PATCH 01/13] Store credentials in the system vault Provider credentials should follow the platform credential-store threat model instead of continuing normal plaintext JSON writes. The vault now stores the existing multi-provider credentials payload in the OS credential store, migrates the legacy JSON file only when the vault has no entry, removes the legacy file after a successful write, and keeps raw credential IPC restricted to the main settings window. Constraint: User explicitly chose Keychain/Credential Manager/Linux keyring for issue #230. Constraint: Preserve the existing provider schema and active-provider routing with minimal frontend churn. Rejected: Keep plaintext JSON as the documented product choice | user rejected this direction. Rejected: Split every credential field into separate vault entries | larger migration surface and risks losing active provider state. Confidence: medium Scope-risk: moderate Directive: Security-model and storage-strategy choices must be confirmed with the user before implementation. Tested: cargo check --manifest-path src-tauri/Cargo.toml; npm run -s build; git diff --check; grep for stale plaintext/Keychain fallback wording. Not-tested: Manual migration on macOS Keychain, Windows Credential Manager, or Linux desktop keyring. Related: https://github.com/appergb/openless/issues/230 --- AGENTS.md | 6 +- CLAUDE.md | 6 +- README.md | 11 +- README.zh.md | 9 +- .../windows-real-asr-insertion-smoke.ps1 | 2 +- openless-all/app/src-tauri/Cargo.lock | 45 +++++- openless-all/app/src-tauri/Cargo.toml | 15 ++ openless-all/app/src-tauri/src/commands.rs | 18 ++- openless-all/app/src-tauri/src/persistence.rs | 137 +++++++++++------- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/Settings.tsx | 3 + 13 files changed, 179 insertions(+), 76 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aebd3f39..415d0cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.) insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback -persistence.rs History/preferences/vocab JSON + Keychain credentials +persistence.rs History/preferences/vocab JSON + platform credential vault coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing permissions.rs TCC checks (Accessibility / Microphone) @@ -91,9 +91,9 @@ Invariants: ### Permissions, credentials, on-disk state -- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant. +- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant. - **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs. -- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys. +- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports. - **Per-user data**: - macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data). - Windows: `%APPDATA%\OpenLess\` diff --git a/CLAUDE.md b/CLAUDE.md index 2a1400fa..d8a70984 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.) insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback -persistence.rs History/preferences/vocab JSON + Keychain credentials +persistence.rs History/preferences/vocab JSON + platform credential vault coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing permissions.rs TCC checks (Accessibility / Microphone) @@ -91,9 +91,9 @@ Invariants: ### Permissions, credentials, on-disk state -- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant. +- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant. - **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs. -- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys. +- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports. - **Per-user data**: - macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data). - Windows: `%APPDATA%\OpenLess\` diff --git a/README.md b/README.md index 182a1838..de83b297 100644 --- a/README.md +++ b/README.md @@ -195,9 +195,14 @@ Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\ ## Credentials -Credentials live in the local Keychain (service = `com.openless.app`). A plaintext JSON file at `~/.openless/credentials.json` (mode 0600, dir 0700) is kept as a dev-mode fallback when Keychain is unavailable. +Credentials live in the OS credential vault (service = `com.openless.app`): macOS Keychain, Windows Credential Manager, or Linux keyring. A legacy plaintext JSON file is read only as a migration source and removed after a successful vault write: -The repository contains no API keys, tokens, or private endpoints. +```text +macOS / Linux: ~/.openless/credentials.json +Windows: %APPDATA%\OpenLess\credentials.json +``` + +New credential writes do not persist plaintext secrets. The repository contains no API keys, tokens, or private endpoints. You'll need: @@ -247,7 +252,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback asr/ Volcengine streaming ASR (WebSocket) + Whisper HTTP polish.rs OpenAI-compatible chat-completions (Ark / DeepSeek / etc.) insertion.rs AX focused-element → clipboard + Cmd+V → copy-only fallback -persistence.rs History / preferences / vocab JSON + Keychain credentials +persistence.rs History / preferences / vocab JSON + platform credential vault permissions.rs TCC checks (Accessibility / Microphone) coordinator.rs State machine: Idle → Starting → Listening → Processing commands.rs Tauri IPC surface diff --git a/README.zh.md b/README.zh.md index d3cb93de..eea10e60 100644 --- a/README.zh.md +++ b/README.zh.md @@ -198,13 +198,14 @@ npm run build ## 凭据 -凭据保存在本机 Keychain(service = `com.openless.app`)。开发期同时维护一份明文 JSON 兜底,用于在 Keychain 不可用时回退: +凭据保存在系统凭据库(service = `com.openless.app`):macOS Keychain、Windows Credential Manager 或 Linux keyring。旧版明文 JSON 只作为迁移来源读取,成功写入系统凭据库后会被删除: ```text -~/.openless/credentials.json # 0600,目录 0700 +macOS / Linux: ~/.openless/credentials.json +Windows: %APPDATA%\OpenLess\credentials.json ``` -仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。 +新的凭据写入不会继续保存明文 secrets。仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。 需要配置的字段: @@ -254,7 +255,7 @@ recorder.rs 麦克风 → 16 kHz 单声道 Int16 PCM,RMS 回调 asr/ 火山引擎流式 ASR(WebSocket)+ Whisper HTTP polish.rs OpenAI 兼容 chat-completions(Ark / DeepSeek 等) insertion.rs AX focused-element → 剪贴板 + Cmd+V → 仅复制兜底 -persistence.rs 历史记录 / 偏好设置 / 词典 JSON + Keychain 凭据 +persistence.rs 历史记录 / 偏好设置 / 词典 JSON + 系统凭据库 permissions.rs TCC 权限检查(辅助功能 / 麦克风) coordinator.rs 状态机:Idle → Starting → Listening → Processing commands.rs Tauri IPC 接口 diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index 1c11ca18..4c92d1e4 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -597,7 +597,7 @@ if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -o throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials." } if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) { - Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault." + Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault." } $logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log" diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index c4cfc496..da167533 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -2600,6 +2600,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2683,6 +2698,16 @@ dependencies = [ "redox_syscall 0.7.5", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2881,7 +2906,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -3388,6 +3413,7 @@ dependencies = [ "ferrous-opencc", "futures-util", "global-hotkey", + "keyring", "libc", "log", "objc2 0.5.2", @@ -4369,7 +4395,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -4397,7 +4423,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.52.0", @@ -4507,6 +4533,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 1ee8ed13..53134d05 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -51,6 +51,21 @@ enigo = "0.2" arboard = "3" rdev = "0.5" +[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"] + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring] +version = "3.6.3" +default-features = false +features = ["linux-native"] + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.5" core-foundation = "0.10" diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 93cc6cdd..c9babd4c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use std::time::Duration; use serde::Serialize; use serde_json::Value; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, State, Window}; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; @@ -143,7 +143,8 @@ fn configured(field: &Option) -> bool { } #[tauri::command] -pub fn set_credential(account: String, value: String) -> Result<(), String> { +pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> { + ensure_main_window(&window)?; let acc = parse_account(&account)?; if value.is_empty() { CredentialsVault::remove(acc).map_err(|e| e.to_string()) @@ -173,13 +174,22 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> { } /// 读出某个账号的实际值(用于设置页预填表单)。 -/// 与 Swift `CredentialsVault.get` 同语义,先 Keychain,缺则回落 ~/.openless/credentials.json。 +/// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。 #[tauri::command] -pub fn read_credential(account: String) -> Result, String> { +pub fn read_credential(window: Window, account: String) -> Result, String> { + ensure_main_window(&window)?; let acc = parse_account(&account)?; CredentialsVault::get(acc).map_err(|e| e.to_string()) } +fn ensure_main_window(window: &Window) -> Result<(), String> { + if window.label() == "main" { + Ok(()) + } else { + Err("credential access is only allowed from the main window".to_string()) + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ProviderCheckResult { diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f5951e97..503621a4 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -1,16 +1,15 @@ //! Local persistence: history JSON, user preferences JSON, vocab JSON, and -//! Keychain-backed credentials vault. +//! platform-backed credentials vault. //! //! Storage roots: //! - macOS: `~/Library/Application Support/OpenLess` //! - Windows: `%APPDATA%\OpenLess` //! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess` //! -//! Divergence from Swift: the Swift `CredentialsVault` falls back to a JSON -//! file (`~/.openless/credentials.json`) when Keychain is unavailable. The -//! Rust port intentionally does NOT replicate that fallback — we rely solely -//! on the platform keyring. The macOS service name (`com.openless.app`) is -//! preserved so existing Keychain entries from the Swift app remain readable. +//! Credential storage policy: provider credentials are stored in the OS +//! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring). +//! A legacy plaintext JSON file is read once as a migration source and removed +//! after a successful vault write; new writes never persist plaintext secrets. use std::fs; use std::path::{Path, PathBuf}; @@ -32,12 +31,12 @@ const PREFERENCES_FILE: &str = "preferences.json"; const VOCAB_FILE: &str = "dictionary.json"; const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; -/// Swift 老 `CredentialsVault` 的 JSON 备用路径。 -/// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件, -/// 让用户在 Swift 版填过的凭据无需重输。 +/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。 const LEGACY_CREDS_DIR: &str = ".openless"; const LEGACY_CREDS_FILE: &str = "credentials.json"; +const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1"; + static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); fn credentials_lock() -> &'static Mutex<()> { @@ -115,12 +114,10 @@ fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Resul .with_context(|| format!("decode failed: {}", path.display())) } -// ───────────────────────── credentials JSON store ───────────────────────── +// ───────────────────────── credentials vault ───────────────────────── // -// 与 Swift `Sources/OpenLessPersistence/CredentialsVault.swift` 同源——纯 JSON 文件, -// 路径 `~/.openless/credentials.json`,权限 0600。**故意不用 Keychain**: -// ad-hoc 签名每次构建 hash 都变,Keychain ACL 失效后会触发逐账号弹框;用户已明确 -// 选择"直接写本地文件"。 +// 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider +// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload,而不是逐字段散落。 // // v1 schema: // { @@ -262,56 +259,87 @@ fn credentials_path() -> Result { } } -fn ensure_credentials_dir(path: &Path) -> Result<()> { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir).with_context(|| format!("create dir {} failed", dir.display()))?; - // 0700 on parent so other users can't peek - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); - } - } - Ok(()) +fn keyring_entry() -> Result { + keyring::Entry::new(CredentialsVault::SERVICE_NAME, KEYRING_CREDENTIALS_ACCOUNT) + .context("open system credential vault") } -fn load_credentials() -> CredsRoot { - let path = match credentials_path() { - Ok(p) => p, - Err(_) => return CredsRoot::default(), - }; +fn clean_credentials(root: &CredsRoot) -> CredsRoot { + let mut cleaned = root.clone(); + cleaned.providers.asr.retain(|_, v| !v.is_empty()); + cleaned.providers.llm.retain(|_, v| !v.is_empty()); + cleaned +} + +fn read_legacy_credentials_file(path: &Path) -> Option { if !path.exists() { - return CredsRoot::default(); + return None; } - let bytes = match fs::read(&path) { + let bytes = match fs::read(path) { Ok(b) => b, Err(e) => { - log::warn!("[vault] read {} failed: {}", path.display(), e); + log::warn!("[vault] read legacy {} failed: {}", path.display(), e); + return None; + } + }; + match serde_json::from_slice::(&bytes) { + Ok(root) => Some(root), + Err(e) => { + log::warn!("[vault] parse legacy {} failed: {}", path.display(), e); + None + } + } +} + +fn remove_legacy_credentials_file() { + let Ok(path) = credentials_path() else { return }; + if path.exists() { + if let Err(e) = fs::remove_file(&path) { + log::warn!("[vault] remove legacy {} failed: {}", path.display(), e); + } + } +} + +fn load_keyring_credentials() -> Result> { + let entry = keyring_entry()?; + let json = match entry.get_password() { + Ok(json) => json, + Err(keyring::Error::NoEntry) => return Ok(None), + Err(e) => return Err(anyhow!(e)).context("read system credential vault"), + }; + serde_json::from_str::(&json) + .map(Some) + .context("decode system credential vault payload") +} + +fn load_credentials() -> CredsRoot { + match load_keyring_credentials() { + Ok(Some(root)) => return root, + Ok(None) => {} + Err(e) => { + log::warn!("[vault] system credential read failed: {e}"); return CredsRoot::default(); } + } + + let Some(legacy) = credentials_path().ok().and_then(|p| read_legacy_credentials_file(&p)) else { + return CredsRoot::default(); }; - serde_json::from_slice::(&bytes).unwrap_or_else(|e| { - log::warn!("[vault] parse {} failed: {}", path.display(), e); - CredsRoot::default() - }) + + if let Err(e) = save_credentials(&legacy) { + log::warn!("[vault] legacy credential migration failed: {e}"); + return CredsRoot::default(); + } + legacy } fn save_credentials(root: &CredsRoot) -> Result<()> { - let path = credentials_path()?; - ensure_credentials_dir(&path)?; - // 写盘前过滤掉空 entry,保持 JSON 干净(mirrors Swift cleanedSchema)。 - let mut cleaned = root.clone(); - cleaned.providers.asr.retain(|_, v| !v.is_empty()); - cleaned.providers.llm.retain(|_, v| !v.is_empty()); - let json = serde_json::to_vec_pretty(&cleaned).context("encode credentials failed")?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, &json).with_context(|| format!("write {} failed", tmp.display()))?; - fs::rename(&tmp, &path).with_context(|| format!("rename to {} failed", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); - } + let cleaned = clean_credentials(root); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + keyring_entry()? + .set_password(&json) + .context("write system credential vault")?; + remove_legacy_credentials_file(); Ok(()) } @@ -682,12 +710,11 @@ pub struct CredentialsSnapshot { pub ark_endpoint: Option, } -/// 凭据存储——纯 JSON 文件,**不**走 Keychain。详见文件头部注释。 +/// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。 pub struct CredentialsVault; impl CredentialsVault { - /// 历史保留:Swift 时代以此名作为 Keychain service。Rust 不再使用 Keychain, - /// 但暴露此常量给可能仍依赖它的代码点。 + /// 系统凭据库 service name;macOS 下对应 Keychain service。 pub const SERVICE_NAME: &'static str = "com.openless.app"; pub fn get(account: CredentialAccount) -> Result> { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index c55ee618..0e4d37ef 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -291,6 +291,7 @@ export const en: typeof zhCN = { llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.', providerLabel: 'Provider', llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', + credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', asrDesc: 'Used to turn speech into text in real time.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ac6798ec..6470e4e9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -289,6 +289,7 @@ export const zhCN = { llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。', providerLabel: '供应商', llmProviderDesc: '选择后将自动填入 Base URL 默认值。', + credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', asrDesc: '用于将口述实时转写为文本。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5491af92..e449becf 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -291,6 +291,7 @@ export const zhTW: typeof zhCN = { llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。', providerLabel: '供應商', llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', + credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', asrDesc: '用於將口述實時轉寫爲文本。', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index f7aa64c2..2bc27a2b 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -508,6 +508,9 @@ function ProvidersSection() { return ( <> +
+ {t('settings.providers.credentialStorageNotice')} +
{t('settings.providers.llmTitle')}
From 64296e3555303a8124a0eee3f35d7b926c428acb Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 09:07:36 +0800 Subject: [PATCH 02/13] Avoid Windows vault blob overflow for provider credentials The OS vault migration keeps provider state as one logical JSON payload, but Windows Credential Manager limits each credential blob. Store the logical payload as a manifest plus bounded chunks so long API keys, multiple providers, or extra headers do not make future saves fail on Windows. Constraint: Windows Credential Manager generic credential blob is capped and keyring stores passwords as UTF-16 on Windows. Rejected: Split each provider field into independent vault entries | broader schema rewrite and more migration surface than needed for this review fix. Confidence: high Scope-risk: narrow Directive: Keep the legacy plaintext credentials.json path migration-only; do not add compatibility for transient PR-internal vault formats unless the release shipped them. Tested: cargo check --manifest-path src-tauri/Cargo.toml Tested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit Tested: npm run -s build Tested: git diff --check Not-tested: Live Windows Credential Manager write with oversized real provider payload. Related: #230 --- openless-all/app/src-tauri/src/persistence.rs | 154 ++++++++++++++++-- 1 file changed, 141 insertions(+), 13 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 503621a4..ed6fa0ac 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -36,6 +36,10 @@ const LEGACY_CREDS_DIR: &str = ".openless"; const LEGACY_CREDS_FILE: &str = "credentials.json"; const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1"; +const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk."; +// Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores +// passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that. +const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); @@ -117,7 +121,8 @@ fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Resul // ───────────────────────── credentials vault ───────────────────────── // // 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider -// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload,而不是逐字段散落。 +// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload;payload 会按平台 +// 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。 // // v1 schema: // { @@ -260,7 +265,11 @@ fn credentials_path() -> Result { } fn keyring_entry() -> Result { - keyring::Entry::new(CredentialsVault::SERVICE_NAME, KEYRING_CREDENTIALS_ACCOUNT) + keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT) +} + +fn keyring_entry_for(account: &str) -> Result { + keyring::Entry::new(CredentialsVault::SERVICE_NAME, account) .context("open system credential vault") } @@ -300,13 +309,80 @@ fn remove_legacy_credentials_file() { } } +#[derive(Debug, Serialize, Deserialize)] +struct CredsChunkManifest { + openless_credentials_storage: String, + version: u32, + chunks: usize, +} + +fn chunk_account(index: usize) -> String { + format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}") +} + +fn chunk_json_payload(json: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_units = 0usize; + for ch in json.chars() { + let units = ch.len_utf16(); + if !current.is_empty() && current_units + units > KEYRING_CHUNK_MAX_UTF16_UNITS { + chunks.push(std::mem::take(&mut current)); + current_units = 0; + } + current.push(ch); + current_units += units; + } + if !current.is_empty() || json.is_empty() { + chunks.push(current); + } + chunks +} + +fn read_chunk_manifest(json: &str) -> Option { + let manifest = serde_json::from_str::(json).ok()?; + if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 { + Some(manifest) + } else { + None + } +} + +fn get_keyring_password(account: &str) -> Result> { + match keyring_entry_for(account)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => { + Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}")) + } + } +} + +fn delete_keyring_password(account: &str) { + match keyring_entry_for(account).and_then(|entry| { + entry + .delete_credential() + .with_context(|| format!("delete system credential vault {account}")) + }) { + Ok(()) | Err(_) => {} + } +} + fn load_keyring_credentials() -> Result> { - let entry = keyring_entry()?; - let json = match entry.get_password() { - Ok(json) => json, - Err(keyring::Error::NoEntry) => return Ok(None), - Err(e) => return Err(anyhow!(e)).context("read system credential vault"), + let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else { + return Ok(None); }; + + let manifest = read_chunk_manifest(&json_or_manifest) + .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; + let mut json = String::new(); + for index in 0..manifest.chunks { + let account = chunk_account(index); + let chunk = get_keyring_password(&account)? + .ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?; + json.push_str(&chunk); + } + serde_json::from_str::(&json) .map(Some) .context("decode system credential vault payload") @@ -322,7 +398,10 @@ fn load_credentials() -> CredsRoot { } } - let Some(legacy) = credentials_path().ok().and_then(|p| read_legacy_credentials_file(&p)) else { + let Some(legacy) = credentials_path() + .ok() + .and_then(|p| read_legacy_credentials_file(&p)) + else { return CredsRoot::default(); }; @@ -336,9 +415,36 @@ fn load_credentials() -> CredsRoot { fn save_credentials(root: &CredsRoot) -> Result<()> { let cleaned = clean_credentials(root); let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + let previous_chunk_count = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) + .ok() + .flatten() + .and_then(|value| read_chunk_manifest(&value)) + .map(|manifest| manifest.chunks) + .unwrap_or(0); + let chunks = chunk_json_payload(&json); + + for (index, chunk) in chunks.iter().enumerate() { + let account = chunk_account(index); + keyring_entry_for(&account)? + .set_password(chunk) + .with_context(|| format!("write system credential vault chunk {index}"))?; + } + + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".to_string(), + version: 1, + chunks: chunks.len(), + }; + let manifest_json = + serde_json::to_string(&manifest).context("encode credential manifest failed")?; keyring_entry()? - .set_password(&json) - .context("write system credential vault")?; + .set_password(&manifest_json) + .context("write system credential vault manifest")?; + + for index in chunks.len()..previous_chunk_count { + delete_keyring_password(&chunk_account(index)); + } + remove_legacy_credentials_file(); Ok(()) } @@ -784,14 +890,33 @@ impl CredentialsVault { #[cfg(test)] mod tests { - use super::{list_vocab_presets, save_vocab_presets}; + use super::{ + chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS, + }; use crate::types::{VocabPreset, VocabPresetStore}; use std::fs; use std::path::PathBuf; + #[test] + fn credential_payload_chunks_stay_under_windows_blob_limit() { + let payload = format!( + "{}{}{}", + "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25), + "😀".repeat(20), + "b".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25) + ); + let chunks = chunk_json_payload(&payload); + assert!(chunks.len() > 1); + assert_eq!(chunks.concat(), payload); + assert!(chunks + .iter() + .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); + } + #[test] fn vocab_presets_roundtrip_json_file() { - let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&tmp).expect("create temp dir"); // Linux path helper uses XDG_DATA_HOME first. unsafe { @@ -810,7 +935,10 @@ mod tests { let loaded = list_vocab_presets().expect("list presets"); assert_eq!(loaded.custom.len(), 1); assert_eq!(loaded.custom[0].id, "test"); - assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]); + assert_eq!( + loaded.custom[0].phrases, + vec!["PR".to_string(), "CI".to_string()] + ); assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]); let _ = fs::remove_dir_all(&tmp); } From ddd65280888f4cc68da5b0eb9b3bede266ab2f3a Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 09:19:37 +0800 Subject: [PATCH 03/13] Preserve legacy credentials when vault migration cannot run Users upgrading from the plaintext credentials file should not lose access to existing settings just because the OS vault backend is temporarily unavailable or migration cannot be written. Keep the old file migration-only, but return its contents as the compatibility source when vault reads or writes fail. Constraint: credentials.json remains a legacy compatibility source only, not the normal write target. Rejected: Return defaults on vault read/write errors | hides existing upgrade data and makes configured providers appear empty. Confidence: high Scope-risk: narrow Directive: Do not reintroduce plaintext writes; fallback reads are only for pre-PR legacy migration compatibility. Tested: cargo check --manifest-path src-tauri/Cargo.toml Tested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit Tested: npm run -s build Tested: git diff --check Not-tested: Manual vault-unavailable upgrade run on Windows/macOS/Linux. Related: #230 --- openless-all/app/src-tauri/src/persistence.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index ed6fa0ac..204c8673 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -388,26 +388,28 @@ fn load_keyring_credentials() -> Result> { .context("decode system credential vault payload") } +fn load_legacy_credentials() -> Option { + credentials_path() + .ok() + .and_then(|p| read_legacy_credentials_file(&p)) +} + fn load_credentials() -> CredsRoot { match load_keyring_credentials() { Ok(Some(root)) => return root, Ok(None) => {} Err(e) => { log::warn!("[vault] system credential read failed: {e}"); - return CredsRoot::default(); + return load_legacy_credentials().unwrap_or_default(); } } - let Some(legacy) = credentials_path() - .ok() - .and_then(|p| read_legacy_credentials_file(&p)) - else { + let Some(legacy) = load_legacy_credentials() else { return CredsRoot::default(); }; if let Err(e) = save_credentials(&legacy) { log::warn!("[vault] legacy credential migration failed: {e}"); - return CredsRoot::default(); } legacy } From 4498bedf4ccf6fae3ab893aaf5ebd544a9eae3d3 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 09:42:40 +0800 Subject: [PATCH 04/13] Make vault chunk migration crash-safe Credential vault saves now write a fresh generation of chunks before switching the manifest, so failed chunk writes or crashes do not overwrite chunks still referenced by the previous manifest. The same change also migrates documented per-account vault entries into the chunked manifest before falling back to legacy JSON. Constraint: Windows Credential Manager requires chunking, and legacy credentials may exist either as plaintext JSON or documented per-account vault entries. Rejected: Overwrite stable chunk names before manifest update | can mix old and new payloads after partial writes. Confidence: high Scope-risk: narrow Directive: Treat the manifest as the commit pointer; write new-generation chunks before updating it and delete old chunks only after manifest write succeeds. Tested: cargo check --manifest-path src-tauri/Cargo.toml Tested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit Tested: npm run -s build Tested: git diff --check Not-tested: Manual crash injection during OS vault writes. Related: #230 --- openless-all/app/src-tauri/src/persistence.rs | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 204c8673..d4e10a32 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -313,11 +313,12 @@ fn remove_legacy_credentials_file() { struct CredsChunkManifest { openless_credentials_storage: String, version: u32, + generation: String, chunks: usize, } -fn chunk_account(index: usize) -> String { - format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}") +fn chunk_account(generation: &str, index: usize) -> String { + format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{generation}.{index}") } fn chunk_json_payload(json: &str) -> Vec { @@ -377,7 +378,7 @@ fn load_keyring_credentials() -> Result> { .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; let mut json = String::new(); for index in 0..manifest.chunks { - let account = chunk_account(index); + let account = chunk_account(&manifest.generation, index); let chunk = get_keyring_password(&account)? .ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?; json.push_str(&chunk); @@ -388,6 +389,25 @@ fn load_keyring_credentials() -> Result> { .context("decode system credential vault payload") } +fn load_legacy_keyring_credentials() -> CredsRoot { + let mut root = CredsRoot::default(); + for account in CredentialAccount::all() { + let legacy_account = account.keyring_account(); + match get_keyring_password(legacy_account) { + Ok(Some(value)) => write_account(&mut root, *account, Some(value)), + Ok(None) => {} + Err(e) => log::warn!("[vault] read legacy vault {legacy_account} failed: {e}"), + } + } + clean_credentials(&root) +} + +fn remove_legacy_keyring_credentials() { + for account in CredentialAccount::all() { + delete_keyring_password(account.keyring_account()); + } +} + fn load_legacy_credentials() -> Option { credentials_path() .ok() @@ -404,6 +424,16 @@ fn load_credentials() -> CredsRoot { } } + let legacy_vault = load_legacy_keyring_credentials(); + if !legacy_vault.providers.asr.is_empty() || !legacy_vault.providers.llm.is_empty() { + if let Err(e) = save_credentials(&legacy_vault) { + log::warn!("[vault] legacy vault credential migration failed: {e}"); + } else { + remove_legacy_keyring_credentials(); + } + return legacy_vault; + } + let Some(legacy) = load_legacy_credentials() else { return CredsRoot::default(); }; @@ -417,16 +447,15 @@ fn load_credentials() -> CredsRoot { fn save_credentials(root: &CredsRoot) -> Result<()> { let cleaned = clean_credentials(root); let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; - let previous_chunk_count = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) + let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) .ok() .flatten() - .and_then(|value| read_chunk_manifest(&value)) - .map(|manifest| manifest.chunks) - .unwrap_or(0); + .and_then(|value| read_chunk_manifest(&value)); let chunks = chunk_json_payload(&json); + let generation = Uuid::new_v4().to_string(); for (index, chunk) in chunks.iter().enumerate() { - let account = chunk_account(index); + let account = chunk_account(&generation, index); keyring_entry_for(&account)? .set_password(chunk) .with_context(|| format!("write system credential vault chunk {index}"))?; @@ -435,6 +464,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { let manifest = CredsChunkManifest { openless_credentials_storage: "chunked".to_string(), version: 1, + generation: generation.clone(), chunks: chunks.len(), }; let manifest_json = @@ -443,8 +473,12 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { .set_password(&manifest_json) .context("write system credential vault manifest")?; - for index in chunks.len()..previous_chunk_count { - delete_keyring_password(&chunk_account(index)); + if let Some(previous) = previous_manifest { + if previous.generation != generation { + for index in 0..previous.chunks { + delete_keyring_password(&chunk_account(&previous.generation, index)); + } + } } remove_legacy_credentials_file(); From 9a1461bcb60e99d51fa8daa2dfaf4528132fa09f Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 10:02:59 +0800 Subject: [PATCH 05/13] Protect vault migrations from stale fallback overwrites The migration path must prefer the current legacy JSON source over older per-account vault leftovers, clean those leftovers after a valid manifest is available, and avoid write operations overwriting a damaged or unreadable vault payload with fallback defaults. Constraint: credentials.json is migration-only, but it was the previous Tauri source of truth.\nRejected: Use read fallback for mutating updates | can overwrite unreadable vault data with defaults.\nConfidence: high\nScope-risk: narrow\nDirective: Mutating credential operations must propagate chunked vault read errors instead of saving fallback roots.\nTested: cargo check --manifest-path src-tauri/Cargo.toml\nTested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit\nTested: npm run -s build\nTested: git diff --check\nNot-tested: Manual migration with simultaneous JSON and stale per-account vault entries.\nRelated: #230 --- openless-all/app/src-tauri/src/persistence.rs | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index d4e10a32..a7232705 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -414,18 +414,20 @@ fn load_legacy_credentials() -> Option { .and_then(|p| read_legacy_credentials_file(&p)) } -fn load_credentials() -> CredsRoot { - match load_keyring_credentials() { - Ok(Some(root)) => return root, - Ok(None) => {} - Err(e) => { - log::warn!("[vault] system credential read failed: {e}"); - return load_legacy_credentials().unwrap_or_default(); +fn legacy_vault_has_credentials(root: &CredsRoot) -> bool { + !root.providers.asr.is_empty() || !root.providers.llm.is_empty() +} + +fn migrate_legacy_sources() -> CredsRoot { + if let Some(legacy) = load_legacy_credentials() { + if let Err(e) = save_credentials(&legacy) { + log::warn!("[vault] legacy credential migration failed: {e}"); } + return legacy; } let legacy_vault = load_legacy_keyring_credentials(); - if !legacy_vault.providers.asr.is_empty() || !legacy_vault.providers.llm.is_empty() { + if legacy_vault_has_credentials(&legacy_vault) { if let Err(e) = save_credentials(&legacy_vault) { log::warn!("[vault] legacy vault credential migration failed: {e}"); } else { @@ -434,14 +436,32 @@ fn load_credentials() -> CredsRoot { return legacy_vault; } - let Some(legacy) = load_legacy_credentials() else { - return CredsRoot::default(); - }; + CredsRoot::default() +} - if let Err(e) = save_credentials(&legacy) { - log::warn!("[vault] legacy credential migration failed: {e}"); +fn load_credentials() -> CredsRoot { + match load_keyring_credentials() { + Ok(Some(root)) => { + remove_legacy_keyring_credentials(); + root + } + Ok(None) => migrate_legacy_sources(), + Err(e) => { + log::warn!("[vault] system credential read failed: {e}"); + load_legacy_credentials().unwrap_or_default() + } + } +} + +fn load_credentials_for_update() -> Result { + match load_keyring_credentials() { + Ok(Some(root)) => { + remove_legacy_keyring_credentials(); + Ok(root) + } + Ok(None) => Ok(migrate_legacy_sources()), + Err(e) => Err(e), } - legacy } fn save_credentials(root: &CredsRoot) -> Result<()> { @@ -866,7 +886,7 @@ impl CredentialsVault { pub fn set(account: CredentialAccount, value: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; let v = if value.is_empty() { None } else { @@ -878,7 +898,7 @@ impl CredentialsVault { pub fn remove(account: CredentialAccount) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; write_account(&mut root, account, None); save_credentials(&root) } @@ -890,14 +910,14 @@ impl CredentialsVault { pub fn set_active_asr_provider(id: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; root.active.asr = id.to_string(); save_credentials(&root) } pub fn set_active_llm_provider(id: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; root.active.llm = id.to_string(); save_credentials(&root) } From b97a54bb89f61c1e1de189900dcedca6966542b2 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 10:25:44 +0800 Subject: [PATCH 06/13] Surface legacy plaintext cleanup failures Vault migration should not silently report success when credentials were written to the OS vault but the previous plaintext credentials file could not be removed. Propagating that cleanup error on write/update paths makes the remaining plaintext exposure visible instead of treating the migration as complete. Constraint: credentials.json is allowed only as a migration source, not a durable fallback after a successful vault write.\nRejected: Keep deletion best-effort only | can leave plaintext credentials indefinitely with no user-visible failure.\nConfidence: high\nScope-risk: narrow\nDirective: Do not hide legacy plaintext deletion failures on credential write paths.\nTested: cargo check --manifest-path src-tauri/Cargo.toml\nTested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit\nTested: npm run -s build\nTested: git diff --check\nNot-tested: Manual permission-denied legacy file cleanup on installed apps.\nRelated: #230 --- openless-all/app/src-tauri/src/persistence.rs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index a7232705..13b660d6 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -300,12 +300,20 @@ fn read_legacy_credentials_file(path: &Path) -> Option { } } -fn remove_legacy_credentials_file() { - let Ok(path) = credentials_path() else { return }; +fn remove_legacy_credentials_file() -> Result<()> { + let Ok(path) = credentials_path() else { + return Ok(()); + }; if path.exists() { - if let Err(e) = fs::remove_file(&path) { - log::warn!("[vault] remove legacy {} failed: {}", path.display(), e); - } + fs::remove_file(&path) + .with_context(|| format!("remove legacy credentials file {}", path.display()))?; + } + Ok(()) +} + +fn remove_legacy_credentials_file_best_effort() { + if let Err(e) = remove_legacy_credentials_file() { + log::warn!("[vault] remove legacy credentials file failed: {e}"); } } @@ -443,6 +451,7 @@ fn load_credentials() -> CredsRoot { match load_keyring_credentials() { Ok(Some(root)) => { remove_legacy_keyring_credentials(); + remove_legacy_credentials_file_best_effort(); root } Ok(None) => migrate_legacy_sources(), @@ -457,6 +466,7 @@ fn load_credentials_for_update() -> Result { match load_keyring_credentials() { Ok(Some(root)) => { remove_legacy_keyring_credentials(); + remove_legacy_credentials_file()?; Ok(root) } Ok(None) => Ok(migrate_legacy_sources()), @@ -501,7 +511,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { } } - remove_legacy_credentials_file(); + remove_legacy_credentials_file()?; Ok(()) } From 579efb0546fac16ca770767337a2ee3c8d37af90 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 11:01:13 +0800 Subject: [PATCH 07/13] Keep Linux vault credentials persistent Linux credential storage must survive reboot after the legacy plaintext file is removed. The keyring linux-native feature uses kernel keyutils, so the Linux build now enables the persistent Secret Service-backed combo and the migration read fallback also considers older per-account vault entries when a current manifest is unreadable. Constraint: credentials.json must be removed after successful vault migration.\nConstraint: keyring linux-native/keyutils is an in-memory cache across reboot.\nRejected: Keep plaintext JSON as Linux fallback | violates the vault storage boundary.\nConfidence: high\nScope-risk: moderate\nDirective: Linux credential storage must remain persistent if legacy plaintext cleanup is enforced.\nTested: cargo check --manifest-path src-tauri/Cargo.toml\nTested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit\nTested: npm run -s build\nTested: git diff --check\nNot-tested: Manual Secret Service read/write on a desktop Linux keyring session.\nRelated: #230 --- openless-all/app/src-tauri/Cargo.lock | 329 +++++++++++++++++- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/src/persistence.rs | 40 ++- 3 files changed, 348 insertions(+), 23 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index da167533..61d62340 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -400,6 +411,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block-sys" version = "0.2.1" @@ -594,6 +614,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.61" @@ -668,6 +697,16 @@ 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", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -1053,6 +1092,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1103,6 +1160,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2039,6 +2097,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2368,6 +2444,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2607,8 +2693,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", + "dbus-secret-service", "linux-keyutils", "log", + "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", "windows-sys 0.60.2", @@ -2970,6 +3058,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -2980,6 +3081,39 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2997,6 +3131,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4533,6 +4698,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -4952,6 +5136,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -5339,7 +5529,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -7287,6 +7477,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkbcommon" version = "0.7.0" @@ -7327,6 +7527,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.15.0" @@ -7357,9 +7589,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.2", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7372,9 +7617,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -7385,7 +7641,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.2", - "zvariant", + "zvariant 5.11.0", ] [[package]] @@ -7434,6 +7690,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7501,6 +7771,19 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.11.0" @@ -7511,8 +7794,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.2", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7525,7 +7821,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 53134d05..978fddab 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -64,7 +64,7 @@ features = ["windows-native"] [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring] version = "3.6.3" default-features = false -features = ["linux-native"] +features = ["linux-native-sync-persistent", "crypto-rust"] [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.5" diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 13b660d6..4904236e 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -426,27 +426,45 @@ fn legacy_vault_has_credentials(root: &CredsRoot) -> bool { !root.providers.asr.is_empty() || !root.providers.llm.is_empty() } -fn migrate_legacy_sources() -> CredsRoot { +fn load_legacy_sources_without_migration() -> CredsRoot { if let Some(legacy) = load_legacy_credentials() { - if let Err(e) = save_credentials(&legacy) { - log::warn!("[vault] legacy credential migration failed: {e}"); - } return legacy; } let legacy_vault = load_legacy_keyring_credentials(); if legacy_vault_has_credentials(&legacy_vault) { - if let Err(e) = save_credentials(&legacy_vault) { - log::warn!("[vault] legacy vault credential migration failed: {e}"); - } else { - remove_legacy_keyring_credentials(); - } return legacy_vault; } CredsRoot::default() } +fn migrate_legacy_sources() -> CredsRoot { + match migrate_legacy_sources_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] legacy credential migration failed: {e}"); + load_legacy_sources_without_migration() + } + } +} + +fn migrate_legacy_sources_for_update() -> Result { + if let Some(legacy) = load_legacy_credentials() { + save_credentials(&legacy)?; + return Ok(legacy); + } + + let legacy_vault = load_legacy_keyring_credentials(); + if legacy_vault_has_credentials(&legacy_vault) { + save_credentials(&legacy_vault)?; + remove_legacy_keyring_credentials(); + return Ok(legacy_vault); + } + + Ok(CredsRoot::default()) +} + fn load_credentials() -> CredsRoot { match load_keyring_credentials() { Ok(Some(root)) => { @@ -457,7 +475,7 @@ fn load_credentials() -> CredsRoot { Ok(None) => migrate_legacy_sources(), Err(e) => { log::warn!("[vault] system credential read failed: {e}"); - load_legacy_credentials().unwrap_or_default() + load_legacy_sources_without_migration() } } } @@ -469,7 +487,7 @@ fn load_credentials_for_update() -> Result { remove_legacy_credentials_file()?; Ok(root) } - Ok(None) => Ok(migrate_legacy_sources()), + Ok(None) => migrate_legacy_sources_for_update(), Err(e) => Err(e), } } From 5bf9d727244ba72a0ce06046439199af6142064c Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 11:34:24 +0800 Subject: [PATCH 08/13] Preserve legacy vault entries on partial read failure Legacy per-account vault migration must be all-or-nothing. If one old entry is temporarily unreadable, saving the readable subset and deleting the original entries can silently drop the failed secret, so update-path migration now aborts on any legacy vault read error while the non-mutating read fallback remains conservative. Constraint: migration cleanup deletes old per-account vault entries after saving the chunked manifest.\nRejected: Migrate the readable subset | can discard an unreadable legacy secret before retry.\nConfidence: high\nScope-risk: narrow\nDirective: Do not remove legacy per-account vault entries unless every legacy account read completed without error.\nTested: cargo check --manifest-path src-tauri/Cargo.toml\nTested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit\nTested: npm run -s build\nTested: git diff --check\nNot-tested: Manual transient per-account vault read failure during upgrade.\nRelated: #230 --- openless-all/app/src-tauri/src/persistence.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 4904236e..8a56d39d 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -398,16 +398,26 @@ fn load_keyring_credentials() -> Result> { } fn load_legacy_keyring_credentials() -> CredsRoot { + match load_legacy_keyring_credentials_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] read legacy vault credentials failed: {e}"); + CredsRoot::default() + } + } +} + +fn load_legacy_keyring_credentials_for_update() -> Result { let mut root = CredsRoot::default(); for account in CredentialAccount::all() { let legacy_account = account.keyring_account(); match get_keyring_password(legacy_account) { Ok(Some(value)) => write_account(&mut root, *account, Some(value)), Ok(None) => {} - Err(e) => log::warn!("[vault] read legacy vault {legacy_account} failed: {e}"), + Err(e) => return Err(e.context(format!("read legacy vault {legacy_account}"))), } } - clean_credentials(&root) + Ok(clean_credentials(&root)) } fn remove_legacy_keyring_credentials() { @@ -455,7 +465,7 @@ fn migrate_legacy_sources_for_update() -> Result { return Ok(legacy); } - let legacy_vault = load_legacy_keyring_credentials(); + let legacy_vault = load_legacy_keyring_credentials_for_update()?; if legacy_vault_has_credentials(&legacy_vault) { save_credentials(&legacy_vault)?; remove_legacy_keyring_credentials(); From c1be1ddd5586b436c1f718e98e33a97958e02a93 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 12:46:01 +0800 Subject: [PATCH 09/13] Clean stale vault entries after JSON migration When the current legacy credentials.json source wins migration, older per-account vault entries should not remain as stale fallback data. After the JSON payload is successfully written to the chunked vault and plaintext cleanup succeeds, the migration now removes those older per-account entries as well. Constraint: credentials.json is the previous Tauri source of truth and must win over stale Swift-era vault entries.\nRejected: Leave per-account entries for fallback | keeps stale secrets reachable after successful migration.\nConfidence: high\nScope-risk: narrow\nDirective: Clean old per-account vault entries after any successful authoritative migration source is saved.\nTested: cargo check --manifest-path src-tauri/Cargo.toml\nTested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit\nTested: npm run -s build\nTested: git diff --check\nNot-tested: Manual upgrade with both credentials.json and stale per-account vault entries.\nRelated: #230 --- openless-all/app/src-tauri/src/persistence.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 8a56d39d..61f4c9de 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -462,6 +462,7 @@ fn migrate_legacy_sources() -> CredsRoot { fn migrate_legacy_sources_for_update() -> Result { if let Some(legacy) = load_legacy_credentials() { save_credentials(&legacy)?; + remove_legacy_keyring_credentials(); return Ok(legacy); } From deb11960d36c9d36e40ebc193b6cf3c83e961d99 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 14:35:33 +0800 Subject: [PATCH 10/13] Keep vault updates independent of obsolete JSON cleanup Credential writes should not fail only because an old migration source cannot be deleted. Once current vault data is readable and writable, legacy plaintext cleanup remains best-effort and logged so users can still save or remove credentials even if credentials.json is locked or read-only.\n\nThe rebase also brought the new Japanese and Korean locale files onto this branch, so the credential storage notice key is filled there to keep the frontend type contract intact.\n\nConstraint: credentials.json is obsolete migration data after vault write.\nRejected: Propagate legacy JSON deletion failures on every vault save | blocks credential updates despite a valid vault.\nConfidence: high\nScope-risk: narrow\nDirective: Do not make credential updates depend on deleting legacy plaintext files.\nTested: cargo check --manifest-path src-tauri/Cargo.toml\nTested: cargo test --manifest-path src-tauri/Cargo.toml credential_payload_chunks_stay_under_windows_blob_limit\nTested: npm run -s build\nTested: git diff --check\nNot-tested: Manual locked/read-only credentials.json cleanup.\nRelated: #230 --- openless-all/app/src-tauri/src/persistence.rs | 4 ++-- openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 61f4c9de..47abeb94 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -495,7 +495,7 @@ fn load_credentials_for_update() -> Result { match load_keyring_credentials() { Ok(Some(root)) => { remove_legacy_keyring_credentials(); - remove_legacy_credentials_file()?; + remove_legacy_credentials_file_best_effort(); Ok(root) } Ok(None) => migrate_legacy_sources_for_update(), @@ -540,7 +540,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { } } - remove_legacy_credentials_file()?; + remove_legacy_credentials_file_best_effort(); Ok(()) } diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c98989f6..45312c31 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -293,6 +293,7 @@ export const ja: typeof zhCN = { llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。', providerLabel: 'サプライヤー', llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', + credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', asrDesc: '口述をリアルタイムでテキストに転写。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ac669fc1..93efe33a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -293,6 +293,7 @@ export const ko: typeof zhCN = { llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.', providerLabel: '공급자', llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.', + credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.', asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', From c06db84d3e0997efae89a89a8daca2a8f7a81b90 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 15:21:29 +0800 Subject: [PATCH 11/13] Keep Windows smoke gates compatible with vault storage Windows smoke scripts should no longer fail solely because credentials.json was migrated into the OS credential vault. The default suite now runs runtime startup without the obsolete JSON gate, and explicit legacy JSON checks warn instead of blocking vault-backed test machines.\n\nConstraint: Provider credentials now live in the OS credential vault after migration.\nRejected: Query Windows Credential Manager from smoke scripts | larger platform-specific parsing surface for a smoke gate that only needs to avoid the obsolete JSON blocker.\nConfidence: high\nScope-risk: narrow\nDirective: Do not make Windows smoke defaults depend on migrated plaintext credentials.json.\nTested: git diff --check\nTested: Python sanity check for conflict markers in edited PowerShell scripts\nNot-tested: PowerShell parser unavailable in this Linux environment.\nRelated: #230 --- openless-all/app/scripts/windows-real-regression.ps1 | 2 +- openless-all/app/scripts/windows-runtime-smoke.ps1 | 2 +- openless-all/app/scripts/windows-smoke-suite.ps1 | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openless-all/app/scripts/windows-real-regression.ps1 b/openless-all/app/scripts/windows-real-regression.ps1 index aeaafa97..6a59baf6 100644 --- a/openless-all/app/scripts/windows-real-regression.ps1 +++ b/openless-all/app/scripts/windows-real-regression.ps1 @@ -84,7 +84,7 @@ $credentialStatus = Get-OpenLessCredentialStatus Write-Host "== Credential gate ==" $credentialStatus | Format-List if ($RequireCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { - throw "Real regression requires configured Volcengine ASR and Ark LLM credentials." + Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault." } Write-Host "" diff --git a/openless-all/app/scripts/windows-runtime-smoke.ps1 b/openless-all/app/scripts/windows-runtime-smoke.ps1 index 237b153b..053dcc19 100644 --- a/openless-all/app/scripts/windows-runtime-smoke.ps1 +++ b/openless-all/app/scripts/windows-runtime-smoke.ps1 @@ -71,7 +71,7 @@ if (-not $credentialStatus.ArkConfigured) { Write-Host "[warn] Ark LLM credentials are not configured; polishing will fall back or fail depending on mode." } if ($RequireCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { - throw "Real regression requires configured Volcengine ASR and Ark LLM credentials." + Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault." } Write-Host "" diff --git a/openless-all/app/scripts/windows-smoke-suite.ps1 b/openless-all/app/scripts/windows-smoke-suite.ps1 index 55797fed..5efc5492 100644 --- a/openless-all/app/scripts/windows-smoke-suite.ps1 +++ b/openless-all/app/scripts/windows-smoke-suite.ps1 @@ -90,7 +90,6 @@ try { Invoke-Step "Runtime smoke" { Invoke-Script (Join-Path $PSScriptRoot "windows-runtime-smoke.ps1") @{ ExePath = $ExePath - RequireCredentials = $true } } } From 1d04131bedc05a0ab589775a1d2a0bda342fdae1 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 6 May 2026 15:49:28 +0800 Subject: [PATCH 12/13] Preserve strict credential gate for real regression mode Real regression runs that explicitly pass -RequireCredentials should still fail fast when credentials cannot be confirmed. This keeps the real-ASR signal strict while leaving the default runtime smoke suite decoupled from legacy credentials.json.\n\nConstraint: default smoke suite must not depend on migrated plaintext JSON.\nRejected: Keep warning-only behavior in real regression mode | allows credential-missing runs to appear as valid real-ASR regressions.\nConfidence: high\nScope-risk: narrow\nDirective: Keep -RequireCredentials in windows-real-regression as a hard gate unless credential-vault probing is added.\nTested: git diff --check\nNot-tested: Running Windows PowerShell regression scripts in Linux environment.\nRelated: #230 --- openless-all/app/scripts/windows-real-regression.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/scripts/windows-real-regression.ps1 b/openless-all/app/scripts/windows-real-regression.ps1 index 6a59baf6..aeaafa97 100644 --- a/openless-all/app/scripts/windows-real-regression.ps1 +++ b/openless-all/app/scripts/windows-real-regression.ps1 @@ -84,7 +84,7 @@ $credentialStatus = Get-OpenLessCredentialStatus Write-Host "== Credential gate ==" $credentialStatus | Format-List if ($RequireCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { - Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault." + throw "Real regression requires configured Volcengine ASR and Ark LLM credentials." } Write-Host "" From 0505c2fc547f0d55f633ff6f761c966a11af7fa0 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 6 May 2026 20:18:02 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(vault):=20keyring=20chunks=20?= =?UTF-8?q?=E7=94=A8=E7=A8=B3=E5=AE=9A=E5=90=8D=20+=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=AF=8F=E6=AC=A1=20load=20=E9=83=BD=E5=88=A0=20legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测:首次允许 Keychain 后仍反复弹窗十几次。 根因: 1. save_credentials 每次都生成新 UUID 作为 chunk account 命名前缀 (credentials.v1.chunk..0/.1)。macOS Keychain 的「始终允许」 绑定到 (binary 签名, entry 名),每次保存设置都让 chunk entry 名变 → 之前 ACL 全失效 → 重新弹。 2. load_credentials / load_credentials_for_update 每次都调 remove_legacy_keyring_credentials() —— 对 9 个旧 stable account 各做一次 keyring delete。delete 在 macOS 上仍触发 ACL 检查, 每次 load 都弹 9 次「OpenLess 想删除 X」。 合计每次 IPC 触发的 load 12 次弹窗,跟实测吻合。 修复(数据安全优先): A. chunk account 命名稳定化 - chunk_account(generation: Option<&str>, idx) 接受可选 generation - save_credentials 总用 None(稳定 credentials.v1.chunk.{idx}) - 新 manifest 不写 generation 字段(serde skip_serializing_if=None) - 旧 manifest 仍可读(generation: Option,含 UUID 时按旧格式 读)→ 一次 save 后无缝迁移到新格式 + 删除旧 UUID chunks B. partial-write 安全 - 仍保留「先写所有 chunks,再写 manifest」顺序 - chunks 数量减少时主动删除 idx >= new count 的旧 entries - 旧 UUID chunks 保留删除路径(迁移用) C. legacy keyring delete 仅在 migration 路径触发 - load_credentials / load_credentials_for_update 不再调用 remove_legacy_keyring_credentials() - migrate_legacy_sources_for_update 内部仍调用一次 → 首次成功迁移 就清干净;之后 load 不再尝试 delete 不存在的 entries - legacy 文件 delete 不需 ACL,best-effort 调用保留 D. 不影响 Windows - Windows Credential Manager 没有 ACL 弹窗概念 - 仍走 chunked storage(2560 byte 限制需要分块) 修复后体感: - 首次启动迁移:~3 次弹(manifest + 2 chunks),用户点 Always Allow - 之后启动 / 保存设置:0 次弹(ACL 长期有效) --- openless-all/app/src-tauri/src/persistence.rs | 52 ++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 47abeb94..a5438b35 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -321,12 +321,21 @@ fn remove_legacy_credentials_file_best_effort() { struct CredsChunkManifest { openless_credentials_storage: String, version: u32, - generation: String, + /// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀, + /// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。 + /// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。 + #[serde(default, skip_serializing_if = "Option::is_none")] + generation: Option, chunks: usize, } -fn chunk_account(generation: &str, index: usize) -> String { - format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{generation}.{index}") +/// 旧版(generation=Some):`credentials.v1.chunk..{index}` +/// 新版(generation=None):`credentials.v1.chunk.{index}` —— 稳定名,ACL 长期有效 +fn chunk_account(generation: Option<&str>, index: usize) -> String { + match generation { + Some(gen) => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{gen}.{index}"), + None => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}"), + } } fn chunk_json_payload(json: &str) -> Vec { @@ -386,7 +395,7 @@ fn load_keyring_credentials() -> Result> { .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; let mut json = String::new(); for index in 0..manifest.chunks { - let account = chunk_account(&manifest.generation, index); + let account = chunk_account(manifest.generation.as_deref(), index); let chunk = get_keyring_password(&account)? .ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?; json.push_str(&chunk); @@ -479,7 +488,12 @@ fn migrate_legacy_sources_for_update() -> Result { fn load_credentials() -> CredsRoot { match load_keyring_credentials() { Ok(Some(root)) => { - remove_legacy_keyring_credentials(); + // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对 9 个 + // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain + // 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经 + // 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑, + // 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext + // JSON)不需要 ACL,可继续 best-effort 删除。 remove_legacy_credentials_file_best_effort(); root } @@ -494,7 +508,8 @@ fn load_credentials() -> CredsRoot { fn load_credentials_for_update() -> Result { match load_keyring_credentials() { Ok(Some(root)) => { - remove_legacy_keyring_credentials(); + // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring + // entries,避免反复触发 macOS Keychain ACL 弹窗。 remove_legacy_credentials_file_best_effort(); Ok(root) } @@ -511,10 +526,13 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { .flatten() .and_then(|value| read_chunk_manifest(&value)); let chunks = chunk_json_payload(&json); - let generation = Uuid::new_v4().to_string(); + // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 + // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 + // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation + // 设计的回退)。 for (index, chunk) in chunks.iter().enumerate() { - let account = chunk_account(&generation, index); + let account = chunk_account(None, index); keyring_entry_for(&account)? .set_password(chunk) .with_context(|| format!("write system credential vault chunk {index}"))?; @@ -523,7 +541,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { let manifest = CredsChunkManifest { openless_credentials_storage: "chunked".to_string(), version: 1, - generation: generation.clone(), + generation: None, chunks: chunks.len(), }; let manifest_json = @@ -532,10 +550,20 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { .set_password(&manifest_json) .context("write system credential vault manifest")?; + // 清理旧 chunks: + // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) + // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx if let Some(previous) = previous_manifest { - if previous.generation != generation { - for index in 0..previous.chunks { - delete_keyring_password(&chunk_account(&previous.generation, index)); + match previous.generation.as_deref() { + Some(prev_gen) => { + for index in 0..previous.chunks { + delete_keyring_password(&chunk_account(Some(prev_gen), index)); + } + } + None => { + for index in chunks.len()..previous.chunks { + delete_keyring_password(&chunk_account(None, index)); + } } } }