Skip to main content

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}