bob_rs/keychain.rs
1//! OS-keychain storage for the Bob API key.
2//!
3//! Two-layer design:
4//!
5//! 1. **The key value** lives in the OS keychain (macOS Keychain
6//! Access / libsecret / Windows Credential Vault). Reads
7//! require the OS to confirm the calling app has permission
8//! — which on macOS triggers a user password prompt the first
9//! time a new binary (or any process that didn't create the
10//! entry) touches the item. That prompt is acceptable when
11//! it's triggered by a deliberate user action (the user just
12//! clicked "Save key" or "Send chat"). It is **not**
13//! acceptable at app boot.
14//!
15//! 2. **A marker file** at `<app-data-dir>/auth_state.json`
16//! records whether the user has *ever* saved a key. The file
17//! contains no secret material — just `{"hasKey": true,
18//! "source": "keychain"|"env"}`. Reading it is a plain
19//! `fs::read`; no OS prompt. The boot-time readiness check
20//! uses this to decide whether to show "API key not
21//! connected" without touching the keychain.
22//!
23//! This separation is what keeps the **app launch path
24//! prompt-free**. The keychain prompt now fires only when the
25//! user takes an explicit action (saving a key, running bob).
26//!
27//! Lookup precedence for the actual value (`resolve_api_key`):
28//! 1. `BOBSHELL_API_KEY` env var (`.env` or shell-exported)
29//! 2. OS keychain entry
30//!
31//! Writes always go to BOTH the keychain (value) and the marker
32//! file (existence flag). Deletes clear both.
33
34use crate::error::BobError;
35use crate::{KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
36use keyring::Entry;
37use serde::{Deserialize, Serialize};
38use std::path::PathBuf;
39use std::sync::Mutex;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub enum KeySource {
44 Env,
45 Keychain,
46}
47
48/// On-disk shape of the marker file. Intentionally minimal — only
49/// the fields needed to drive the readiness UI without touching
50/// the keychain. Versioned via `schema` so future fields don't
51/// break older readers.
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53struct AuthState {
54 /// Bump if we add fields that older binaries can't ignore.
55 #[serde(default = "default_schema")]
56 schema: u32,
57 /// True iff the user has saved a key via `write_api_key`.
58 /// `.env` keys do NOT set this to true (env is the dev
59 /// override; we let the env detection happen at read time).
60 #[serde(default)]
61 has_keychain_key: bool,
62}
63
64fn default_schema() -> u32 {
65 1
66}
67
68/// Filesystem location for the marker file. Uses the platform's
69/// standard app-data dir under the Compose bundle id so the
70/// file lives next to the Tauri-managed sqlite, logs, etc.
71fn auth_state_path() -> Option<PathBuf> {
72 // `dirs::data_dir()` returns:
73 // * macOS: ~/Library/Application Support
74 // * Linux: $XDG_DATA_HOME or ~/.local/share
75 // * Windows: %APPDATA%
76 // The bundle id matches `tauri.conf.json::identifier`.
77 Some(dirs::data_dir()?.join("com.compose.app").join("auth_state.json"))
78}
79
80fn read_auth_state() -> AuthState {
81 let Some(path) = auth_state_path() else {
82 return AuthState::default();
83 };
84 let Ok(bytes) = std::fs::read(&path) else {
85 return AuthState::default();
86 };
87 serde_json::from_slice::<AuthState>(&bytes).unwrap_or_default()
88}
89
90fn write_auth_state(state: &AuthState) -> Result<(), BobError> {
91 let path = auth_state_path().ok_or(BobError::NoDataDir)?;
92 if let Some(parent) = path.parent() {
93 std::fs::create_dir_all(parent).map_err(|source| BobError::Io {
94 context: "create auth-state directory",
95 source,
96 })?;
97 }
98 let bytes = serde_json::to_vec_pretty(state)?;
99 std::fs::write(&path, bytes).map_err(|source| BobError::Io {
100 context: "write auth-state marker",
101 source,
102 })
103}
104
105/// Process-wide cache of the resolved key. Populated on first
106/// successful keychain read so subsequent `resolve_api_key()`
107/// calls never re-touch the keychain (and therefore never re-
108/// trigger the macOS "Allow this app to access ..." prompt when
109/// the binary identity isn't yet on the entry's ACL).
110///
111/// Invariants:
112/// * Cleared on `write_api_key` (user saved a new value)
113/// * Cleared on `delete_api_key` (user disconnected)
114/// * Lives for the duration of the process — restarting the
115/// app re-reads the keychain (which is fine: the app's own
116/// code-signing identity is then the entry creator, so no
117/// prompt).
118static KEY_CACHE: Mutex<Option<(String, KeySource)>> = Mutex::new(None);
119
120fn cache_read() -> Option<(String, KeySource)> {
121 KEY_CACHE.lock().ok().and_then(|guard| guard.clone())
122}
123
124fn cache_write(value: String, source: KeySource) {
125 if let Ok(mut guard) = KEY_CACHE.lock() {
126 *guard = Some((value, source));
127 }
128}
129
130fn cache_clear() {
131 if let Ok(mut guard) = KEY_CACHE.lock() {
132 *guard = None;
133 }
134}
135
136/// Cheap existence + source probe. Reads the marker file and the
137/// env var; never touches the OS keychain. Use this at app boot.
138///
139/// Returns `None` if no key is configured anywhere.
140pub fn auth_source() -> Option<KeySource> {
141 // Env wins — that's the dev override and it never prompts.
142 if let Ok(value) = std::env::var("BOBSHELL_API_KEY") {
143 if !value.is_empty() {
144 return Some(KeySource::Env);
145 }
146 }
147 if read_auth_state().has_keychain_key {
148 Some(KeySource::Keychain)
149 } else {
150 None
151 }
152}
153
154/// Read the API key from the OS keychain. **This triggers the
155/// macOS user-confirmation prompt** the first time a non-creator
156/// process accesses the entry — only call when you actually need
157/// the value (saving, running bob), never at boot.
158///
159/// Returns `None` if no entry exists or the keychain rejects the
160/// read (locked, missing libsecret).
161pub fn read_api_key() -> Option<String> {
162 let entry = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).ok()?;
163 match entry.get_password() {
164 Ok(value) if !value.trim().is_empty() => Some(value),
165 _ => None,
166 }
167}
168
169/// Write the API key to the OS keychain AND set the marker file.
170/// The keychain write is what triggers a one-time macOS prompt
171/// the first time the user saves a key; subsequent overwrites by
172/// the same app are silent.
173pub fn write_api_key(key: &str) -> Result<(), BobError> {
174 if key.trim().is_empty() {
175 return Err(BobError::Invalid("API key must be non-empty".to_owned()));
176 }
177 let entry = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
178 entry.set_password(key)?;
179 // Mark the existence so future boots don't need to touch the
180 // keychain to know the user has a key.
181 write_auth_state(&AuthState {
182 schema: 1,
183 has_keychain_key: true,
184 })?;
185 // Cache the freshly-written value so the next bob spawn
186 // doesn't pay another keychain prompt. The user just gave
187 // us this exact byte string — we can trust it.
188 cache_write(key.to_owned(), KeySource::Keychain);
189 Ok(())
190}
191
192/// Remove the keychain entry and clear the marker. No-op for
193/// each step if the corresponding state isn't there.
194pub fn delete_api_key() -> Result<(), BobError> {
195 if let Ok(entry) = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
196 match entry.delete_credential() {
197 Ok(()) | Err(keyring::Error::NoEntry) => {}
198 Err(err) => return Err(BobError::Keychain(err)),
199 }
200 }
201 write_auth_state(&AuthState {
202 schema: 1,
203 has_keychain_key: false,
204 })
205 .ok();
206 // Clear the in-memory cache so the next bob spawn realises
207 // the user disconnected (and we surface "API key not
208 // configured" rather than the now-stale cached value).
209 cache_clear();
210 Ok(())
211}
212
213/// Resolve the effective key with env-then-keychain precedence.
214/// Used by `run::spawn_bob` to inject the key into the child env.
215///
216/// The first successful resolution is **cached for the lifetime
217/// of the process** so a flurry of bob runs (or just rapid
218/// successive Send clicks) only ever triggers at most one OS
219/// keychain prompt per session, no matter how many times this
220/// function is called. The cache invalidates whenever the user
221/// saves a new key or disconnects — see `write_api_key` /
222/// `delete_api_key`.
223pub fn resolve_api_key() -> Option<(String, KeySource)> {
224 if let Some(cached) = cache_read() {
225 return Some(cached);
226 }
227 // Env wins — never hits the keychain in this branch.
228 if let Ok(value) = std::env::var("BOBSHELL_API_KEY") {
229 if !value.is_empty() {
230 cache_write(value.clone(), KeySource::Env);
231 return Some((value, KeySource::Env));
232 }
233 }
234 // Keychain read — this is the path that may prompt the OS
235 // user on first access by a new binary identity. We do it
236 // at most once per process thanks to the cache above.
237 let value = read_api_key()?;
238 cache_write(value.clone(), KeySource::Keychain);
239 Some((value, KeySource::Keychain))
240}