Skip to main content

ssh_commander_core/
keychain.rs

1//! macOS Keychain integration for SSH / SFTP / FTP credentials.
2//!
3//! The frontend stores passwords and key passphrases in the system Keychain
4//! instead of re-sending them on every connect. Each credential is keyed by a
5//! `(service, account)` pair where `service` is derived from [`CredentialKind`]
6//! and `account` is an opaque string chosen by the caller (typically
7//! `"<username>@<host>:<port>"`).
8//!
9//! On platforms without a Keychain:
10//! - `save_password` / `delete_password` return a "not supported" error
11//! - `load_password` returns `Ok(None)` so a "no saved credential" flow is
12//!   indistinguishable from "no Keychain exists", letting the UI fall back to
13//!   a password prompt gracefully.
14//!
15//! Secrets are held as `String` at the boundary and converted to `&[u8]` for
16//! the Keychain API. They must never be logged — callers and this module use
17//! `tracing` only to report the non-sensitive `(service, account)` pair.
18
19use anyhow::Result;
20use serde::{Deserialize, Serialize};
21
22/// Kinds of credential we persist. Serialised in snake_case on the wire so the
23/// frontend can emit e.g. `{"kind": "ssh_password"}`.
24///
25/// Each variant maps to a fixed Keychain service string prefixed with
26/// `com.r-shell.` so the entries are easy to identify in Keychain Access.app
27/// and distinct from credentials stored by other applications.
28#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum CredentialKind {
31    SshPassword,
32    SshKeyPassphrase,
33    SftpPassword,
34    SftpKeyPassphrase,
35    FtpPassword,
36    PostgresPassword,
37}
38
39impl CredentialKind {
40    /// The Keychain `kSecAttrService` string associated with this kind.
41    pub fn service(self) -> &'static str {
42        match self {
43            CredentialKind::SshPassword => "com.r-shell.ssh.password",
44            CredentialKind::SshKeyPassphrase => "com.r-shell.ssh.passphrase",
45            CredentialKind::SftpPassword => "com.r-shell.sftp.password",
46            CredentialKind::SftpKeyPassphrase => "com.r-shell.sftp.passphrase",
47            CredentialKind::FtpPassword => "com.r-shell.ftp.password",
48            CredentialKind::PostgresPassword => "com.r-shell.postgres.password",
49        }
50    }
51
52    /// Short human label used in Keychain Access.app when a credential is
53    /// first saved. Paired with the account string to form the full entry
54    /// name the user sees.
55    pub fn friendly_label(self) -> &'static str {
56        match self {
57            CredentialKind::SshPassword => "SSH password",
58            CredentialKind::SshKeyPassphrase => "SSH key passphrase",
59            CredentialKind::SftpPassword => "SFTP password",
60            CredentialKind::SftpKeyPassphrase => "SFTP key passphrase",
61            CredentialKind::FtpPassword => "FTP password",
62            CredentialKind::PostgresPassword => "Postgres password",
63        }
64    }
65}
66
67/// Whether this build can actually read / write the OS keychain.
68/// The frontend uses this to hide "Save to Keychain" UI on unsupported
69/// platforms instead of letting the save call error at runtime.
70pub fn is_supported() -> bool {
71    cfg!(target_os = "macos")
72}
73
74// =============================================================================
75// macOS implementation — real Keychain access via `security-framework`.
76// =============================================================================
77#[cfg(target_os = "macos")]
78mod platform {
79    use super::*;
80    use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
81    use security_framework::passwords::{
82        PasswordOptions, delete_generic_password, get_generic_password, set_generic_password,
83        set_generic_password_options,
84    };
85    use security_framework_sys::base::errSecItemNotFound;
86
87    pub fn save_password(kind: CredentialKind, account: &str, secret: &str) -> Result<()> {
88        // Probe first: if an entry exists, just update its password so the
89        // user's custom label/comment edits (made in Keychain Access.app)
90        // aren't clobbered. If it doesn't exist, create it with friendly
91        // attributes so it's easy to identify / audit.
92        match get_generic_password(kind.service(), account) {
93            Ok(_) => {
94                set_generic_password(kind.service(), account, secret.as_bytes()).map_err(|e| {
95                    anyhow::anyhow!(
96                        "keychain update failed for {}/{}: {}",
97                        kind.service(),
98                        account,
99                        e
100                    )
101                })
102            }
103            Err(e) if e.code() == errSecItemNotFound => {
104                let mut options = PasswordOptions::new_generic_password(kind.service(), account);
105                // Label: shown as "Name" in Keychain Access.app.
106                options.set_label(&format!("r-shell: {} ({})", kind.friendly_label(), account));
107                // Comment: provenance for the user and any auditor who opens
108                // the entry in Keychain Access.
109                options.set_comment(
110                    "Saved by r-shell. Remove from r-shell → Settings → Security → Saved Credentials, \
111                     or delete here to force a re-prompt on the next connect.",
112                );
113                // Do not sync to iCloud Keychain — credentials for a specific
114                // desktop device shouldn't roam.
115                options.set_access_synchronized(Some(false));
116                set_generic_password_options(secret.as_bytes(), options).map_err(|e| {
117                    anyhow::anyhow!(
118                        "keychain create failed for {}/{}: {}",
119                        kind.service(),
120                        account,
121                        e
122                    )
123                })
124            }
125            Err(e) => Err(anyhow::anyhow!(
126                "keychain pre-save probe failed for {}/{}: {}",
127                kind.service(),
128                account,
129                e
130            )),
131        }
132    }
133
134    pub fn list_accounts(kind: CredentialKind) -> Result<Vec<String>> {
135        let results = match ItemSearchOptions::new()
136            .class(ItemClass::generic_password())
137            .service(kind.service())
138            .load_attributes(true)
139            .limit(Limit::All)
140            .search()
141        {
142            Ok(r) => r,
143            // errSecItemNotFound just means "no entries for this service" —
144            // return an empty list, don't propagate the error.
145            Err(e) if e.code() == errSecItemNotFound => return Ok(Vec::new()),
146            Err(e) => {
147                return Err(anyhow::anyhow!(
148                    "keychain list failed for {}: {}",
149                    kind.service(),
150                    e
151                ));
152            }
153        };
154
155        let mut accounts = Vec::with_capacity(results.len());
156        for r in results {
157            if let Some(attrs) = r.simplify_dict() {
158                // "acct" is the string-form key for kSecAttrAccount in the
159                // simplified dictionary returned by security-framework.
160                if let Some(account) = attrs.get("acct") {
161                    accounts.push(account.clone());
162                }
163            }
164        }
165        accounts.sort();
166        accounts.dedup();
167        Ok(accounts)
168    }
169
170    pub fn load_password(kind: CredentialKind, account: &str) -> Result<Option<String>> {
171        match get_generic_password(kind.service(), account) {
172            Ok(bytes) => {
173                let s = String::from_utf8(bytes).map_err(|_| {
174                    anyhow::anyhow!(
175                        "keychain item {}/{} is not valid UTF-8",
176                        kind.service(),
177                        account
178                    )
179                })?;
180                Ok(Some(s))
181            }
182            Err(e) if e.code() == errSecItemNotFound => Ok(None),
183            Err(e) => Err(anyhow::anyhow!(
184                "keychain load failed for {}/{}: {}",
185                kind.service(),
186                account,
187                e
188            )),
189        }
190    }
191
192    pub fn delete_password(kind: CredentialKind, account: &str) -> Result<()> {
193        // Idempotent: deleting a nonexistent item is not an error.
194        match delete_generic_password(kind.service(), account) {
195            Ok(()) => Ok(()),
196            Err(e) if e.code() == errSecItemNotFound => Ok(()),
197            Err(e) => Err(anyhow::anyhow!(
198                "keychain delete failed for {}/{}: {}",
199                kind.service(),
200                account,
201                e
202            )),
203        }
204    }
205}
206
207// =============================================================================
208// Non-macOS stubs
209// =============================================================================
210#[cfg(not(target_os = "macos"))]
211mod platform {
212    use super::*;
213
214    pub fn save_password(_kind: CredentialKind, _account: &str, _secret: &str) -> Result<()> {
215        Err(anyhow::anyhow!(
216            "Keychain integration is only supported on macOS"
217        ))
218    }
219
220    pub fn load_password(_kind: CredentialKind, _account: &str) -> Result<Option<String>> {
221        // Report "no saved credential" so the UI can gracefully fall back to
222        // prompting for the password rather than surfacing an error.
223        Ok(None)
224    }
225
226    pub fn delete_password(_kind: CredentialKind, _account: &str) -> Result<()> {
227        Err(anyhow::anyhow!(
228            "Keychain integration is only supported on macOS"
229        ))
230    }
231
232    pub fn list_accounts(_kind: CredentialKind) -> Result<Vec<String>> {
233        // On non-macOS there are no entries to list — report an empty set
234        // rather than an error so the Settings UI renders gracefully.
235        Ok(Vec::new())
236    }
237}
238
239pub fn save_password(kind: CredentialKind, account: &str, secret: &str) -> Result<()> {
240    tracing::info!(
241        "keychain save: service={}, account={}",
242        kind.service(),
243        account
244    );
245    platform::save_password(kind, account, secret)
246}
247
248pub fn load_password(kind: CredentialKind, account: &str) -> Result<Option<String>> {
249    let result = platform::load_password(kind, account);
250    tracing::debug!(
251        "keychain load: service={}, account={}, found={}",
252        kind.service(),
253        account,
254        matches!(&result, Ok(Some(_)))
255    );
256    result
257}
258
259pub fn delete_password(kind: CredentialKind, account: &str) -> Result<()> {
260    tracing::info!(
261        "keychain delete: service={}, account={}",
262        kind.service(),
263        account
264    );
265    platform::delete_password(kind, account)
266}
267
268/// List all accounts stored under a given kind's service. Returns an empty
269/// vector (not an error) when no entries exist or the platform has no
270/// keychain. Useful for the Settings UI to show the user what's saved.
271pub fn list_accounts(kind: CredentialKind) -> Result<Vec<String>> {
272    let result = platform::list_accounts(kind);
273    tracing::debug!(
274        "keychain list: service={}, count={}",
275        kind.service(),
276        result.as_ref().map(|v| v.len()).unwrap_or(0)
277    );
278    result
279}
280
281// =============================================================================
282// Tests
283// =============================================================================
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn service_strings_are_stable_and_unique() {
290        let kinds = [
291            CredentialKind::SshPassword,
292            CredentialKind::SshKeyPassphrase,
293            CredentialKind::SftpPassword,
294            CredentialKind::SftpKeyPassphrase,
295            CredentialKind::FtpPassword,
296        ];
297        // All prefixed with com.r-shell. so they're easy to find in Keychain
298        // Access.app and grouped apart from other applications.
299        for k in kinds {
300            assert!(
301                k.service().starts_with("com.r-shell."),
302                "service {:?} should be namespaced",
303                k
304            );
305        }
306        // And every kind maps to a distinct service.
307        let set: std::collections::HashSet<&str> = kinds.iter().map(|k| k.service()).collect();
308        assert_eq!(set.len(), kinds.len(), "service strings must be unique");
309    }
310
311    #[test]
312    fn credential_kind_serializes_snake_case() {
313        let json = serde_json::to_string(&CredentialKind::SshKeyPassphrase).unwrap();
314        assert_eq!(json, "\"ssh_key_passphrase\"");
315    }
316
317    #[test]
318    fn credential_kind_deserializes_snake_case() {
319        let kind: CredentialKind = serde_json::from_str("\"ftp_password\"").unwrap();
320        assert_eq!(kind, CredentialKind::FtpPassword);
321    }
322
323    /// Round-trip against the real Keychain. Ignored by default so `cargo test`
324    /// on a developer machine doesn't prompt for Keychain access or leave
325    /// residue. Run with `cargo test -- --ignored` to exercise on macOS.
326    #[cfg(target_os = "macos")]
327    #[test]
328    #[ignore]
329    fn save_load_delete_round_trip_on_real_keychain() {
330        let kind = CredentialKind::SshPassword;
331        let account = format!("r-shell-test-{}@localhost:22", std::process::id());
332        let secret = "round-trip-secret-value";
333
334        // Nothing there initially.
335        let before = load_password(kind, &account).expect("load");
336        assert!(before.is_none(), "pre-existing keychain entry?");
337
338        save_password(kind, &account, secret).expect("save");
339        let loaded = load_password(kind, &account).expect("load").expect("some");
340        assert_eq!(loaded, secret);
341
342        // Overwrite is supported (set_generic_password updates in place).
343        save_password(kind, &account, "different-value").expect("overwrite");
344        let loaded2 = load_password(kind, &account)
345            .expect("reload")
346            .expect("some");
347        assert_eq!(loaded2, "different-value");
348
349        delete_password(kind, &account).expect("delete");
350        let after = load_password(kind, &account).expect("load after delete");
351        assert!(after.is_none(), "entry should be gone after delete");
352
353        // Delete is idempotent.
354        delete_password(kind, &account).expect("idempotent delete");
355    }
356
357    /// Verify that `list_accounts` finds entries we just saved and stops
358    /// listing them after deletion. Ignored by default like the other
359    /// real-Keychain tests; run with `cargo test -- --ignored`.
360    #[cfg(target_os = "macos")]
361    #[test]
362    #[ignore]
363    fn list_accounts_round_trip_on_real_keychain() {
364        let kind = CredentialKind::FtpPassword;
365        let pid = std::process::id();
366        let accounts = [
367            format!("r-shell-list-a-{}@a.test:21", pid),
368            format!("r-shell-list-b-{}@b.test:21", pid),
369        ];
370
371        for a in &accounts {
372            save_password(kind, a, "x").expect("save");
373        }
374
375        let listed = list_accounts(kind).expect("list");
376        for a in &accounts {
377            assert!(
378                listed.iter().any(|l| l == a),
379                "expected {} in list, got {:?}",
380                a,
381                listed
382            );
383        }
384
385        for a in &accounts {
386            delete_password(kind, a).expect("cleanup");
387        }
388
389        let after = list_accounts(kind).expect("list after cleanup");
390        for a in &accounts {
391            assert!(
392                !after.iter().any(|l| l == a),
393                "entry {} should be gone after cleanup",
394                a
395            );
396        }
397    }
398}