Skip to main content

dodot_lib/secret/
keychain.rs

1//! `keychain` provider — macOS Keychain integration via the
2//! `security` command (`/usr/bin/security`, ships with macOS).
3//!
4//! Reference shape: `keychain:<service>` or
5//! `keychain:<service>/<account>`.
6//!
7//! - `keychain:GitHub` → first item whose service name matches
8//!   `GitHub`, regardless of account. Useful when the keychain
9//!   item name is unique across all your stored credentials.
10//! - `keychain:GitHub/alice` → exact (service, account) pair.
11//!   Use this shape when you have multiple credentials per
12//!   service (work + personal accounts).
13//!
14//! Resolution: `security find-generic-password -s <service>
15//! [-a <account>] -w` — `-w` makes `security` print only the
16//! password on stdout (no surrounding metadata), exit 0 on
17//! success, exit 44 with a `SecKeychainSearchCopyNext` stderr
18//! line when the item isn't found.
19//!
20//! Auth model: the user's *login keychain* is unlocked by
21//! default at session start. dodot does NOT call `security
22//! unlock-keychain` — that would either need the user's password
23//! or skip the prompt entirely (security risk). When the
24//! keychain is locked, `security find-generic-password` returns
25//! exit 51 with a `User interaction is not allowed` stderr line
26//! and we surface that as a `NotAuthenticated` probe.
27//!
28//! Platform: macOS only. On Linux / WSL the `security` binary
29//! isn't on PATH and `probe()` returns `NotInstalled` with a
30//! "use secret-tool instead" pointer.
31//!
32//! See `secrets.lex` §5.2 / §5.4 (provider table + error UX) and
33//! §S4 (OS-level providers).
34
35use std::sync::Arc;
36
37use crate::datastore::CommandRunner;
38use crate::secret::provider::{ProbeResult, SecretProvider};
39use crate::secret::secret_string::SecretString;
40use crate::{DodotError, Result};
41
42/// `SecretProvider` impl for the macOS Keychain via the
43/// `security` command. Holds a `CommandRunner` for subprocess
44/// invocations; tests substitute a `ScriptedRunner` to mock
45/// `security` without touching the real keychain.
46pub struct KeychainProvider {
47    runner: Arc<dyn CommandRunner>,
48}
49
50impl KeychainProvider {
51    pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
52        Self { runner }
53    }
54
55    pub fn from_env(runner: Arc<dyn CommandRunner>) -> Self {
56        Self::new(runner)
57    }
58
59    /// Parse the post-prefix reference into `(service,
60    /// Option<account>)`. The registry has already stripped
61    /// `keychain:`. Empty references and trailing slashes are
62    /// rejected up front.
63    fn parse_reference(suffix: &str) -> Result<(&str, Option<&str>)> {
64        if suffix.is_empty() {
65            return Err(DodotError::Other(
66                "keychain reference is empty. Expected `keychain:<service>[/<account>]`.".into(),
67            ));
68        }
69        let (service, account) = match suffix.split_once('/') {
70            Some((s, a)) => (s, Some(a)),
71            None => (suffix, None),
72        };
73        if service.is_empty() {
74            return Err(DodotError::Other(format!(
75                "keychain reference `keychain:{suffix}` has an empty service name."
76            )));
77        }
78        if let Some(a) = account {
79            if a.is_empty() {
80                return Err(DodotError::Other(format!(
81                    "keychain reference `keychain:{suffix}` has an empty account name. \
82                     Either drop the trailing `/` (use `keychain:<service>` for a \
83                     service-only lookup) or supply an account: `keychain:<service>/<account>`."
84                )));
85            }
86        }
87        Ok((service, account))
88    }
89}
90
91impl SecretProvider for KeychainProvider {
92    fn scheme(&self) -> &str {
93        "keychain"
94    }
95
96    fn probe(&self) -> ProbeResult {
97        // Step 1: binary on PATH? On macOS `/usr/bin/security`
98        // is part of the base system, so a missing binary is
99        // almost always "this is a non-macOS host".
100        match self.runner.run("security", &["-h".into()]) {
101            Ok(_) => {}
102            Err(_) => {
103                return ProbeResult::NotInstalled {
104                    // Note the underscore in the TOML key
105                    // (`secret_tool`) vs. the hyphen in the
106                    // scheme prefix (`secret-tool:` in
107                    // references) — see `scheme_to_config_key`.
108                    hint: "the `security` command is macOS-only. \
109                           On Linux / WSL, use the `secret-tool` provider instead \
110                           (`[secret.providers.secret_tool] enabled = true`)."
111                        .into(),
112                };
113            }
114        }
115        // Step 2: keychain accessibility. We don't have a known
116        // item to look up at probe time, so we use a lightweight
117        // sanity check: `security default-keychain` returns the
118        // user's default keychain path on success and a
119        // diagnostic on failure. Doesn't unlock anything, doesn't
120        // require any pre-existing items.
121        match self.runner.run("security", &["default-keychain".into()]) {
122            Ok(out) if out.exit_code == 0 => ProbeResult::Ok,
123            Ok(_) => ProbeResult::ProbeFailed {
124                details: "`security default-keychain` returned non-zero — \
125                          the binary is on PATH but no default keychain is \
126                          configured. Run `security login-keychain` to inspect."
127                    .into(),
128            },
129            Err(_) => ProbeResult::ProbeFailed {
130                details: "could not run `security default-keychain` after a \
131                          successful `security -h`; intermittent subprocess failure"
132                    .into(),
133            },
134        }
135    }
136
137    fn resolve(&self, reference: &str) -> Result<SecretString> {
138        let (service, account) = Self::parse_reference(reference)?;
139        let mut args: Vec<String> =
140            vec!["find-generic-password".into(), "-s".into(), service.into()];
141        if let Some(a) = account {
142            args.push("-a".into());
143            args.push(a.into());
144        }
145        // `-w` prints just the password on stdout — without it
146        // `security` dumps the full attribute table.
147        args.push("-w".into());
148
149        let out = self.runner.run("security", &args)?;
150        if out.exit_code != 0 {
151            let stderr = out.stderr.trim();
152            // `security`'s exit codes for find-generic-password
153            // are stable: 44 = not found, 51 = user interaction
154            // not allowed (typically a locked keychain in a
155            // non-interactive context).
156            let err_msg = if out.exit_code == 44 || stderr.contains("could not be found") {
157                let qualifier = match account {
158                    Some(a) => format!("(service `{service}`, account `{a}`)"),
159                    None => format!("(service `{service}`)"),
160                };
161                format!(
162                    "secret `keychain:{reference}` not found in the keychain {qualifier}. \
163                     Verify with `security find-generic-password -s '{service}'`{} \
164                     -- or add the item via Keychain Access.app / \
165                     `security add-generic-password -s '{service}' [-a '<account>'] -w '<password>'`.",
166                    account
167                        .map(|a| format!(" -a '{a}'"))
168                        .unwrap_or_default(),
169                )
170            } else if out.exit_code == 51
171                || stderr.contains("User interaction is not allowed")
172                || stderr.contains("locked")
173            {
174                format!(
175                    "secret resolution for `keychain:{reference}` failed: \
176                     the keychain is locked or interaction is not allowed. \
177                     Unlock the login keychain (e.g. by signing in / opening \
178                     Keychain Access.app) and re-run dodot."
179                )
180            } else if stderr.is_empty() {
181                format!(
182                    "`security find-generic-password` exited with code {} \
183                     (no diagnostic output)",
184                    out.exit_code
185                )
186            } else {
187                // `security` prints diagnostics to stderr; the
188                // password (`-w` mode) only goes to stdout, so
189                // surfacing stderr verbatim is safe.
190                format!(
191                    "`security find-generic-password` failed (exit {}): {stderr}",
192                    out.exit_code
193                )
194            };
195            return Err(DodotError::Other(err_msg));
196        }
197        // `security -w` emits the password followed by a single
198        // trailing newline. Strip exactly one — same contract as
199        // the other providers (op / bw / pass).
200        let mut value = out.stdout;
201        if value.ends_with('\n') {
202            value.pop();
203        }
204        Ok(SecretString::new(value))
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::datastore::CommandOutput;
212    use std::sync::Mutex;
213
214    type ScriptedResponse = (
215        String,
216        Vec<String>,
217        std::result::Result<CommandOutput, String>,
218    );
219
220    struct ScriptedRunner {
221        responses: Mutex<Vec<ScriptedResponse>>,
222    }
223    impl ScriptedRunner {
224        fn new() -> Self {
225            Self {
226                responses: Mutex::new(Vec::new()),
227            }
228        }
229        fn expect(
230            self,
231            exe: impl Into<String>,
232            args: Vec<String>,
233            response: std::result::Result<CommandOutput, String>,
234        ) -> Self {
235            self.responses
236                .lock()
237                .unwrap()
238                .push((exe.into(), args, response));
239            self
240        }
241    }
242    impl CommandRunner for ScriptedRunner {
243        fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
244            let mut r = self.responses.lock().unwrap();
245            if r.is_empty() {
246                return Err(DodotError::Other(format!(
247                    "ScriptedRunner: unexpected `{exe} {args:?}`"
248                )));
249            }
250            let (e, a, out) = r.remove(0);
251            assert_eq!(exe, e);
252            assert_eq!(args, a.as_slice());
253            out.map_err(DodotError::Other)
254        }
255    }
256    fn ok(stdout: &str) -> std::result::Result<CommandOutput, String> {
257        Ok(CommandOutput {
258            exit_code: 0,
259            stdout: stdout.into(),
260            stderr: String::new(),
261        })
262    }
263    fn err_out(exit: i32, stderr: &str) -> std::result::Result<CommandOutput, String> {
264        Ok(CommandOutput {
265            exit_code: exit,
266            stdout: String::new(),
267            stderr: stderr.into(),
268        })
269    }
270
271    // ── parse_reference ─────────────────────────────────────────
272
273    #[test]
274    fn parse_reference_service_only() {
275        let (s, a) = KeychainProvider::parse_reference("GitHub").unwrap();
276        assert_eq!(s, "GitHub");
277        assert_eq!(a, None);
278    }
279
280    #[test]
281    fn parse_reference_service_and_account() {
282        let (s, a) = KeychainProvider::parse_reference("GitHub/alice").unwrap();
283        assert_eq!(s, "GitHub");
284        assert_eq!(a, Some("alice"));
285    }
286
287    #[test]
288    fn parse_reference_rejects_empty_suffix() {
289        let e = KeychainProvider::parse_reference("")
290            .unwrap_err()
291            .to_string();
292        assert!(e.contains("empty"));
293    }
294
295    #[test]
296    fn parse_reference_rejects_empty_service() {
297        let e = KeychainProvider::parse_reference("/alice")
298            .unwrap_err()
299            .to_string();
300        assert!(e.contains("empty service"));
301    }
302
303    #[test]
304    fn parse_reference_rejects_trailing_slash() {
305        // Trailing slash with no account is a typo — guide the
306        // user back to the service-only shape.
307        let e = KeychainProvider::parse_reference("GitHub/")
308            .unwrap_err()
309            .to_string();
310        assert!(e.contains("empty account"));
311        assert!(e.contains("drop the trailing"));
312    }
313
314    // ── probe ───────────────────────────────────────────────────
315
316    #[test]
317    fn probe_ok_when_security_present_and_default_keychain_resolves() {
318        let runner = Arc::new(
319            ScriptedRunner::new()
320                .expect("security", vec!["-h".into()], ok(""))
321                .expect(
322                    "security",
323                    vec!["default-keychain".into()],
324                    ok("    \"/Users/x/Library/Keychains/login.keychain-db\"\n"),
325                ),
326        );
327        let p = KeychainProvider::new(runner);
328        assert!(matches!(p.probe(), ProbeResult::Ok));
329    }
330
331    #[test]
332    fn probe_not_installed_when_runner_errors() {
333        // On Linux / WSL `security` isn't on PATH at all;
334        // ShellCommandRunner returns Err("command not found").
335        let runner = Arc::new(ScriptedRunner::new().expect(
336            "security",
337            vec!["-h".into()],
338            Err("command not found: security".into()),
339        ));
340        let p = KeychainProvider::new(runner);
341        match p.probe() {
342            ProbeResult::NotInstalled { hint } => {
343                assert!(hint.contains("macOS-only"));
344                assert!(hint.contains("secret-tool"));
345            }
346            other => panic!("expected NotInstalled, got {other:?}"),
347        }
348    }
349
350    #[test]
351    fn probe_failed_when_default_keychain_returns_nonzero() {
352        let runner = Arc::new(
353            ScriptedRunner::new()
354                .expect("security", vec!["-h".into()], ok(""))
355                .expect(
356                    "security",
357                    vec!["default-keychain".into()],
358                    err_out(50, "no default keychain"),
359                ),
360        );
361        let p = KeychainProvider::new(runner);
362        assert!(matches!(p.probe(), ProbeResult::ProbeFailed { .. }));
363    }
364
365    // ── resolve ─────────────────────────────────────────────────
366
367    #[test]
368    fn resolve_service_only_invokes_find_generic_password_correctly() {
369        let runner = Arc::new(ScriptedRunner::new().expect(
370            "security",
371            vec![
372                "find-generic-password".into(),
373                "-s".into(),
374                "GitHub".into(),
375                "-w".into(),
376            ],
377            ok("ghp_abc123\n"),
378        ));
379        let p = KeychainProvider::new(runner);
380        let v = p.resolve("GitHub").unwrap();
381        assert_eq!(v.expose().unwrap(), "ghp_abc123");
382    }
383
384    #[test]
385    fn resolve_with_account_threads_account_into_args() {
386        let runner = Arc::new(ScriptedRunner::new().expect(
387            "security",
388            vec![
389                "find-generic-password".into(),
390                "-s".into(),
391                "GitHub".into(),
392                "-a".into(),
393                "alice".into(),
394                "-w".into(),
395            ],
396            ok("alice-token\n"),
397        ));
398        let p = KeychainProvider::new(runner);
399        let v = p.resolve("GitHub/alice").unwrap();
400        assert_eq!(v.expose().unwrap(), "alice-token");
401    }
402
403    #[test]
404    fn resolve_maps_exit_44_to_not_found_with_actionable_hint() {
405        let runner = Arc::new(ScriptedRunner::new().expect(
406            "security",
407            vec![
408                "find-generic-password".into(),
409                "-s".into(),
410                "missing".into(),
411                "-w".into(),
412            ],
413            err_out(
414                44,
415                "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.",
416            ),
417        ));
418        let p = KeychainProvider::new(runner);
419        let e = p.resolve("missing").unwrap_err().to_string();
420        assert!(e.contains("not found"));
421        assert!(e.contains("`missing`"));
422        assert!(e.contains("security add-generic-password"));
423    }
424
425    #[test]
426    fn resolve_not_found_qualifier_includes_account_when_provided() {
427        let runner = Arc::new(ScriptedRunner::new().expect(
428            "security",
429            vec![
430                "find-generic-password".into(),
431                "-s".into(),
432                "GitHub".into(),
433                "-a".into(),
434                "missing".into(),
435                "-w".into(),
436            ],
437            err_out(44, "could not be found"),
438        ));
439        let p = KeychainProvider::new(runner);
440        let e = p.resolve("GitHub/missing").unwrap_err().to_string();
441        assert!(e.contains("`GitHub`"));
442        assert!(e.contains("`missing`"));
443        assert!(e.contains("-a 'missing'"));
444    }
445
446    #[test]
447    fn resolve_maps_exit_51_to_locked_keychain_diagnostic() {
448        let runner = Arc::new(ScriptedRunner::new().expect(
449            "security",
450            vec![
451                "find-generic-password".into(),
452                "-s".into(),
453                "GitHub".into(),
454                "-w".into(),
455            ],
456            err_out(51, "security: User interaction is not allowed."),
457        ));
458        let p = KeychainProvider::new(runner);
459        let e = p.resolve("GitHub").unwrap_err().to_string();
460        assert!(e.contains("locked or interaction is not allowed"));
461        assert!(e.contains("Unlock"));
462    }
463
464    #[test]
465    fn resolve_passes_through_unrecognized_stderr() {
466        let runner = Arc::new(ScriptedRunner::new().expect(
467            "security",
468            vec![
469                "find-generic-password".into(),
470                "-s".into(),
471                "GitHub".into(),
472                "-w".into(),
473            ],
474            err_out(1, "weird internal failure"),
475        ));
476        let p = KeychainProvider::new(runner);
477        let e = p.resolve("GitHub").unwrap_err().to_string();
478        assert!(e.contains("weird internal failure"));
479        assert!(e.contains("exit 1"));
480    }
481
482    #[test]
483    fn resolve_strips_exactly_one_trailing_newline() {
484        let runner = Arc::new(ScriptedRunner::new().expect(
485            "security",
486            vec![
487                "find-generic-password".into(),
488                "-s".into(),
489                "k".into(),
490                "-w".into(),
491            ],
492            ok("value-with-trailing-blank\n\n"),
493        ));
494        let p = KeychainProvider::new(runner);
495        let v = p.resolve("k").unwrap();
496        assert_eq!(v.expose().unwrap(), "value-with-trailing-blank\n");
497    }
498}