Merge pull request #277 from H-Chris233/fix/issue-230-credential-storage-model
对齐凭据存储策略与威胁模型
This commit is contained in:
17 files changed
+758
-96
No files matched your search
@@ -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\`
|
||||
|
||||
@@ -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\`
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-4
@@ -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 接口
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -90,7 +90,6 @@ try {
|
||||
Invoke-Step "Runtime smoke" {
|
||||
Invoke-Script (Join-Path $PSScriptRoot "windows-runtime-smoke.ps1") @{
|
||||
ExePath = $ExePath
|
||||
RequireCredentials = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+360
-14
@@ -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"
|
||||
@@ -2600,6 +2686,23 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
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",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2683,6 +2786,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 +2994,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -2945,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"
|
||||
@@ -2955,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"
|
||||
@@ -2972,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"
|
||||
@@ -3388,6 +3578,7 @@ dependencies = [
|
||||
"ferrous-opencc",
|
||||
"futures-util",
|
||||
"global-hotkey",
|
||||
"keyring",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -4369,7 +4560,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4397,7 +4588,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 +4698,38 @@ 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"
|
||||
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"
|
||||
@@ -4913,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"
|
||||
@@ -5300,7 +5529,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
"zbus 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7248,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"
|
||||
@@ -7288,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"
|
||||
@@ -7318,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]]
|
||||
@@ -7333,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]]
|
||||
@@ -7346,7 +7641,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.2",
|
||||
"zvariant",
|
||||
"zvariant 5.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7395,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"
|
||||
@@ -7462,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"
|
||||
@@ -7472,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]]
|
||||
@@ -7486,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]]
|
||||
|
||||
@@ -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-sync-persistent", "crypto-rust"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block2 = "0.5"
|
||||
core-foundation = "0.10"
|
||||
|
||||
@@ -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<String>) -> 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<Option<String>, String> {
|
||||
pub fn read_credential(window: Window, account: String) -> Result<Option<String>, 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 {
|
||||
|
||||
@@ -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,16 @@ 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";
|
||||
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<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn credentials_lock() -> &'static Mutex<()> {
|
||||
@@ -115,12 +118,11 @@ fn read_or_default<T: for<'de> 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;payload 会按平台
|
||||
// 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。
|
||||
//
|
||||
// v1 schema:
|
||||
// {
|
||||
@@ -262,56 +264,311 @@ fn credentials_path() -> Result<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
fn keyring_entry() -> Result<keyring::Entry> {
|
||||
keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT)
|
||||
}
|
||||
|
||||
fn keyring_entry_for(account: &str) -> Result<keyring::Entry> {
|
||||
keyring::Entry::new(CredentialsVault::SERVICE_NAME, account)
|
||||
.context("open system credential vault")
|
||||
}
|
||||
|
||||
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<CredsRoot> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("[vault] read legacy {} failed: {}", path.display(), e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<CredsRoot>(&bytes) {
|
||||
Ok(root) => Some(root),
|
||||
Err(e) => {
|
||||
log::warn!("[vault] parse legacy {} failed: {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_legacy_credentials_file() -> Result<()> {
|
||||
let Ok(path) = credentials_path() else {
|
||||
return Ok(());
|
||||
};
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("remove legacy credentials file {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_credentials() -> CredsRoot {
|
||||
let path = match credentials_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return CredsRoot::default(),
|
||||
};
|
||||
if !path.exists() {
|
||||
return CredsRoot::default();
|
||||
fn remove_legacy_credentials_file_best_effort() {
|
||||
if let Err(e) = remove_legacy_credentials_file() {
|
||||
log::warn!("[vault] remove legacy credentials file failed: {e}");
|
||||
}
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("[vault] read {} failed: {}", path.display(), e);
|
||||
return CredsRoot::default();
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CredsChunkManifest {
|
||||
openless_credentials_storage: String,
|
||||
version: u32,
|
||||
/// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀,
|
||||
/// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。
|
||||
/// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
generation: Option<String>,
|
||||
chunks: usize,
|
||||
}
|
||||
|
||||
/// 旧版(generation=Some):`credentials.v1.chunk.<UUID>.{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<String> {
|
||||
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<CredsChunkManifest> {
|
||||
let manifest = serde_json::from_str::<CredsChunkManifest>(json).ok()?;
|
||||
if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 {
|
||||
Some(manifest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_keyring_password(account: &str) -> Result<Option<String>> {
|
||||
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<Option<CredsRoot>> {
|
||||
let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
serde_json::from_slice::<CredsRoot>(&bytes).unwrap_or_else(|e| {
|
||||
log::warn!("[vault] parse {} failed: {}", path.display(), e);
|
||||
CredsRoot::default()
|
||||
})
|
||||
|
||||
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(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);
|
||||
}
|
||||
|
||||
serde_json::from_str::<CredsRoot>(&json)
|
||||
.map(Some)
|
||||
.context("decode system credential vault payload")
|
||||
}
|
||||
|
||||
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<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) => return Err(e.context(format!("read legacy vault {legacy_account}"))),
|
||||
}
|
||||
}
|
||||
Ok(clean_credentials(&root))
|
||||
}
|
||||
|
||||
fn remove_legacy_keyring_credentials() {
|
||||
for account in CredentialAccount::all() {
|
||||
delete_keyring_password(account.keyring_account());
|
||||
}
|
||||
}
|
||||
|
||||
fn load_legacy_credentials() -> Option<CredsRoot> {
|
||||
credentials_path()
|
||||
.ok()
|
||||
.and_then(|p| read_legacy_credentials_file(&p))
|
||||
}
|
||||
|
||||
fn legacy_vault_has_credentials(root: &CredsRoot) -> bool {
|
||||
!root.providers.asr.is_empty() || !root.providers.llm.is_empty()
|
||||
}
|
||||
|
||||
fn load_legacy_sources_without_migration() -> CredsRoot {
|
||||
if let Some(legacy) = load_legacy_credentials() {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
let legacy_vault = load_legacy_keyring_credentials();
|
||||
if legacy_vault_has_credentials(&legacy_vault) {
|
||||
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<CredsRoot> {
|
||||
if let Some(legacy) = load_legacy_credentials() {
|
||||
save_credentials(&legacy)?;
|
||||
remove_legacy_keyring_credentials();
|
||||
return Ok(legacy);
|
||||
}
|
||||
|
||||
let legacy_vault = load_legacy_keyring_credentials_for_update()?;
|
||||
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)) => {
|
||||
// 不在这里调 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
|
||||
}
|
||||
Ok(None) => migrate_legacy_sources(),
|
||||
Err(e) => {
|
||||
log::warn!("[vault] system credential read failed: {e}");
|
||||
load_legacy_sources_without_migration()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_credentials_for_update() -> Result<CredsRoot> {
|
||||
match load_keyring_credentials() {
|
||||
Ok(Some(root)) => {
|
||||
// 同 load_credentials:不再每次 update 都尝试 delete legacy keyring
|
||||
// entries,避免反复触发 macOS Keychain ACL 弹窗。
|
||||
remove_legacy_credentials_file_best_effort();
|
||||
Ok(root)
|
||||
}
|
||||
Ok(None) => migrate_legacy_sources_for_update(),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
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")?;
|
||||
let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| read_chunk_manifest(&value));
|
||||
let chunks = chunk_json_payload(&json);
|
||||
|
||||
// 先写所有 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(None, 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,
|
||||
generation: None,
|
||||
chunks: chunks.len(),
|
||||
};
|
||||
let manifest_json =
|
||||
serde_json::to_string(&manifest).context("encode credential manifest failed")?;
|
||||
keyring_entry()?
|
||||
.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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove_legacy_credentials_file_best_effort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -682,12 +939,11 @@ pub struct CredentialsSnapshot {
|
||||
pub ark_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// 凭据存储——纯 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<Option<String>> {
|
||||
@@ -697,7 +953,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 {
|
||||
@@ -709,7 +965,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)
|
||||
}
|
||||
@@ -721,14 +977,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)
|
||||
}
|
||||
@@ -757,14 +1013,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 {
|
||||
@@ -783,7 +1058,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);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -293,6 +293,7 @@ export const ja: typeof zhCN = {
|
||||
llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。',
|
||||
providerLabel: 'サプライヤー',
|
||||
llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。',
|
||||
credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。',
|
||||
asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。',
|
||||
asrTitle: 'ASR 音声(転写)',
|
||||
asrDesc: '口述をリアルタイムでテキストに転写。',
|
||||
|
||||
@@ -293,6 +293,7 @@ export const ko: typeof zhCN = {
|
||||
llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.',
|
||||
providerLabel: '공급자',
|
||||
llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.',
|
||||
credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.',
|
||||
asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.',
|
||||
asrTitle: 'ASR 음성(전사)',
|
||||
asrDesc: '구술을 실시간으로 텍스트로 전사합니다.',
|
||||
|
||||
@@ -289,6 +289,7 @@ export const zhCN = {
|
||||
llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。',
|
||||
providerLabel: '供应商',
|
||||
llmProviderDesc: '选择后将自动填入 Base URL 默认值。',
|
||||
credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。',
|
||||
asrProviderDesc: '切换后将自动选用对应凭据。',
|
||||
asrTitle: 'ASR 语音(转写)',
|
||||
asrDesc: '用于将口述实时转写为文本。',
|
||||
|
||||
@@ -291,6 +291,7 @@ export const zhTW: typeof zhCN = {
|
||||
llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。',
|
||||
providerLabel: '供應商',
|
||||
llmProviderDesc: '選擇後將自動填入 Base URL 默認值。',
|
||||
credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。',
|
||||
asrProviderDesc: '切換後將自動選用對應憑據。',
|
||||
asrTitle: 'ASR 語音(轉寫)',
|
||||
asrDesc: '用於將口述實時轉寫爲文本。',
|
||||
|
||||
@@ -508,6 +508,9 @@ function ProvidersSection() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
|
||||
{t('settings.providers.credentialStorageNotice')}
|
||||
</div>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.llmTitle')}</div>
|
||||
|
||||
Reference in new issue
Block a user