1use crate::error::AgentError;
7use zeroize::Zeroizing;
8
9pub trait PassphraseCache: Send + Sync {
11 fn store(&self, alias: &str, passphrase: &str, stored_at_unix: i64) -> Result<(), AgentError>;
13
14 fn load(&self, alias: &str) -> Result<Option<(Zeroizing<String>, i64)>, AgentError>;
17
18 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
28pub 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 const USER_PRESENCE: usize = 1 << 0;
93 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 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 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 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
500pub 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
533pub 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}