Skip to main content

auths_core/storage/
passphrase_cache.rs

1//! OS keychain-backed passphrase cache.
2//!
3//! Stores and retrieves passphrases by key alias using the platform keychain.
4//! Separate from the main key storage — uses service name `dev.auths.passphrase`.
5
6use crate::error::AgentError;
7use zeroize::Zeroizing;
8
9/// Trait for storing/retrieving passphrases in the OS keychain.
10pub trait PassphraseCache: Send + Sync {
11    /// Store a passphrase for the given alias.
12    fn store(&self, alias: &str, passphrase: &str, stored_at_unix: i64) -> Result<(), AgentError>;
13
14    /// Load a cached passphrase for the given alias.
15    /// Returns `None` if no cached passphrase exists.
16    fn load(&self, alias: &str) -> Result<Option<(Zeroizing<String>, i64)>, AgentError>;
17
18    /// Delete a cached passphrase for the given alias.
19    fn delete(&self, alias: &str) -> Result<(), AgentError>;
20}
21
22#[cfg(any(
23    target_os = "macos",
24    all(target_os = "linux", feature = "keychain-linux-secretservice")
25))]
26const PASSPHRASE_SERVICE: &str = "dev.auths.passphrase";
27
28/// No-op cache that never stores or returns anything.
29pub struct NoopPassphraseCache;
30
31impl PassphraseCache for NoopPassphraseCache {
32    fn store(
33        &self,
34        _alias: &str,
35        _passphrase: &str,
36        _stored_at_unix: i64,
37    ) -> Result<(), AgentError> {
38        Ok(())
39    }
40
41    fn load(&self, _alias: &str) -> Result<Option<(Zeroizing<String>, i64)>, AgentError> {
42        Ok(None)
43    }
44
45    fn delete(&self, _alias: &str) -> Result<(), AgentError> {
46        Ok(())
47    }
48}
49
50#[cfg(any(
51    target_os = "macos",
52    all(target_os = "linux", feature = "keychain-linux-secretservice")
53))]
54fn encode_secret(passphrase: &str, stored_at_unix: i64) -> String {
55    format!("{}|{}", stored_at_unix, passphrase)
56}
57
58#[cfg(any(
59    target_os = "macos",
60    all(target_os = "linux", feature = "keychain-linux-secretservice")
61))]
62fn decode_secret(secret: &str) -> Option<(Zeroizing<String>, i64)> {
63    let (ts_str, passphrase) = secret.split_once('|')?;
64    let ts: i64 = ts_str.parse().ok()?;
65    Some((Zeroizing::new(passphrase.to_string()), ts))
66}
67
68#[cfg(target_os = "macos")]
69mod macos {
70    use super::*;
71    use core_foundation::base::{CFRelease, CFTypeRef, TCFType, kCFAllocatorDefault};
72    use core_foundation::boolean::kCFBooleanTrue;
73    use core_foundation::data::CFData;
74    use core_foundation::dictionary::{
75        CFDictionaryCreate, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
76    };
77    use core_foundation::number::CFNumber;
78    use core_foundation::string::CFString;
79    use security_framework_sys::access_control::{
80        SecAccessControlCreateWithFlags, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
81    };
82    use security_framework_sys::base::{errSecItemNotFound, errSecSuccess};
83    use security_framework_sys::item::{
84        kSecAttrAccessControl, kSecAttrAccount, kSecAttrService, kSecClass,
85        kSecClassGenericPassword, kSecMatchLimit, kSecReturnData, kSecValueData,
86    };
87    use security_framework_sys::keychain_item::{SecItemAdd, SecItemCopyMatching, SecItemDelete};
88    use std::os::raw::c_void;
89    use std::ptr;
90
91    // kSecAccessControlUserPresence: Touch ID or device passcode
92    const USER_PRESENCE: usize = 1 << 0;
93    // User cancelled Touch ID or authentication failed
94    const ERR_SEC_USER_CANCELED: i32 = -128;
95    const ERR_SEC_AUTH_FAILED: i32 = -25293;
96
97    pub struct MacOsPassphraseCache {
98        pub biometric: bool,
99    }
100
101    impl MacOsPassphraseCache {
102        fn store_with_biometric(
103            &self,
104            alias: &str,
105            passphrase: &str,
106            stored_at_unix: i64,
107        ) -> Result<(), AgentError> {
108            let service_cf = CFString::new(PASSPHRASE_SERVICE);
109            let alias_cf = CFString::new(alias);
110            let secret = encode_secret(passphrase, stored_at_unix);
111            let data_cf = CFData::from_buffer(secret.as_bytes());
112
113            unsafe {
114                let access_control = SecAccessControlCreateWithFlags(
115                    kCFAllocatorDefault,
116                    kSecAttrAccessibleWhenUnlockedThisDeviceOnly as CFTypeRef,
117                    USER_PRESENCE,
118                    ptr::null_mut(),
119                );
120                if access_control.is_null() {
121                    return Err(AgentError::SecurityError(
122                        "Failed to create biometric access control".to_string(),
123                    ));
124                }
125
126                let keys: [*const c_void; 5] = [
127                    kSecClass as *const c_void,
128                    kSecAttrService as *const c_void,
129                    kSecAttrAccount as *const c_void,
130                    kSecValueData as *const c_void,
131                    kSecAttrAccessControl as *const c_void,
132                ];
133                let values: [*const c_void; 5] = [
134                    kSecClassGenericPassword as *const c_void,
135                    service_cf.as_CFTypeRef(),
136                    alias_cf.as_CFTypeRef(),
137                    data_cf.as_CFTypeRef(),
138                    access_control as *const c_void,
139                ];
140                let query = CFDictionaryCreate(
141                    kCFAllocatorDefault,
142                    keys.as_ptr(),
143                    values.as_ptr(),
144                    keys.len() as isize,
145                    &kCFTypeDictionaryKeyCallBacks,
146                    &kCFTypeDictionaryValueCallBacks,
147                );
148                CFRelease(access_control as CFTypeRef);
149
150                if query.is_null() {
151                    return Err(AgentError::SecurityError(
152                        "Failed to create CFDictionary for passphrase store".to_string(),
153                    ));
154                }
155                let status = SecItemAdd(query, ptr::null_mut());
156                CFRelease(query as CFTypeRef);
157
158                if status == errSecSuccess {
159                    Ok(())
160                } else {
161                    Err(AgentError::SecurityError(format!(
162                        "SecItemAdd for passphrase cache failed (OSStatus: {})",
163                        status
164                    )))
165                }
166            }
167        }
168
169        fn store_without_biometric(
170            &self,
171            alias: &str,
172            passphrase: &str,
173            stored_at_unix: i64,
174        ) -> Result<(), AgentError> {
175            let service_cf = CFString::new(PASSPHRASE_SERVICE);
176            let alias_cf = CFString::new(alias);
177            let secret = encode_secret(passphrase, stored_at_unix);
178            let data_cf = CFData::from_buffer(secret.as_bytes());
179
180            unsafe {
181                let keys: [*const c_void; 4] = [
182                    kSecClass as *const c_void,
183                    kSecAttrService as *const c_void,
184                    kSecAttrAccount as *const c_void,
185                    kSecValueData as *const c_void,
186                ];
187                let values: [*const c_void; 4] = [
188                    kSecClassGenericPassword as *const c_void,
189                    service_cf.as_CFTypeRef(),
190                    alias_cf.as_CFTypeRef(),
191                    data_cf.as_CFTypeRef(),
192                ];
193                let query = CFDictionaryCreate(
194                    kCFAllocatorDefault,
195                    keys.as_ptr(),
196                    values.as_ptr(),
197                    keys.len() as isize,
198                    &kCFTypeDictionaryKeyCallBacks,
199                    &kCFTypeDictionaryValueCallBacks,
200                );
201                if query.is_null() {
202                    return Err(AgentError::SecurityError(
203                        "Failed to create CFDictionary for passphrase store".to_string(),
204                    ));
205                }
206                let status = SecItemAdd(query, ptr::null_mut());
207                CFRelease(query as CFTypeRef);
208
209                if status == errSecSuccess {
210                    Ok(())
211                } else {
212                    Err(AgentError::SecurityError(format!(
213                        "SecItemAdd for passphrase cache failed (OSStatus: {})",
214                        status
215                    )))
216                }
217            }
218        }
219    }
220
221    impl PassphraseCache for MacOsPassphraseCache {
222        fn store(
223            &self,
224            alias: &str,
225            passphrase: &str,
226            stored_at_unix: i64,
227        ) -> Result<(), AgentError> {
228            let _ = self.delete(alias);
229
230            if self.biometric {
231                // Biometric requires code-signing entitlements; fall back if missing
232                match self.store_with_biometric(alias, passphrase, stored_at_unix) {
233                    Ok(()) => return Ok(()),
234                    Err(_) => {
235                        return self.store_without_biometric(alias, passphrase, stored_at_unix);
236                    }
237                }
238            }
239            self.store_without_biometric(alias, passphrase, stored_at_unix)
240        }
241
242        fn load(&self, alias: &str) -> Result<Option<(Zeroizing<String>, i64)>, AgentError> {
243            let service_cf = CFString::new(PASSPHRASE_SERVICE);
244            let alias_cf = CFString::new(alias);
245            let limit_one_cf = CFNumber::from(1i32);
246            let mut result_ref: CFTypeRef = ptr::null_mut();
247
248            let status = unsafe {
249                let keys: [*const c_void; 5] = [
250                    kSecClass as *const c_void,
251                    kSecAttrService as *const c_void,
252                    kSecAttrAccount as *const c_void,
253                    kSecReturnData as *const c_void,
254                    kSecMatchLimit as *const c_void,
255                ];
256                let values: [*const c_void; 5] = [
257                    kSecClassGenericPassword as *const c_void,
258                    service_cf.as_CFTypeRef(),
259                    alias_cf.as_CFTypeRef(),
260                    kCFBooleanTrue as *const c_void,
261                    limit_one_cf.as_CFTypeRef(),
262                ];
263                let query = CFDictionaryCreate(
264                    kCFAllocatorDefault,
265                    keys.as_ptr(),
266                    values.as_ptr(),
267                    keys.len() as isize,
268                    &kCFTypeDictionaryKeyCallBacks,
269                    &kCFTypeDictionaryValueCallBacks,
270                );
271                if query.is_null() {
272                    return Err(AgentError::SecurityError(
273                        "Failed to create CFDictionary for passphrase load".to_string(),
274                    ));
275                }
276                let status = SecItemCopyMatching(query, &mut result_ref);
277                CFRelease(query as CFTypeRef);
278                status
279            };
280
281            if status == errSecItemNotFound {
282                return Ok(None);
283            }
284            // Touch ID cancelled or auth failed — treat as cache miss
285            if status == ERR_SEC_USER_CANCELED || status == ERR_SEC_AUTH_FAILED {
286                return Ok(None);
287            }
288            if status != errSecSuccess {
289                return Err(AgentError::SecurityError(format!(
290                    "SecItemCopyMatching for passphrase cache failed (OSStatus: {})",
291                    status
292                )));
293            }
294            if result_ref.is_null() {
295                return Ok(None);
296            }
297
298            let bytes = unsafe {
299                let cf_data = CFData::wrap_under_create_rule(result_ref as _);
300                cf_data.bytes().to_vec()
301            };
302
303            let secret_str = String::from_utf8(bytes).map_err(|e| {
304                AgentError::SecurityError(format!("Invalid passphrase encoding: {}", e))
305            })?;
306
307            Ok(decode_secret(&secret_str))
308        }
309
310        fn delete(&self, alias: &str) -> Result<(), AgentError> {
311            let service_cf = CFString::new(PASSPHRASE_SERVICE);
312            let alias_cf = CFString::new(alias);
313
314            unsafe {
315                let keys: [*const c_void; 3] = [
316                    kSecClass as *const c_void,
317                    kSecAttrService as *const c_void,
318                    kSecAttrAccount as *const c_void,
319                ];
320                let values: [*const c_void; 3] = [
321                    kSecClassGenericPassword as *const c_void,
322                    service_cf.as_CFTypeRef(),
323                    alias_cf.as_CFTypeRef(),
324                ];
325                let query = CFDictionaryCreate(
326                    kCFAllocatorDefault,
327                    keys.as_ptr(),
328                    values.as_ptr(),
329                    keys.len() as isize,
330                    &kCFTypeDictionaryKeyCallBacks,
331                    &kCFTypeDictionaryValueCallBacks,
332                );
333                if query.is_null() {
334                    return Err(AgentError::SecurityError(
335                        "Failed to create CFDictionary for passphrase delete".to_string(),
336                    ));
337                }
338                let status = SecItemDelete(query);
339                CFRelease(query as CFTypeRef);
340
341                if status == errSecSuccess || status == errSecItemNotFound {
342                    Ok(())
343                } else {
344                    Err(AgentError::SecurityError(format!(
345                        "SecItemDelete for passphrase cache failed (OSStatus: {})",
346                        status
347                    )))
348                }
349            }
350        }
351    }
352}
353
354#[cfg(all(target_os = "linux", feature = "keychain-linux-secretservice"))]
355mod linux {
356    use super::*;
357    use secret_service::{EncryptionType, SecretService};
358    use std::collections::HashMap;
359
360    const ATTR_SERVICE: &str = "service";
361    const ATTR_ALIAS: &str = "alias";
362
363    pub struct LinuxPassphraseCache;
364
365    impl PassphraseCache for LinuxPassphraseCache {
366        fn store(
367            &self,
368            alias: &str,
369            passphrase: &str,
370            stored_at_unix: i64,
371        ) -> Result<(), AgentError> {
372            let secret = encode_secret(passphrase, stored_at_unix);
373            let alias = alias.to_string();
374
375            tokio::task::block_in_place(|| {
376                tokio::runtime::Handle::current().block_on(async {
377                    let ss = SecretService::connect(EncryptionType::Dh)
378                        .await
379                        .map_err(|e| AgentError::BackendUnavailable {
380                            backend: "linux-secret-service",
381                            reason: format!("Failed to connect: {}", e),
382                        })?;
383
384                    let collection = ss.get_default_collection().await.map_err(|e| {
385                        AgentError::SecurityError(format!(
386                            "Failed to get default collection: {}",
387                            e
388                        ))
389                    })?;
390
391                    // Delete existing
392                    let search_attrs: HashMap<&str, &str> = [
393                        (ATTR_SERVICE, PASSPHRASE_SERVICE),
394                        (ATTR_ALIAS, alias.as_str()),
395                    ]
396                    .into_iter()
397                    .collect();
398
399                    if let Ok(items) = ss.search_items(search_attrs).await {
400                        for item in items.unlocked {
401                            let _ = item.delete().await;
402                        }
403                    }
404
405                    let attrs: HashMap<&str, &str> = [
406                        (ATTR_SERVICE, PASSPHRASE_SERVICE),
407                        (ATTR_ALIAS, alias.as_str()),
408                    ]
409                    .into_iter()
410                    .collect();
411
412                    let label = format!("Auths passphrase: {}", alias);
413                    collection
414                        .create_item(&label, attrs, secret.as_bytes(), true, "text/plain")
415                        .await
416                        .map_err(|e| {
417                            AgentError::StorageError(format!("Failed to store passphrase: {}", e))
418                        })?;
419
420                    Ok(())
421                })
422            })
423        }
424
425        fn load(&self, alias: &str) -> Result<Option<(Zeroizing<String>, i64)>, AgentError> {
426            let alias = alias.to_string();
427
428            tokio::task::block_in_place(|| {
429                tokio::runtime::Handle::current().block_on(async {
430                    let ss = SecretService::connect(EncryptionType::Dh)
431                        .await
432                        .map_err(|e| AgentError::BackendUnavailable {
433                            backend: "linux-secret-service",
434                            reason: format!("Failed to connect: {}", e),
435                        })?;
436
437                    let attrs: HashMap<&str, &str> = [
438                        (ATTR_SERVICE, PASSPHRASE_SERVICE),
439                        (ATTR_ALIAS, alias.as_str()),
440                    ]
441                    .into_iter()
442                    .collect();
443
444                    let items = ss.search_items(attrs).await.map_err(|e| {
445                        AgentError::StorageError(format!("Failed to search items: {}", e))
446                    })?;
447
448                    let item = match items.unlocked.into_iter().next() {
449                        Some(i) => i,
450                        None => return Ok(None),
451                    };
452
453                    let secret_bytes = item.get_secret().await.map_err(|e| {
454                        AgentError::StorageError(format!("Failed to get secret: {}", e))
455                    })?;
456
457                    let secret_str = String::from_utf8(secret_bytes).map_err(|e| {
458                        AgentError::StorageError(format!("Invalid secret encoding: {}", e))
459                    })?;
460
461                    Ok(decode_secret(&secret_str))
462                })
463            })
464        }
465
466        fn delete(&self, alias: &str) -> Result<(), AgentError> {
467            let alias = alias.to_string();
468
469            tokio::task::block_in_place(|| {
470                tokio::runtime::Handle::current().block_on(async {
471                    let ss = SecretService::connect(EncryptionType::Dh)
472                        .await
473                        .map_err(|e| AgentError::BackendUnavailable {
474                            backend: "linux-secret-service",
475                            reason: format!("Failed to connect: {}", e),
476                        })?;
477
478                    let attrs: HashMap<&str, &str> = [
479                        (ATTR_SERVICE, PASSPHRASE_SERVICE),
480                        (ATTR_ALIAS, alias.as_str()),
481                    ]
482                    .into_iter()
483                    .collect();
484
485                    let items = ss.search_items(attrs).await.map_err(|e| {
486                        AgentError::StorageError(format!("Failed to search items: {}", e))
487                    })?;
488
489                    for item in items.unlocked {
490                        let _ = item.delete().await;
491                    }
492
493                    Ok(())
494                })
495            })
496        }
497    }
498}
499
500/// Returns the platform-appropriate passphrase cache.
501///
502/// Args:
503/// * `biometric`: When `true` on macOS, protects cached passphrases with Touch ID.
504///   Ignored on other platforms.
505///
506/// Usage:
507/// ```ignore
508/// let cache = get_passphrase_cache(true);
509/// cache.store("main", "my-secret", chrono::Utc::now().timestamp())?;
510/// ```
511pub fn get_passphrase_cache(biometric: bool) -> Box<dyn PassphraseCache> {
512    #[cfg(target_os = "macos")]
513    {
514        Box::new(macos::MacOsPassphraseCache { biometric })
515    }
516
517    #[cfg(all(target_os = "linux", feature = "keychain-linux-secretservice"))]
518    {
519        let _ = biometric;
520        Box::new(linux::LinuxPassphraseCache)
521    }
522
523    #[cfg(not(any(
524        target_os = "macos",
525        all(target_os = "linux", feature = "keychain-linux-secretservice")
526    )))]
527    {
528        let _ = biometric;
529        Box::new(NoopPassphraseCache)
530    }
531}
532
533/// Parses a human-friendly duration string into seconds.
534///
535/// Supports: `"7d"` (days), `"24h"` (hours), `"30m"` (minutes), `"3600s"` or `"3600"` (seconds).
536///
537/// Args:
538/// * `s`: Duration string.
539///
540/// Usage:
541/// ```ignore
542/// assert_eq!(parse_duration_str("7d"), Some(604800));
543/// assert_eq!(parse_duration_str("24h"), Some(86400));
544/// ```
545pub fn parse_duration_str(s: &str) -> Option<i64> {
546    let s = s.trim();
547    if s.is_empty() {
548        return None;
549    }
550
551    let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
552        (n, 86400i64)
553    } else if let Some(n) = s.strip_suffix('h') {
554        (n, 3600)
555    } else if let Some(n) = s.strip_suffix('m') {
556        (n, 60)
557    } else if let Some(n) = s.strip_suffix('s') {
558        (n, 1)
559    } else {
560        (s, 1)
561    };
562
563    let n: i64 = num_str.parse().ok()?;
564    if n <= 0 {
565        return None;
566    }
567    Some(n * multiplier)
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn noop_cache_returns_none() {
576        let cache = NoopPassphraseCache;
577        assert!(cache.load("any").unwrap().is_none());
578    }
579
580    #[test]
581    fn noop_cache_store_and_delete_succeed() {
582        let cache = NoopPassphraseCache;
583        cache.store("any", "pass", 12345).unwrap();
584        cache.delete("any").unwrap();
585    }
586
587    #[cfg(any(
588        target_os = "macos",
589        all(target_os = "linux", feature = "keychain-linux-secretservice")
590    ))]
591    mod secret_encoding {
592        use super::*;
593
594        #[test]
595        fn encode_decode_roundtrip() {
596            let encoded = encode_secret("my-passphrase", 1700000000);
597            let (pass, ts) = decode_secret(&encoded).unwrap();
598            assert_eq!(*pass, "my-passphrase");
599            assert_eq!(ts, 1700000000);
600        }
601
602        #[test]
603        fn decode_handles_pipe_in_passphrase() {
604            let encoded = encode_secret("pass|with|pipes", 100);
605            let (pass, ts) = decode_secret(&encoded).unwrap();
606            assert_eq!(*pass, "pass|with|pipes");
607            assert_eq!(ts, 100);
608        }
609
610        #[test]
611        fn decode_rejects_empty() {
612            assert!(decode_secret("").is_none());
613        }
614
615        #[test]
616        fn decode_rejects_no_pipe() {
617            assert!(decode_secret("12345").is_none());
618        }
619
620        #[test]
621        fn decode_rejects_bad_timestamp() {
622            assert!(decode_secret("notanumber|pass").is_none());
623        }
624    }
625
626    #[test]
627    fn parse_duration_days() {
628        assert_eq!(parse_duration_str("7d"), Some(604800));
629    }
630
631    #[test]
632    fn parse_duration_hours() {
633        assert_eq!(parse_duration_str("24h"), Some(86400));
634    }
635
636    #[test]
637    fn parse_duration_minutes() {
638        assert_eq!(parse_duration_str("30m"), Some(1800));
639    }
640
641    #[test]
642    fn parse_duration_seconds_suffix() {
643        assert_eq!(parse_duration_str("3600s"), Some(3600));
644    }
645
646    #[test]
647    fn parse_duration_bare_number() {
648        assert_eq!(parse_duration_str("3600"), Some(3600));
649    }
650
651    #[test]
652    fn parse_duration_empty() {
653        assert!(parse_duration_str("").is_none());
654    }
655
656    #[test]
657    fn parse_duration_zero() {
658        assert!(parse_duration_str("0d").is_none());
659    }
660
661    #[test]
662    fn parse_duration_negative() {
663        assert!(parse_duration_str("-1d").is_none());
664    }
665
666    #[test]
667    fn parse_duration_whitespace() {
668        assert_eq!(parse_duration_str("  7d  "), Some(604800));
669    }
670}