use crate::error::{Result, SecurityError};
use crate::ffi;
use crate::private::{
cf_data, cf_data_to_vec, cf_dictionary_get_value, cf_dictionary_set_value,
cf_mutable_dictionary, cf_string, cf_string_to_string, sec_error_message, OwnedCf,
};
fn generic_password_query(account: Option<&str>, service: &str) -> Result<OwnedCf> {
let dictionary = cf_mutable_dictionary(3)?;
let service = cf_string(service)?;
unsafe {
cf_dictionary_set_value(
dictionary.as_mut_dictionary(),
ffi::kSecClass,
ffi::kSecClassGenericPassword.cast(),
);
cf_dictionary_set_value(
dictionary.as_mut_dictionary(),
ffi::kSecAttrService,
service.as_ptr(),
);
}
if let Some(account) = account {
let account = cf_string(account)?;
unsafe {
cf_dictionary_set_value(
dictionary.as_mut_dictionary(),
ffi::kSecAttrAccount,
account.as_ptr(),
);
}
}
Ok(dictionary)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct KeychainEntry {
account: String,
service: String,
}
impl KeychainEntry {
#[must_use]
pub fn new(account: impl Into<String>, service: impl Into<String>) -> Self {
Self {
account: account.into(),
service: service.into(),
}
}
#[must_use]
pub fn account(&self) -> &str {
&self.account
}
#[must_use]
pub fn service(&self) -> &str {
&self.service
}
pub fn set(&self, password: &str) -> Result<()> {
Keychain::set(&self.account, &self.service, password)
}
pub fn get(&self) -> Result<String> {
Keychain::get(&self.account, &self.service)
}
pub fn delete(&self) -> Result<()> {
Keychain::delete(&self.account, &self.service)
}
}
pub struct Keychain;
impl Keychain {
#[must_use]
pub fn entry(account: impl Into<String>, service: impl Into<String>) -> KeychainEntry {
KeychainEntry::new(account, service)
}
pub fn set(account: &str, service: &str, password: &str) -> Result<()> {
let search_query = generic_password_query(Some(account), service)?;
let add_query = generic_password_query(Some(account), service)?;
let password_data = cf_data(password.as_bytes())?;
unsafe {
cf_dictionary_set_value(
add_query.as_mut_dictionary(),
ffi::kSecValueData,
password_data.as_ptr(),
);
}
let status = unsafe { ffi::SecItemAdd(add_query.as_dictionary(), std::ptr::null_mut()) };
match status {
ffi::status::SUCCESS => Ok(()),
ffi::status::DUPLICATE_ITEM => {
let attributes = cf_mutable_dictionary(1)?;
unsafe {
cf_dictionary_set_value(
attributes.as_mut_dictionary(),
ffi::kSecValueData,
password_data.as_ptr(),
);
}
let status = unsafe {
ffi::SecItemUpdate(search_query.as_dictionary(), attributes.as_dictionary())
};
if status == ffi::status::SUCCESS {
Ok(())
} else {
Err(SecurityError::from_status(
"SecItemUpdate",
status,
sec_error_message(status),
))
}
}
_ => Err(SecurityError::from_status(
"SecItemAdd",
status,
sec_error_message(status),
)),
}
}
pub fn get(account: &str, service: &str) -> Result<String> {
let query = generic_password_query(Some(account), service)?;
unsafe {
cf_dictionary_set_value(
query.as_mut_dictionary(),
ffi::kSecReturnData,
ffi::kCFBooleanTrue.cast(),
);
cf_dictionary_set_value(
query.as_mut_dictionary(),
ffi::kSecMatchLimit,
ffi::kSecMatchLimitOne.cast(),
);
}
let mut result = std::ptr::null();
let status = unsafe { ffi::SecItemCopyMatching(query.as_dictionary(), &mut result) };
if status != ffi::status::SUCCESS {
let context = format!(
"generic password {account:?} @ {service:?}: {}",
sec_error_message(status)
);
return Err(SecurityError::from_status(
"SecItemCopyMatching",
status,
context,
));
}
let data = OwnedCf::new(result);
if crate::private::cf_type_id(data.as_ptr()) != unsafe { ffi::CFDataGetTypeID() } {
return Err(SecurityError::UnexpectedType {
operation: "SecItemCopyMatching",
expected: "CFData",
});
}
String::from_utf8(cf_data_to_vec(data.as_data())).map_err(|error| {
SecurityError::InvalidArgument(format!("keychain password is not valid UTF-8: {error}"))
})
}
pub fn delete(account: &str, service: &str) -> Result<()> {
let query = generic_password_query(Some(account), service)?;
let status = unsafe { ffi::SecItemDelete(query.as_dictionary()) };
match status {
ffi::status::SUCCESS | ffi::status::ITEM_NOT_FOUND => Ok(()),
_ => Err(SecurityError::from_status(
"SecItemDelete",
status,
sec_error_message(status),
)),
}
}
pub fn list_accounts(service: &str) -> Result<Vec<String>> {
let query = generic_password_query(None, service)?;
unsafe {
cf_dictionary_set_value(
query.as_mut_dictionary(),
ffi::kSecReturnAttributes,
ffi::kCFBooleanTrue.cast(),
);
cf_dictionary_set_value(
query.as_mut_dictionary(),
ffi::kSecMatchLimit,
ffi::kSecMatchLimitAll.cast(),
);
}
let mut result = std::ptr::null();
let status = unsafe { ffi::SecItemCopyMatching(query.as_dictionary(), &mut result) };
if status == ffi::status::ITEM_NOT_FOUND {
return Ok(Vec::new());
}
if status != ffi::status::SUCCESS {
return Err(SecurityError::from_status(
"SecItemCopyMatching",
status,
sec_error_message(status),
));
}
let result = OwnedCf::new(result);
let dictionary_type = unsafe { ffi::CFDictionaryGetTypeID() };
let array_type = unsafe { ffi::CFArrayGetTypeID() };
let result_type = crate::private::cf_type_id(result.as_ptr());
let mut accounts = Vec::new();
if result_type == dictionary_type {
if let Some(account) = account_from_attributes(result.as_dictionary()) {
accounts.push(account);
}
} else if result_type == array_type {
let count = unsafe { ffi::CFArrayGetCount(result.as_array()) };
let count = usize::try_from(count).unwrap_or_default();
for index in 0..count {
let Ok(index) = isize::try_from(index) else {
continue;
};
let value = unsafe { ffi::CFArrayGetValueAtIndex(result.as_array(), index) };
if value.is_null() {
continue;
}
if let Some(account) = account_from_attributes(value.cast()) {
accounts.push(account);
}
}
} else {
return Err(SecurityError::UnexpectedType {
operation: "SecItemCopyMatching",
expected: "CFDictionary or CFArray",
});
}
accounts.sort();
accounts.dedup();
Ok(accounts)
}
}
fn account_from_attributes(dictionary: ffi::CFDictionaryRef) -> Option<String> {
let value = cf_dictionary_get_value(dictionary, unsafe { ffi::kSecAttrAccount });
if value.is_null() || crate::private::cf_type_id(value) != unsafe { ffi::CFStringGetTypeID() } {
return None;
}
cf_string_to_string(value.cast())
}