apple_native_keyring_store/
protected.rs1use std::collections::HashMap;
81use std::sync::Arc;
82use std::time::{SystemTime, UNIX_EPOCH};
83
84use log::error;
85use security_framework::access_control::{ProtectionMode, SecAccessControl};
86use security_framework::base::Error;
87use security_framework::item;
88use security_framework::passwords::{
89 AccessControlOptions, PasswordOptions, delete_generic_password_options, generic_password,
90 set_generic_password_options,
91};
92
93use keyring_core::{
94 CredentialPersistence, Entry, Error as ErrorCode, Result,
95 api::{Credential, CredentialApi, CredentialStoreApi},
96 attributes::parse_attributes,
97};
98
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub enum AccessPolicy {
106 AfterFirstUnlock,
107 AfterFirstUnlockThisDeviceOnly,
108 #[default]
109 WhenUnlocked,
110 WhenUnlockedThisDeviceOnly,
111 WhenPasscodeSetThisDeviceOnly,
112 RequireUserPresence,
113}
114
115impl AccessPolicy {
116 fn as_ref(&self) -> &AccessPolicy {
117 self
118 }
119}
120
121impl From<&AccessPolicy> for ProtectionMode {
122 fn from(value: &AccessPolicy) -> Self {
123 match value {
124 AccessPolicy::AfterFirstUnlock => ProtectionMode::AccessibleAfterFirstUnlock,
125 AccessPolicy::AfterFirstUnlockThisDeviceOnly => {
126 ProtectionMode::AccessibleAfterFirstUnlockThisDeviceOnly
127 }
128 AccessPolicy::WhenUnlocked => ProtectionMode::AccessibleWhenUnlocked,
129 AccessPolicy::WhenUnlockedThisDeviceOnly => {
130 ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly
131 }
132 AccessPolicy::WhenPasscodeSetThisDeviceOnly => {
133 ProtectionMode::AccessibleWhenPasscodeSetThisDeviceOnly
134 }
135 AccessPolicy::RequireUserPresence => ProtectionMode::AccessibleWhenUnlocked,
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Cred {
147 pub service: String,
148 pub account: String,
149 pub access_policy: AccessPolicy,
150 pub access_group: Option<String>,
151 pub cloud_synchronize: bool,
152}
153
154impl Cred {
155 pub fn build(
161 service: &str,
162 user: &str,
163 access_policy: AccessPolicy,
164 access_group: Option<String>,
165 cloud_synchronize: bool,
166 ) -> Result<Entry> {
167 if service.is_empty() {
168 return Err(ErrorCode::Invalid(
169 "service".to_string(),
170 "cannot be empty".to_string(),
171 ));
172 }
173 if user.is_empty() {
174 return Err(ErrorCode::Invalid(
175 "user".to_string(),
176 "cannot be empty".to_string(),
177 ));
178 }
179 let cred = Self {
180 service: service.to_string(),
181 account: user.to_string(),
182 access_policy,
183 access_group,
184 cloud_synchronize,
185 };
186 Ok(Entry::new_with_credential(Arc::new(cred)))
187 }
188
189 fn build_from_search_result(result: &item::SearchResult, cloud_sync: bool) -> Result<Entry> {
190 if let Some(attrs) = result.simplify_dict() {
191 let service = attrs.get("svce").ok_or_else(|| {
192 ErrorCode::Invalid("search result".to_string(), "has no service".to_string())
193 })?;
194 let account = attrs.get("acct").ok_or_else(|| {
195 ErrorCode::Invalid("search result".to_string(), "has no account".to_string())
196 })?;
197 let group = attrs.get("agrp").cloned();
198 Ok(Entry::new_with_credential(Arc::new(Cred {
199 service: service.clone(),
200 account: account.clone(),
201 access_group: group,
202 access_policy: Default::default(),
203 cloud_synchronize: cloud_sync,
204 })))
205 } else {
206 Err(ErrorCode::Invalid(
208 "search result".to_string(),
209 "has no attributes".to_string(),
210 ))
211 }
212 }
213
214 fn clone_from_search_result(&self, result: &item::SearchResult) -> Self {
215 let mut cred = self.clone();
216 if let Some(attrs) = result.simplify_dict() {
217 if let Some(group) = attrs.get("agrp") {
218 cred.access_group = Some(group.to_string());
219 } else {
220 error!("Search result credential has no access group; using entry's group")
222 }
223 } else {
224 error!("Search result credential has no attributes; using entry's group")
226 }
227 cred
228 }
229}
230
231impl CredentialApi for Cred {
232 fn set_secret(&self, secret: &[u8]) -> Result<()> {
234 let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
235 options.use_protected_keychain();
236 if let Some(access_group) = &self.access_group {
237 options.set_access_group(access_group);
238 }
239 if self.cloud_synchronize {
240 options.set_access_synchronized(Some(true));
241 } else {
242 match &self.access_policy {
243 AccessPolicy::RequireUserPresence => {
244 let access_control = SecAccessControl::create_with_protection(
245 Some(self.access_policy.as_ref().into()),
246 AccessControlOptions::USER_PRESENCE.bits(),
247 )
248 .map_err(decode_error)?;
249 options.set_access_control(access_control);
250 }
251 other => {
252 options.set_access_control(
253 SecAccessControl::create_with_protection(
254 Some(other.into()),
255 Default::default(),
256 )
257 .map_err(decode_error)?,
258 );
259 }
260 }
261 }
262 set_generic_password_options(secret, options).map_err(decode_error)?;
263 Ok(())
264 }
265
266 fn get_secret(&self) -> Result<Vec<u8>> {
268 let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
269 options.use_protected_keychain();
270 if let Some(access_group) = &self.access_group {
271 options.set_access_group(access_group);
272 }
273 if self.cloud_synchronize {
274 options.set_access_synchronized(Some(true));
275 }
276 generic_password(options).map_err(decode_error)
277 }
278
279 fn delete_credential(&self) -> Result<()> {
281 let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
282 options.use_protected_keychain();
283 if let Some(access_group) = &self.access_group {
284 options.set_access_group(access_group);
285 }
286 if self.cloud_synchronize {
287 options.set_access_synchronized(Some(true));
288 }
289 delete_generic_password_options(options).map_err(decode_error)?;
290 Ok(())
291 }
292
293 fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
302 if let Some(access_group) = &self.access_group {
303 let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
304 options.use_protected_keychain();
305 options.set_access_group(access_group);
306 if self.cloud_synchronize {
307 options.set_access_synchronized(Some(true));
308 }
309 generic_password(options).map_err(decode_error)?;
310 Ok(None)
311 } else {
312 let results = search_items(
313 Some(&self.service),
314 Some(&self.account),
315 self.access_group.as_deref(),
316 self.cloud_synchronize,
317 false,
318 )?;
319 match results.len() {
320 0 => Err(ErrorCode::NoEntry),
321 1 => Ok(Some(Arc::new(self.clone_from_search_result(&results[0])))),
322 _ => {
323 let entries: Vec<Entry> = results
324 .iter()
325 .map(|r| {
326 Entry::new_with_credential(Arc::new(self.clone_from_search_result(r)))
327 })
328 .collect();
329 Err(ErrorCode::Ambiguous(entries))
330 }
331 }
332 }
333 }
334
335 fn get_specifiers(&self) -> Option<(String, String)> {
337 Some((self.service.clone(), self.account.clone()))
338 }
339
340 fn as_any(&self) -> &dyn std::any::Any {
342 self
343 }
344
345 fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347 std::fmt::Debug::fmt(self, f)
348 }
349}
350
351pub struct Store {
353 id: String,
354 access_group: Option<String>,
355 cloud_synchronize: bool,
356}
357
358impl std::fmt::Debug for Store {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 f.debug_struct("Store")
361 .field("vendor", &self.vendor())
362 .field("id", &self.id())
363 .field("access_group", &self.access_group)
364 .field("cloud_synchronize", &self.cloud_synchronize)
365 .finish()
366 }
367}
368
369impl Store {
370 pub fn new() -> Result<Arc<Self>> {
372 Ok(Self::new_internal(None, false))
373 }
374
375 pub fn new_with_configuration(config: &HashMap<&str, &str>) -> Result<Arc<Self>> {
384 let config = parse_attributes(&["access-group", "*cloud-sync"], Some(config))?;
385 let mut cloud_synchronize = false;
386 let mut access_group = None;
387 if let Some(option) = config.get("cloud-sync") {
388 cloud_synchronize = option.eq("true");
389 }
390 if let Some(option) = config.get("access-group") {
391 if !option.is_empty() {
392 access_group = Some(option.to_string());
393 }
394 }
395 Ok(Self::new_internal(access_group, cloud_synchronize))
396 }
397
398 fn new_internal(access_group: Option<String>, cloud_synchronize: bool) -> Arc<Self> {
399 let now = SystemTime::now();
400 let elapsed = if now.lt(&UNIX_EPOCH) {
401 UNIX_EPOCH.duration_since(now).unwrap()
402 } else {
403 now.duration_since(UNIX_EPOCH).unwrap()
404 };
405 let id = format!(
406 "Protected Data Storage, Crate version {}, Instantiated at {}",
407 env!("CARGO_PKG_VERSION"),
408 elapsed.as_secs_f64()
409 );
410 Arc::new(Store {
411 id,
412 access_group,
413 cloud_synchronize,
414 })
415 }
416}
417
418impl CredentialStoreApi for Store {
419 fn vendor(&self) -> String {
421 "macOS/iOS Protected Store, https://crates.io/crates/apple-native-keyring-store".to_string()
422 }
423
424 fn id(&self) -> String {
426 self.id.to_string()
427 }
428
429 fn build(
450 &self,
451 service: &str,
452 user: &str,
453 modifiers: Option<&HashMap<&str, &str>>,
454 ) -> Result<Entry> {
455 let mods = parse_attributes(&["access-policy"], modifiers)?;
456 if self.cloud_synchronize && mods.contains_key("access-policy") {
457 return Err(ErrorCode::Invalid(
458 "access-policy".to_string(),
459 "cannot be specified in a cloud-synchronized store".to_string(),
460 ));
461 }
462 Cred::build(
463 service,
464 user,
465 determine_access_policy(&mods)?,
466 self.access_group.clone(),
467 self.cloud_synchronize,
468 )
469 }
470
471 fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
488 let spec = parse_attributes(
489 &[
490 "service",
491 "account",
492 "access-group",
493 "*show-authentication-ui",
494 ],
495 Some(spec),
496 )?;
497 let cloud_sync = self.cloud_synchronize;
498 let show_ui = spec
499 .get("show-authentication-ui")
500 .is_some_and(|s| s.eq("true"));
501 let items = search_items(
502 spec.get("service").map(String::as_str),
503 spec.get("account").map(String::as_str),
504 spec.get("access-group").map(String::as_str),
505 cloud_sync,
506 !show_ui,
507 )?;
508 let mut results = Vec::new();
509 for item in items.iter() {
510 results.push(Cred::build_from_search_result(item, cloud_sync)?)
511 }
512 Ok(results)
513 }
514
515 fn as_any(&self) -> &dyn std::any::Any {
517 self
518 }
519
520 fn persistence(&self) -> CredentialPersistence {
522 CredentialPersistence::UntilDelete
523 }
524
525 fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 std::fmt::Debug::fmt(self, f)
528 }
529}
530
531fn search_items(
532 service: Option<&str>,
533 account: Option<&str>,
534 access_group: Option<&str>,
535 cloud_sync: bool,
536 suppress_ui: bool,
537) -> Result<Vec<item::SearchResult>> {
538 let mut options = item::ItemSearchOptions::new();
539 options
540 .class(item::ItemClass::generic_password())
541 .load_attributes(true)
542 .limit(item::Limit::All)
543 .skip_authenticated_items(suppress_ui);
544 if let Some(service) = service {
545 options.service(service);
546 }
547 if let Some(account) = account {
548 options.account(account);
549 }
550 if let Some(access_group) = access_group {
551 options.access_group(access_group);
552 }
553 options.cloud_sync(Some(cloud_sync));
554 #[cfg(target_os = "macos")]
555 options.ignore_legacy_keychains();
556 let result = options.search();
557 match result {
558 Ok(results) => Ok(results),
559 Err(err) => match decode_error(err) {
560 ErrorCode::NoEntry => Ok(Vec::new()),
561 other => Err(other),
562 },
563 }
564}
565
566fn determine_access_policy(mods: &HashMap<String, String>) -> Result<AccessPolicy> {
567 if let Some(policy) = mods.get("access-policy") {
568 match policy.to_ascii_lowercase().as_str() {
569 "after-first-unlock" | "afterfirstunlock" => Ok(AccessPolicy::AfterFirstUnlock),
570 "after-first-unlock-this-device-only" | "afterfirstunlockthisdeviceonly" => {
571 Ok(AccessPolicy::AfterFirstUnlockThisDeviceOnly)
572 }
573 "when-unlocked" | "whenunlocked" | "default" => Ok(AccessPolicy::WhenUnlocked),
574 "when-unlocked-this-device-only" | "whenunlockedthisdeviceonly" => {
575 Ok(AccessPolicy::WhenUnlockedThisDeviceOnly)
576 }
577 "require-user-presence" | "requireuserpresence" => {
578 Ok(AccessPolicy::RequireUserPresence)
579 }
580 "when-passcode-set-this-device-only" | "whenpasscodesetthisdeviceonly" => {
581 Ok(AccessPolicy::WhenPasscodeSetThisDeviceOnly)
582 }
583 _ => Err(ErrorCode::Invalid(
584 "access-policy".to_string(),
585 format!("unknown value: {policy}"),
586 )),
587 }
588 } else {
589 Ok(AccessPolicy::default())
590 }
591}
592
593fn decode_error(err: Error) -> ErrorCode {
598 match err.code() {
599 -25291 => ErrorCode::NoStorageAccess(Box::new(err)), -25292 => ErrorCode::NoStorageAccess(Box::new(err)), -25300 => ErrorCode::NoEntry, -34018 => ErrorCode::PlatformFailure(Box::new(err)), _ => ErrorCode::PlatformFailure(Box::new(err)),
604 }
605}