Skip to content

Commit e7beea5

Browse files
committed
Align credential storage with the implemented threat model
The Rust app currently writes provider credentials to a local plaintext JSON file, while repository guidance and file headers still described a Keychain-backed vault. This records the product choice explicitly, documents the local-file security boundary, adds a settings-page notice, and limits raw credential read/write IPC to the main window instead of exposing it to auxiliary windows by default. Constraint: Issue #230 asks to resolve the mismatch without leaving docs, implementation, and threat model contradictory. Rejected: Reintroduce platform Keychain storage | larger cross-platform migration and dependency change outside the minimal fix. Confidence: high Scope-risk: narrow Directive: Do not describe credentials as Keychain-backed unless the persistence implementation actually changes to a platform credential vault. Tested: npm run -s build; cargo check --manifest-path src-tauri/Cargo.toml; git diff --check; grep for stale Keychain-first wording. Not-tested: Manual Settings window credential editing in a packaged Tauri app. Related: #230
1 parent 7f1df50 commit e7beea5

11 files changed

Lines changed: 56 additions & 36 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS
6464
asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP
6565
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.)
6666
insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback
67-
persistence.rs History/preferences/vocab JSON + Keychain credentials
67+
persistence.rs History/preferences/vocab JSON + local credentials JSON
6868
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
6969
permissions.rs TCC checks (Accessibility / Microphone)
7070
@@ -91,9 +91,9 @@ Invariants:
9191

9292
### Permissions, credentials, on-disk state
9393

94-
- **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.
94+
- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json`. Changing it breaks existing TCC grants.
9595
- **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.
96-
- **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.
96+
- **Credentials** live in a local plaintext JSON file, not Keychain / Credential Manager / Secret Service. macOS / Linux use `~/.openless/credentials.json`; Windows uses `%APPDATA%\OpenLess\credentials.json`. Unix builds set the directory to `0700` and the file to `0600`, which reduces cross-user exposure but does not protect against same-user processes, backups, sync tools, or diagnostics. Never hard-code keys or include this file in logs, exports, build artifacts, or bug reports.
9797
- **Per-user data**:
9898
- 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).
9999
- Windows: `%APPDATA%\OpenLess\`

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS
6464
asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP
6565
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.)
6666
insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback
67-
persistence.rs History/preferences/vocab JSON + Keychain credentials
67+
persistence.rs History/preferences/vocab JSON + local credentials JSON
6868
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
6969
permissions.rs TCC checks (Accessibility / Microphone)
7070
@@ -91,9 +91,9 @@ Invariants:
9191

9292
### Permissions, credentials, on-disk state
9393

94-
- **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.
94+
- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json`. Changing it breaks existing TCC grants.
9595
- **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.
96-
- **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.
96+
- **Credentials** live in a local plaintext JSON file, not Keychain / Credential Manager / Secret Service. macOS / Linux use `~/.openless/credentials.json`; Windows uses `%APPDATA%\OpenLess\credentials.json`. Unix builds set the directory to `0700` and the file to `0600`, which reduces cross-user exposure but does not protect against same-user processes, backups, sync tools, or diagnostics. Never hard-code keys or include this file in logs, exports, build artifacts, or bug reports.
9797
- **Per-user data**:
9898
- 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).
9999
- Windows: `%APPDATA%\OpenLess\`

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,14 @@ Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\
195195

196196
## Credentials
197197

198-
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.
198+
OpenLess stores provider credentials in a local plaintext JSON file, not in Keychain / Credential Manager / Secret Service:
199199

200-
The repository contains no API keys, tokens, or private endpoints.
200+
```text
201+
macOS / Linux: ~/.openless/credentials.json # file 0600, parent dir 0700 on Unix
202+
Windows: %APPDATA%\OpenLess\credentials.json
203+
```
204+
205+
Those permissions reduce cross-user reads, but same-user processes, backups, sync tools, and diagnostics can still access the file. Do not include it in logs, exports, build artifacts, or bug reports. The repository contains no API keys, tokens, or private endpoints.
201206

202207
You'll need:
203208

@@ -247,7 +252,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback
247252
asr/ Volcengine streaming ASR (WebSocket) + Whisper HTTP
248253
polish.rs OpenAI-compatible chat-completions (Ark / DeepSeek / etc.)
249254
insertion.rs AX focused-element → clipboard + Cmd+V → copy-only fallback
250-
persistence.rs History / preferences / vocab JSON + Keychain credentials
255+
persistence.rs History / preferences / vocab JSON + local credentials JSON
251256
permissions.rs TCC checks (Accessibility / Microphone)
252257
coordinator.rs State machine: Idle → Starting → Listening → Processing
253258
commands.rs Tauri IPC surface

README.zh.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,13 +198,14 @@ npm run build
198198

199199
## 凭据
200200

201-
凭据保存在本机 Keychain(service = `com.openless.app`)。开发期同时维护一份明文 JSON 兜底,用于在 Keychain 不可用时回退
201+
OpenLess 将供应商凭据保存为本地明文 JSON 文件,不使用 Keychain / Credential Manager / Secret Service
202202

203203
```text
204-
~/.openless/credentials.json # 0600,目录 0700
204+
macOS / Linux: ~/.openless/credentials.json # Unix 下文件 0600,父目录 0700
205+
Windows: %APPDATA%\OpenLess\credentials.json
205206
```
206207

207-
仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。
208+
这些权限可以降低跨用户读取风险,但同一系统用户下的进程、备份、同步工具或诊断收集仍可能接触该文件。不要把它放进日志、导出包、构建产物或问题反馈。仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。
208209

209210
需要配置的字段:
210211

@@ -254,7 +255,7 @@ recorder.rs 麦克风 → 16 kHz 单声道 Int16 PCM,RMS 回调
254255
asr/ 火山引擎流式 ASR(WebSocket)+ Whisper HTTP
255256
polish.rs OpenAI 兼容 chat-completions(Ark / DeepSeek 等)
256257
insertion.rs AX focused-element → 剪贴板 + Cmd+V → 仅复制兜底
257-
persistence.rs 历史记录 / 偏好设置 / 词典 JSON + Keychain 凭据
258+
persistence.rs 历史记录 / 偏好设置 / 词典 JSON + 本地凭据 JSON
258259
permissions.rs TCC 权限检查(辅助功能 / 麦克风)
259260
coordinator.rs 状态机:Idle → Starting → Listening → Processing
260261
commands.rs Tauri IPC 接口

openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,7 @@ if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -o
597597
throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials."
598598
}
599599
if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) {
600-
Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault."
600+
Write-Warning "credentials.json is incomplete; continuing because credentials may be configured during the app session."
601601
}
602602

603603
$logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log"

openless-all/app/src-tauri/src/commands.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::time::Duration;
55

66
use serde::Serialize;
77
use serde_json::Value;
8-
use tauri::{AppHandle, Emitter, State};
8+
use tauri::{AppHandle, Emitter, State, Window};
99

1010
use crate::coordinator::Coordinator;
1111
use crate::permissions::{self, PermissionStatus};
@@ -143,7 +143,8 @@ fn configured(field: &Option<String>) -> bool {
143143
}
144144

145145
#[tauri::command]
146-
pub fn set_credential(account: String, value: String) -> Result<(), String> {
146+
pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> {
147+
ensure_main_window(&window)?;
147148
let acc = parse_account(&account)?;
148149
if value.is_empty() {
149150
CredentialsVault::remove(acc).map_err(|e| e.to_string())
@@ -173,13 +174,22 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> {
173174
}
174175

175176
/// 读出某个账号的实际值(用于设置页预填表单)。
176-
/// 与 Swift `CredentialsVault.get` 同语义,先 Keychain,缺则回落 ~/.openless/credentials.json
177+
/// 凭据以本地 JSON 文件保存;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露
177178
#[tauri::command]
178-
pub fn read_credential(account: String) -> Result<Option<String>, String> {
179+
pub fn read_credential(window: Window, account: String) -> Result<Option<String>, String> {
180+
ensure_main_window(&window)?;
179181
let acc = parse_account(&account)?;
180182
CredentialsVault::get(acc).map_err(|e| e.to_string())
181183
}
182184

185+
fn ensure_main_window(window: &Window) -> Result<(), String> {
186+
if window.label() == "main" {
187+
Ok(())
188+
} else {
189+
Err("credential access is only allowed from the main window".to_string())
190+
}
191+
}
192+
183193
#[derive(Serialize)]
184194
#[serde(rename_all = "camelCase")]
185195
pub struct ProviderCheckResult {

openless-all/app/src-tauri/src/persistence.rs

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
//! Local persistence: history JSON, user preferences JSON, vocab JSON, and
2-
//! Keychain-backed credentials vault.
2+
//! plaintext credentials JSON.
33
//!
44
//! Storage roots:
55
//! - macOS: `~/Library/Application Support/OpenLess`
66
//! - Windows: `%APPDATA%\OpenLess`
77
//! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess`
88
//!
9-
//! Divergence from Swift: the Swift `CredentialsVault` falls back to a JSON
10-
//! file (`~/.openless/credentials.json`) when Keychain is unavailable. The
11-
//! Rust port intentionally does NOT replicate that fallback — we rely solely
12-
//! on the platform keyring. The macOS service name (`com.openless.app`) is
13-
//! preserved so existing Keychain entries from the Swift app remain readable.
9+
//! Credential storage policy: the Tauri app stores provider credentials in a
10+
//! local JSON file, not in Keychain / Credential Manager / Secret Service.
11+
//! - macOS / Linux: `~/.openless/credentials.json`
12+
//! - Windows: `%APPDATA%\OpenLess\credentials.json`
13+
//! Unix builds set the parent directory to 0700 and the file to 0600. This
14+
//! protects against other OS users, but same-user processes, backups, sync
15+
//! tools, and diagnostics can still read plaintext secrets. Do not include the
16+
//! credentials file in exports, logs, or build artifacts.
1417
1518
use std::fs;
1619
use std::path::{Path, PathBuf};
@@ -32,9 +35,8 @@ const PREFERENCES_FILE: &str = "preferences.json";
3235
const VOCAB_FILE: &str = "dictionary.json";
3336
const VOCAB_PRESETS_FILE: &str = "vocab-presets.json";
3437

35-
/// Swift 老 `CredentialsVault` 的 JSON 备用路径。
36-
/// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件,
37-
/// 让用户在 Swift 版填过的凭据无需重输。
38+
/// 当前 Tauri 凭据 JSON 路径使用的目录 / 文件名。
39+
/// 这是唯一写入位置,不是 Keychain fallback。
3840
const LEGACY_CREDS_DIR: &str = ".openless";
3941
const LEGACY_CREDS_FILE: &str = "credentials.json";
4042

@@ -117,10 +119,8 @@ fn read_or_default<T: for<'de> Deserialize<'de> + Default>(path: &Path) -> Resul
117119

118120
// ───────────────────────── credentials JSON store ─────────────────────────
119121
//
120-
// 与 Swift `Sources/OpenLessPersistence/CredentialsVault.swift` 同源——纯 JSON 文件,
121-
// 路径 `~/.openless/credentials.json`,权限 0600。**故意不用 Keychain**:
122-
// ad-hoc 签名每次构建 hash 都变,Keychain ACL 失效后会触发逐账号弹框;用户已明确
123-
// 选择"直接写本地文件"。
122+
// 纯 JSON 凭据文件,非平台凭据库。路径见 credentials_path(),Unix 权限为
123+
// 目录 0700 / 文件 0600。该边界只防跨用户读取,不防同用户进程、备份或诊断收集。
124124
//
125125
// v1 schema:
126126
// {
@@ -636,9 +636,8 @@ pub enum CredentialAccount {
636636
}
637637

638638
impl CredentialAccount {
639-
/// Account names match the Swift `CredentialAccount` constants exactly so
640-
/// existing Keychain entries written by the macOS Swift app remain
641-
/// readable after upgrade.
639+
/// Stable account keys used inside credentials.json.
640+
/// The method name is kept for compatibility with older call sites; it no longer implies Keychain usage.
642641
pub fn keyring_account(&self) -> &'static str {
643642
match self {
644643
CredentialAccount::VolcengineAppKey => "volcengine.app_key",
@@ -686,8 +685,7 @@ pub struct CredentialsSnapshot {
686685
pub struct CredentialsVault;
687686

688687
impl CredentialsVault {
689-
/// 历史保留:Swift 时代以此名作为 Keychain service。Rust 不再使用 Keychain,
690-
/// 但暴露此常量给可能仍依赖它的代码点。
688+
/// 历史保留的服务名常量;Rust 凭据存储不使用 Keychain。
691689
pub const SERVICE_NAME: &'static str = "com.openless.app";
692690

693691
pub fn get(account: CredentialAccount) -> Result<Option<String>> {

openless-all/app/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ export const en: typeof zhCN = {
291291
llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.',
292292
providerLabel: 'Provider',
293293
llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.',
294+
credentialStorageNotice: 'Credentials are stored in a local plaintext JSON file. File permissions reduce cross-user access, but same-user processes and backups may still read it.',
294295
asrProviderDesc: 'Switching providers automatically loads the matching credentials.',
295296
asrTitle: 'ASR (transcription)',
296297
asrDesc: 'Used to turn speech into text in real time.',

openless-all/app/src/i18n/zh-CN.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ export const zhCN = {
289289
llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。',
290290
providerLabel: '供应商',
291291
llmProviderDesc: '选择后将自动填入 Base URL 默认值。',
292+
credentialStorageNotice: '凭据保存为本地明文 JSON 文件。文件权限会降低跨用户读取风险,但同一系统用户下的进程和备份仍可能读取。',
292293
asrProviderDesc: '切换后将自动选用对应凭据。',
293294
asrTitle: 'ASR 语音(转写)',
294295
asrDesc: '用于将口述实时转写为文本。',

openless-all/app/src/i18n/zh-TW.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ export const zhTW: typeof zhCN = {
291291
llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。',
292292
providerLabel: '供應商',
293293
llmProviderDesc: '選擇後將自動填入 Base URL 默認值。',
294+
credentialStorageNotice: '憑據儲存為本機明文 JSON 檔案。檔案權限會降低跨使用者讀取風險,但同一系統使用者下的程序和備份仍可能讀取。',
294295
asrProviderDesc: '切換後將自動選用對應憑據。',
295296
asrTitle: 'ASR 語音(轉寫)',
296297
asrDesc: '用於將口述實時轉寫爲文本。',

0 commit comments

Comments
 (0)