use crate::errors::{SafeError, SafeResult};
use core_foundation::base::{CFRelease, CFType, CFTypeRef, TCFType};
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::CFDictionary;
use core_foundation::string::CFString;
use keyring::Error as KeyringError;
use security_framework::base::Error as SecError;
use security_framework::passwords::{
delete_generic_password_options, generic_password, set_generic_password_options,
PasswordOptions,
};
use security_framework::passwords_options::AccessControlOptions;
use security_framework_sys::base::errSecItemNotFound;
use security_framework_sys::item::{
kSecAttrAccount, kSecAttrService, kSecClass, kSecClassGenericPassword, kSecReturnAttributes,
kSecReturnData, kSecUseAuthenticationUI, kSecUseAuthenticationUISkip,
kSecUseDataProtectionKeychain,
};
use security_framework_sys::keychain::SecKeychainSetUserInteractionAllowed;
use security_framework_sys::keychain_item::SecItemCopyMatching;
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25308;
const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34018;
pub(crate) const SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET: u64 = 0x08;
pub(super) const SERVICE_NAME: &str = "tsafe";
const KEYCHAIN_ACCESS_GROUP: Option<&str> = option_env!("TSAFE_MACOS_KEYCHAIN_ACCESS_GROUP");
fn plain_options(service: &str, account: &str) -> PasswordOptions {
PasswordOptions::new_generic_password(service, account)
}
fn data_protection_options(service: &str, account: &str) -> PasswordOptions {
let mut o = PasswordOptions::new_generic_password(service, account);
o.use_protected_keychain();
if let Some(group) = KEYCHAIN_ACCESS_GROUP {
o.set_access_group(group);
}
o
}
fn missing_entitlement_for_touch_id_protected_item(e: &SecError) -> bool {
if e.code() == ERR_SEC_MISSING_ENTITLEMENT {
return true;
}
e.message()
.map(|m| m.to_lowercase().contains("entitlement"))
.unwrap_or(false)
}
fn store_password_plain_keyring(profile: &str, password: &str) -> SafeResult<()> {
let entry = keyring::Entry::new(SERVICE_NAME, profile).map_err(|e| SafeError::Crypto {
context: format!("keyring init: {e}"),
})?;
entry
.set_password(password)
.map_err(|e| SafeError::Crypto {
context: format!("keyring store: {e}"),
})?;
Ok(())
}
pub fn store_password(profile: &str, password: &str) -> SafeResult<()> {
let _ = delete_generic_password_options(data_protection_options(SERVICE_NAME, profile));
let _ = delete_generic_password_options(plain_options(SERVICE_NAME, profile));
let _ = keyring::Entry::new(SERVICE_NAME, profile).and_then(|e| e.delete_credential());
let mut options = data_protection_options(SERVICE_NAME, profile);
options.set_access_control_options(AccessControlOptions::BIOMETRY_CURRENT_SET);
match set_generic_password_options(password.as_bytes(), options) {
Ok(()) => Ok(()),
Err(e_acl) => {
match store_password_plain_keyring(profile, password) {
Ok(()) => {
if missing_entitlement_for_touch_id_protected_item(&e_acl) {
eprintln!(
"tsafe: note: Touch ID–protected keychain items are not available for this build (OS reports missing keychain entitlement). Stored in the login keychain without per-read biometric (quick unlock still works when your session can access the keychain)."
);
} else {
eprintln!(
"tsafe: note: could not store with Touch ID–protected keychain item ({e_acl}). Stored using standard login keychain storage instead."
);
}
Ok(())
}
Err(e_plain) => Err(SafeError::Crypto {
context: format!(
"keychain: could not store credential (primary: {e_acl}; fallback: {e_plain})"
),
}),
}
}
}
}
pub fn retrieve_password(profile: &str) -> SafeResult<Option<String>> {
unsafe {
let _ = SecKeychainSetUserInteractionAllowed(1);
}
fn retrieve_plain(profile: &str) -> SafeResult<Option<String>> {
match generic_password(plain_options(SERVICE_NAME, profile)) {
Ok(bytes) => String::from_utf8(bytes)
.map(Some)
.map_err(|e| SafeError::Crypto {
context: format!("keychain password is not valid UTF-8: {e}"),
}),
Err(e2) if e2.code() == errSecItemNotFound => Ok(None),
Err(e2) => Err(SafeError::Crypto {
context: format!("keychain retrieve: {e2}"),
}),
}
}
match generic_password(data_protection_options(SERVICE_NAME, profile)) {
Ok(bytes) => String::from_utf8(bytes)
.map(Some)
.map_err(|e| SafeError::Crypto {
context: format!("keychain password is not valid UTF-8: {e}"),
}),
Err(e) if e.code() == errSecItemNotFound => retrieve_plain(profile),
Err(e) if missing_entitlement_for_touch_id_protected_item(&e) => retrieve_plain(profile),
Err(e) => Err(SafeError::Crypto {
context: format!("keychain retrieve: {e}"),
}),
}
}
pub fn remove_password(profile: &str) -> SafeResult<()> {
let d1 = delete_generic_password_options(data_protection_options(SERVICE_NAME, profile));
let d2 = delete_generic_password_options(plain_options(SERVICE_NAME, profile));
let mut sec_err: Option<SecError> = None;
for (idx, r) in [d1, d2].into_iter().enumerate() {
match r {
Ok(()) => return Ok(()),
Err(e) if e.code() == errSecItemNotFound => {}
Err(e) if idx == 0 && missing_entitlement_for_touch_id_protected_item(&e) => {}
Err(e) => sec_err = Some(e),
}
}
if let Some(e) = sec_err {
return Err(SafeError::Crypto {
context: format!("keychain delete: {e}"),
});
}
let entry = keyring::Entry::new(SERVICE_NAME, profile).map_err(|e| SafeError::Crypto {
context: format!("keyring init: {e}"),
})?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(KeyringError::NoEntry) => Ok(()),
Err(ke) => Err(SafeError::Crypto {
context: format!("keyring delete: {ke}"),
}),
}
}
pub fn has_password(profile: &str) -> bool {
item_exists_skip_ui(SERVICE_NAME, profile, true).unwrap_or(false)
|| item_exists_skip_ui(SERVICE_NAME, profile, false).unwrap_or(false)
}
pub(super) fn quick_unlock_storage_note(profile: &str) -> Option<String> {
let in_dp = item_exists_skip_ui(SERVICE_NAME, profile, true).unwrap_or(false);
if in_dp {
return Some(
"macOS storage tier: data-protection keychain (matches signed-release + ACL path; Touch ID / Watch / passcode per read when the OS allows)."
.to_string(),
);
}
let in_legacy = item_exists_skip_ui(SERVICE_NAME, profile, false).unwrap_or(false);
if in_legacy {
return Some(
"macOS storage tier: login keychain fallback (no data-protection item found for this profile). Per-read Touch ID may not apply — common for unsigned `cargo install` / dev builds; try `biometric disable` then `biometric enable` after installing a signed release, or see docs/features/biometric.md."
.to_string(),
);
}
None
}
fn item_exists_skip_ui(
service: &str,
account: &str,
data_protection: bool,
) -> Result<bool, SecError> {
let mut pairs: Vec<(CFString, CFType)> = vec![
(
unsafe { CFString::wrap_under_get_rule(kSecClass) },
unsafe { CFString::wrap_under_get_rule(kSecClassGenericPassword).into_CFType() },
),
(
unsafe { CFString::wrap_under_get_rule(kSecAttrService) },
CFString::from(service).into_CFType(),
),
(
unsafe { CFString::wrap_under_get_rule(kSecAttrAccount) },
CFString::from(account).into_CFType(),
),
(
unsafe { CFString::wrap_under_get_rule(kSecReturnAttributes) },
CFBoolean::from(true).into_CFType(),
),
(
unsafe { CFString::wrap_under_get_rule(kSecReturnData) },
CFBoolean::from(false).into_CFType(),
),
(
unsafe { CFString::wrap_under_get_rule(kSecUseAuthenticationUI) },
unsafe { CFString::wrap_under_get_rule(kSecUseAuthenticationUISkip).into_CFType() },
),
];
if data_protection {
pairs.push((
unsafe { CFString::wrap_under_get_rule(kSecUseDataProtectionKeychain) },
CFBoolean::from(true).into_CFType(),
));
}
let params = CFDictionary::from_CFType_pairs(&pairs);
let mut ret: CFTypeRef = std::ptr::null();
let status = unsafe { SecItemCopyMatching(params.as_concrete_TypeRef(), &mut ret) };
if !ret.is_null() {
unsafe {
CFRelease(ret);
}
}
match status {
0 => Ok(true),
e if e == errSecItemNotFound => Ok(false),
e if e == ERR_SEC_INTERACTION_NOT_ALLOWED => Ok(true),
e => Err(SecError::from_code(e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_entitlement_code_triggers_fallback_path() {
let e = SecError::from_code(ERR_SEC_MISSING_ENTITLEMENT);
assert!(missing_entitlement_for_touch_id_protected_item(&e));
}
#[test]
fn biometry_current_set_constant_has_expected_value() {
assert_eq!(
SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET, 0x08,
"kSecAccessControlBiometryCurrentSet must equal 1<<3 (0x08) per Apple Security headers"
);
assert_eq!(
AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET,
"AccessControlOptions::BIOMETRY_CURRENT_SET bits must match SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET"
);
}
#[test]
fn biometry_current_set_is_not_user_presence() {
assert_ne!(
AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
AccessControlOptions::USER_PRESENCE.bits(),
"BIOMETRY_CURRENT_SET and USER_PRESENCE must be distinct flag values"
);
}
#[test]
#[ignore = "requires: macos-signed-hardware — signed binary + Touch ID enrollment"]
fn store_password_uses_biometry_current_set() {
let profile = "tsafe-e4-2-test-biometry-current-set";
let password = "hunter2-test-only";
let _ = remove_password(profile);
store_password(profile, password).expect("store_password must succeed on signed macOS");
assert!(
has_password(profile),
"has_password must return true after store_password"
);
remove_password(profile).expect("remove_password must succeed");
assert!(
!has_password(profile),
"has_password must be false after removal"
);
}
#[test]
#[ignore = "requires: macos-signed-hardware — signed binary + Touch ID enrollment"]
fn re_enroll_stores_with_updated_acl() {
let profile = "tsafe-e4-2-test-reenroll";
let old_password = "old-password-stale";
let new_password = "new-password-fresh";
let _ = remove_password(profile);
store_password(profile, old_password).expect("initial store must succeed");
assert!(has_password(profile));
remove_password(profile).expect("remove stale credential");
store_password(profile, new_password).expect("re-enroll store must succeed");
assert!(
has_password(profile),
"credential must be present after re-enroll"
);
remove_password(profile).expect("cleanup");
assert!(!has_password(profile));
}
}