use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use log::error;
use security_framework::access_control::{ProtectionMode, SecAccessControl};
use security_framework::base::Error;
use security_framework::item;
use security_framework::passwords::{
AccessControlOptions, PasswordOptions, delete_generic_password_options, generic_password,
set_generic_password_options,
};
use keyring_core::{
CredentialPersistence, Entry, Error as ErrorCode, Result,
api::{Credential, CredentialApi, CredentialStoreApi},
attributes::parse_attributes,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum AccessPolicy {
AfterFirstUnlock,
AfterFirstUnlockThisDeviceOnly,
#[default]
WhenUnlocked,
WhenUnlockedThisDeviceOnly,
WhenPasscodeSetThisDeviceOnly,
RequireUserPresence,
}
impl AccessPolicy {
fn as_ref(&self) -> &AccessPolicy {
self
}
}
impl From<&AccessPolicy> for ProtectionMode {
fn from(value: &AccessPolicy) -> Self {
match value {
AccessPolicy::AfterFirstUnlock => ProtectionMode::AccessibleAfterFirstUnlock,
AccessPolicy::AfterFirstUnlockThisDeviceOnly => {
ProtectionMode::AccessibleAfterFirstUnlockThisDeviceOnly
}
AccessPolicy::WhenUnlocked => ProtectionMode::AccessibleWhenUnlocked,
AccessPolicy::WhenUnlockedThisDeviceOnly => {
ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly
}
AccessPolicy::WhenPasscodeSetThisDeviceOnly => {
ProtectionMode::AccessibleWhenPasscodeSetThisDeviceOnly
}
AccessPolicy::RequireUserPresence => ProtectionMode::AccessibleWhenUnlocked,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cred {
pub service: String,
pub account: String,
pub access_policy: AccessPolicy,
pub access_group: Option<String>,
pub cloud_synchronize: bool,
}
impl Cred {
pub fn build(
service: &str,
user: &str,
access_policy: AccessPolicy,
access_group: Option<String>,
cloud_synchronize: bool,
) -> Result<Entry> {
if service.is_empty() {
return Err(ErrorCode::Invalid(
"service".to_string(),
"cannot be empty".to_string(),
));
}
if user.is_empty() {
return Err(ErrorCode::Invalid(
"user".to_string(),
"cannot be empty".to_string(),
));
}
let cred = Self {
service: service.to_string(),
account: user.to_string(),
access_policy,
access_group,
cloud_synchronize,
};
Ok(Entry::new_with_credential(Arc::new(cred)))
}
fn build_from_search_result(result: &item::SearchResult, cloud_sync: bool) -> Result<Entry> {
if let Some(attrs) = result.simplify_dict() {
let service = attrs.get("svce").ok_or_else(|| {
ErrorCode::Invalid("search result".to_string(), "has no service".to_string())
})?;
let account = attrs.get("acct").ok_or_else(|| {
ErrorCode::Invalid("search result".to_string(), "has no account".to_string())
})?;
let group = attrs.get("agrp").cloned();
Ok(Entry::new_with_credential(Arc::new(Cred {
service: service.clone(),
account: account.clone(),
access_group: group,
access_policy: Default::default(),
cloud_synchronize: cloud_sync,
})))
} else {
Err(ErrorCode::Invalid(
"search result".to_string(),
"has no attributes".to_string(),
))
}
}
fn clone_from_search_result(&self, result: &item::SearchResult) -> Self {
let mut cred = self.clone();
if let Some(attrs) = result.simplify_dict() {
if let Some(group) = attrs.get("agrp") {
cred.access_group = Some(group.to_string());
} else {
error!("Search result credential has no access group; using entry's group")
}
} else {
error!("Search result credential has no attributes; using entry's group")
}
cred
}
}
impl CredentialApi for Cred {
fn set_secret(&self, secret: &[u8]) -> Result<()> {
let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
options.use_protected_keychain();
if let Some(access_group) = &self.access_group {
options.set_access_group(access_group);
}
if self.cloud_synchronize {
options.set_access_synchronized(Some(true));
} else {
match &self.access_policy {
AccessPolicy::RequireUserPresence => {
let access_control = SecAccessControl::create_with_protection(
Some(self.access_policy.as_ref().into()),
AccessControlOptions::USER_PRESENCE.bits(),
)
.map_err(decode_error)?;
options.set_access_control(access_control);
}
other => {
options.set_access_control(
SecAccessControl::create_with_protection(
Some(other.into()),
Default::default(),
)
.map_err(decode_error)?,
);
}
}
}
set_generic_password_options(secret, options).map_err(decode_error)?;
Ok(())
}
fn get_secret(&self) -> Result<Vec<u8>> {
let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
options.use_protected_keychain();
if let Some(access_group) = &self.access_group {
options.set_access_group(access_group);
}
if self.cloud_synchronize {
options.set_access_synchronized(Some(true));
}
generic_password(options).map_err(decode_error)
}
fn delete_credential(&self) -> Result<()> {
let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
options.use_protected_keychain();
if let Some(access_group) = &self.access_group {
options.set_access_group(access_group);
}
if self.cloud_synchronize {
options.set_access_synchronized(Some(true));
}
delete_generic_password_options(options).map_err(decode_error)?;
Ok(())
}
fn get_credential(&self) -> Result<Option<Arc<Credential>>> {
if let Some(access_group) = &self.access_group {
let mut options = PasswordOptions::new_generic_password(&self.service, &self.account);
options.use_protected_keychain();
options.set_access_group(access_group);
if self.cloud_synchronize {
options.set_access_synchronized(Some(true));
}
generic_password(options).map_err(decode_error)?;
Ok(None)
} else {
let results = search_items(
Some(&self.service),
Some(&self.account),
self.access_group.as_deref(),
self.cloud_synchronize,
false,
)?;
match results.len() {
0 => Err(ErrorCode::NoEntry),
1 => Ok(Some(Arc::new(self.clone_from_search_result(&results[0])))),
_ => {
let entries: Vec<Entry> = results
.iter()
.map(|r| {
Entry::new_with_credential(Arc::new(self.clone_from_search_result(r)))
})
.collect();
Err(ErrorCode::Ambiguous(entries))
}
}
}
}
fn get_specifiers(&self) -> Option<(String, String)> {
Some((self.service.clone(), self.account.clone()))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
pub struct Store {
id: String,
access_group: Option<String>,
cloud_synchronize: bool,
}
impl std::fmt::Debug for Store {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Store")
.field("vendor", &self.vendor())
.field("id", &self.id())
.field("access_group", &self.access_group)
.field("cloud_synchronize", &self.cloud_synchronize)
.finish()
}
}
impl Store {
pub fn new() -> Result<Arc<Self>> {
Ok(Self::new_internal(None, false))
}
pub fn new_with_configuration(config: &HashMap<&str, &str>) -> Result<Arc<Self>> {
let config = parse_attributes(&["access-group", "*cloud-sync"], Some(config))?;
let mut cloud_synchronize = false;
let mut access_group = None;
if let Some(option) = config.get("cloud-sync") {
cloud_synchronize = option.eq("true");
}
if let Some(option) = config.get("access-group") {
if !option.is_empty() {
access_group = Some(option.to_string());
}
}
Ok(Self::new_internal(access_group, cloud_synchronize))
}
fn new_internal(access_group: Option<String>, cloud_synchronize: bool) -> Arc<Self> {
let now = SystemTime::now();
let elapsed = if now.lt(&UNIX_EPOCH) {
UNIX_EPOCH.duration_since(now).unwrap()
} else {
now.duration_since(UNIX_EPOCH).unwrap()
};
let id = format!(
"Protected Data Storage, Crate version {}, Instantiated at {}",
env!("CARGO_PKG_VERSION"),
elapsed.as_secs_f64()
);
Arc::new(Store {
id,
access_group,
cloud_synchronize,
})
}
}
impl CredentialStoreApi for Store {
fn vendor(&self) -> String {
"macOS/iOS Protected Store, https://crates.io/crates/apple-native-keyring-store".to_string()
}
fn id(&self) -> String {
self.id.to_string()
}
fn build(
&self,
service: &str,
user: &str,
modifiers: Option<&HashMap<&str, &str>>,
) -> Result<Entry> {
let mods = parse_attributes(&["access-policy"], modifiers)?;
if self.cloud_synchronize && mods.contains_key("access-policy") {
return Err(ErrorCode::Invalid(
"access-policy".to_string(),
"cannot be specified in a cloud-synchronized store".to_string(),
));
}
Cred::build(
service,
user,
determine_access_policy(&mods)?,
self.access_group.clone(),
self.cloud_synchronize,
)
}
fn search(&self, spec: &HashMap<&str, &str>) -> Result<Vec<Entry>> {
let spec = parse_attributes(
&[
"service",
"account",
"access-group",
"*show-authentication-ui",
],
Some(spec),
)?;
let cloud_sync = self.cloud_synchronize;
let show_ui = spec
.get("show-authentication-ui")
.is_some_and(|s| s.eq("true"));
let items = search_items(
spec.get("service").map(String::as_str),
spec.get("account").map(String::as_str),
spec.get("access-group").map(String::as_str),
cloud_sync,
!show_ui,
)?;
let mut results = Vec::new();
for item in items.iter() {
results.push(Cred::build_from_search_result(item, cloud_sync)?)
}
Ok(results)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn persistence(&self) -> CredentialPersistence {
CredentialPersistence::UntilDelete
}
fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
fn search_items(
service: Option<&str>,
account: Option<&str>,
access_group: Option<&str>,
cloud_sync: bool,
suppress_ui: bool,
) -> Result<Vec<item::SearchResult>> {
let mut options = item::ItemSearchOptions::new();
options
.class(item::ItemClass::generic_password())
.load_attributes(true)
.limit(item::Limit::All)
.skip_authenticated_items(suppress_ui);
if let Some(service) = service {
options.service(service);
}
if let Some(account) = account {
options.account(account);
}
if let Some(access_group) = access_group {
options.access_group(access_group);
}
options.cloud_sync(Some(cloud_sync));
#[cfg(target_os = "macos")]
options.ignore_legacy_keychains();
let result = options.search();
match result {
Ok(results) => Ok(results),
Err(err) => match decode_error(err) {
ErrorCode::NoEntry => Ok(Vec::new()),
other => Err(other),
},
}
}
fn determine_access_policy(mods: &HashMap<String, String>) -> Result<AccessPolicy> {
if let Some(policy) = mods.get("access-policy") {
match policy.to_ascii_lowercase().as_str() {
"after-first-unlock" | "afterfirstunlock" => Ok(AccessPolicy::AfterFirstUnlock),
"after-first-unlock-this-device-only" | "afterfirstunlockthisdeviceonly" => {
Ok(AccessPolicy::AfterFirstUnlock)
}
"when-unlocked" | "whenunlocked" | "default" => Ok(AccessPolicy::WhenUnlocked),
"when-unlocked-this-device-only" | "whenunlockedthisdeviceonly" => {
Ok(AccessPolicy::WhenUnlocked)
}
"require-user-presence" | "requireuserpresence" => {
Ok(AccessPolicy::RequireUserPresence)
}
"when-passcode-set-this-device-only" | "whenpasscodesetthisdeviceonly" => {
Ok(AccessPolicy::WhenPasscodeSetThisDeviceOnly)
}
_ => Err(ErrorCode::Invalid(
"access-policy".to_string(),
format!("unknown value: {policy}"),
)),
}
} else {
Ok(AccessPolicy::default())
}
}
fn decode_error(err: Error) -> ErrorCode {
match err.code() {
-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)),
}
}