Skip to main content

apple_native_keyring_store/
protected.rs

1/*!
2
3# Protected Data credential store
4
5iOS (and macOS on Apple Silicon) offers a secure storage service called
6_Protected Data_. This module provides a credential store for that service.
7
8To use all the features of this module, your client application must be
9code-signed with a provisioning profile. Since command-line tools cannot be
10code-signed, there's not much point in their using this module.
11
12There are actually two, distinct protected stores: one local to the
13device, and one that is synchronized with iCloud. If you create a store with
14`Store::new`, you get the default configuration in which the local store is
15used, but if you create a store with `Store::new_with_configuration` and pass
16the string `true` for the `cloud-sync` key, then the iCloud-synchronized
17store is used instead. (Use of the cloud-synchronized store is only available
18to applications that have the iCloud capability enabled in their provisioning
19profile.)
20
21For a given service/user pair, this module creates/searches for a generic
22password item whose _account_ attribute holds the user and whose _service_
23attribute holds the service. Because of a quirk in the protected data API,
24neither the _account_ nor the _service_ may be the empty string. (Empty strings
25are treated as wildcards when looking up credentials.)
26
27## Ambiguity
28
29Both the local and cloud-synchronized stores are application-specific, in
30that each application is given its own _access group_ in which it stores its
31items. Since there can be only one generic password item in each access group
32with a given _account_ and _service_, an application that can access only one
33access group will never encounter ambiguity. It also means that, by default,
34applications can never share credentials for the same service and account.
35
36Because there are occasions when credentials must be shared between applications,
37sandboxed applications can be given access to multiple access groups. When an
38application has been configured to have multiple access groups, its protected
39store will search across all those access groups for a given entry, so ambiguity
40is possible. To avoid this, an application can create one store for each available
41access group, passing the access group name as the value of the`access-group`
42modifier when creating each store. (This is also how such an application can specify
43which group it wants to use when creating a new credential.)
44
45If you have retrieved a wrapper entry and want to know the access group of the
46underlying item, you can downcast the wrapper entry to the `Cred` type and look
47at its `access_group` field. For more information about this, see the many Apple
48developer docs about sharing access groups among applications. Also look at the
49`tests` example code for the tests of ambiguity.
50
51## Access control
52
53Protected data items _in the local store_ can be created with varying levels of
54protection. This module uses a default access policy of "accessible when device
55is unlocked", but entry modifiers can be used to change this. See the docs for
56[build](Store::build) for details.
57
58## Attributes
59
60This store exposes no attributes.
61
62## Search
63
64This store exposes search over both the local and cloud-synchronized stores.
65You can search for credentials by service and/or user (case-sensitive exact match),
66and you can restrict searches to a specific access group.
67If you specify neither a service nor a user, then the search will return all
68credentials in the store (or access group), but read on for restrictions.
69
70The OS, by design, does not expose the access policy on existing secrets in the
71store. So the wrapper entries returned from search will always have the default
72access policy, not the policy of the entry that was found.
73
74Items whose access policy requires user interaction will pop an authentication
75dialog during the search. To avoid this, the default behavior of searches is
76to skip over these entries. You can specify in the search spec that you want
77them not to be skipped, but this is not recommended.
78 */
79
80use 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/// Access policies for protected data items.
100///
101/// These are recognized case-insensitively from their
102/// camel-cased or snake-cased equivalents, as
103/// well as the string "default".
104#[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/// The representation of a generic password credential.
141///
142/// If there is no access group, the credential will be created in a
143/// default group as chosen by the OS per
144/// [these guidelines](https://developer.apple.com/documentation/security/ksecattraccessgroup).
145#[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    /// Create an entry representing a protected generic password.
156    ///
157    /// This will fail if the service or user strings are empty,
158    /// because empty attribute values act as wildcards in the
159    /// Keychain Services API.
160    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            // should never happen
207            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                // should never happen, so warn if it does
221                error!("Search result credential has no access group; using entry's group")
222            }
223        } else {
224            // should never happen, so warn if it does
225            error!("Search result credential has no attributes; using entry's group")
226        }
227        cred
228    }
229}
230
231impl CredentialApi for Cred {
232    /// See the keychain-core API docs.
233    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    /// See the keychain-core API docs.
267    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    /// See the keychain-core API docs.
280    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    /// See the keychain-core API docs.
294    ///
295    /// There are two cases:
296    /// 1. If the cred has an access group, then it can't be ambiguous,
297    ///    so we just make sure that it exists before returning None.
298    /// 2. If the cred has no access group, then we do a search to
299    ///    check for ambiguity and, if none, return a wrapper that has
300    ///    the access group attached.
301    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    /// See the keychain-core API docs.
336    fn get_specifiers(&self) -> Option<(String, String)> {
337        Some((self.service.clone(), self.account.clone()))
338    }
339
340    /// See the keychain-core API docs.
341    fn as_any(&self) -> &dyn std::any::Any {
342        self
343    }
344
345    /// See the keychain-core API docs.
346    fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        std::fmt::Debug::fmt(self, f)
348    }
349}
350
351/// The builder for iOS keychain credentials
352pub 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    /// Create a default store, which does *not* synchronize with the cloud.
371    pub fn new() -> Result<Arc<Self>> {
372        Ok(Self::new_internal(None, false))
373    }
374
375    /// Create a configured store.
376    ///
377    /// There are two allowed configuration keys:
378    /// - `cloud-sync` (`true` or `false`), default false. Specifying this key as true
379    ///   will sync all items in the store with iCloud.
380    /// - `access-group`. If non-empty, this store will store all its items in the
381    ///   specified access group. If empty or not specified, as in the default configuration,
382    ///   all items will be stored in the app's default access group.
383    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    /// See the keychain-core API docs.
420    fn vendor(&self) -> String {
421        "macOS/iOS Protected Store, https://crates.io/crates/apple-native-keyring-store".to_string()
422    }
423
424    /// See the keychain-core API docs.
425    fn id(&self) -> String {
426        self.id.to_string()
427    }
428
429    /// See the keychain-core API docs.
430    ///
431    /// There is only one allowed modifier: `access-policy`, which can be one of
432    /// these (case-insensitive) values (ordered least to most restrictive):
433    /// - `AfterFirstUnlock` (or `after-first-unlock`)
434    /// - `AfterFirstUnlockThisDeviceOnly` (or `after-first-unlock-this-device-only`)
435    /// - `WhenUnlocked` (or `when-unlocked`), the default
436    /// - `WhenUnlockedThisDeviceOnly` (or `when-unlocked-this-device-only`)
437    /// - `WhenPasscodeSetThisDeviceOnly` (or `when-passcode-set-this-device-only`)
438    /// - `RequireUserPresence` (or `require-user-presence`)
439    ///
440    /// These correspond to similarly named values of the `kSecAttrAccessible` attribute,
441    /// described in the
442    /// [Apple docs](https://developer.apple.com/documentation/security/restricting-keychain-item-accessibility),
443    /// except for `RequireUserPresence` which is like
444    /// `WhenUnlocked` but adds a requirement to do biometric authentication whenever
445    /// the credential is accessed.
446    ///
447    /// Note: You cannot specify an access policy in a cloud-synchronized store: the
448    /// OS controls this access to manage synchronization.
449    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    /// See the keychain-core API docs.
472    ///
473    /// The primary spec keys are `service`, `account`, and `access-group`, which
474    /// restrict the search to items which match (case-sensitive) the given values.
475    /// Without any restrictions, every generic password item in the store is returned.
476    ///
477    /// There is a `show-authentication-ui` key (value true or false, default false)
478    /// which can be used to prevent the default behavior of skipping
479    /// any items whose access policy requires user interaction.
480    ///
481    /// Because the OS hides the access policy information
482    /// of existing items, every wrapper returned from a search has a
483    /// default access policy which may or may not match that of the item
484    /// it wraps. This default access policy has no effect unless you
485    /// delete the underlying item and re-create it from the wrapper
486    /// by setting its password.
487    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    /// See the keychain-core API docs.
516    fn as_any(&self) -> &dyn std::any::Any {
517        self
518    }
519
520    //// See the keychain-core API docs.
521    fn persistence(&self) -> CredentialPersistence {
522        CredentialPersistence::UntilDelete
523    }
524
525    /// See the keychain-core API docs.
526    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
593/// Map an iOS API error to a crate error with appropriate annotation
594///
595/// The iOS error code values used here are from
596/// [this reference](https://opensource.apple.com/source/libsecurity_keychain/libsecurity_keychain-78/lib/SecBase.h.auto.html)
597fn decode_error(err: Error) -> ErrorCode {
598    match err.code() {
599        -25291 => ErrorCode::NoStorageAccess(Box::new(err)), // errSecNotAvailable
600        -25292 => ErrorCode::NoStorageAccess(Box::new(err)), // errSecReadOnly
601        -25300 => ErrorCode::NoEntry,                        // errSecItemNotFound
602        -34018 => ErrorCode::PlatformFailure(Box::new(err)), // errSecMissingEntitlement
603        _ => ErrorCode::PlatformFailure(Box::new(err)),
604    }
605}