use crate::tests::*;
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use log::*;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
use security_framework::passwords::{
delete_generic_password, get_generic_password, set_generic_password,
};
use security_framework_sys::access_control::{
kSecAttrAccessibleAfterFirstUnlock, kSecAttrAccessibleWhenUnlocked,
};
#[allow(non_upper_case_globals)]
#[link(name = "Security", kind = "framework")]
extern "C" {
static kSecAttrAccessible: CFStringRef;
}
static ACCESS_KEY: &str = "accessibility@keychain-rs.io";
static ACCESS_KEY2: &str = "accessibility2@keychain-rs.io";
fn cf_static(key: CFStringRef) -> String {
unsafe { CFString::wrap_under_get_rule(key) }.to_string()
}
fn accessibility_attr() -> String {
cf_static(unsafe { kSecAttrAccessible })
}
pub(super) fn target_class() -> String {
cf_static(unsafe { kSecAttrAccessibleAfterFirstUnlock })
}
pub(super) fn legacy_class() -> String {
cf_static(unsafe { kSecAttrAccessibleWhenUnlocked })
}
pub(super) fn account_name(service: &str, key: &str) -> String {
[
crate::escape(TEST_APPLICATION),
crate::escape(service),
crate::escape(key),
]
.join("\t")
}
pub(super) fn item_class(account: &str) -> Option<String> {
let results = ItemSearchOptions::new()
.class(ItemClass::generic_password())
.account(account)
.load_attributes(true)
.limit(1)
.search()
.ok()?;
let attrs = results.first()?.simplify_dict()?;
let found = attrs.get(&accessibility_attr()).cloned();
if found.is_none() {
warn!("no {} attribute in {:?}", accessibility_attr(), attrs);
}
found
}
fn all_item_count() -> usize {
ItemSearchOptions::new()
.class(ItemClass::generic_password())
.load_attributes(true)
.limit(Limit::All)
.search()
.map(|r| r.len())
.unwrap_or(0)
}
fn item_count(account: &str) -> usize {
ItemSearchOptions::new()
.class(ItemClass::generic_password())
.account(account)
.load_attributes(true)
.limit(Limit::All)
.search()
.map(|r| r.len())
.unwrap_or(0)
}
fn assert_class(account: &str, expected: &str, what: &str) {
let actual = item_class(account);
info!(
"{}: class expected {:?} actual {:?}",
what, expected, actual
);
assert_eq!(actual.as_deref(), Some(expected), "{}", what);
}
fn seed_legacy(account: &str, value: &str) {
let _ = delete_generic_password("", account);
set_generic_password("", account, value.as_bytes()).unwrap();
assert_class(account, &legacy_class(), "seeded legacy item");
}
fn clear(manager: &KeyringManager, key: &str) {
let _ = manager.with_keyring(TEST_SERVICE, key, |kr| kr.delete_value());
}
pub fn exec_test_accessibility_born_current(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
clear(manager, ACCESS_KEY);
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.set_value("fresh"))
.unwrap();
assert_class(&account, &target_class(), "fresh write");
assert_eq!(item_count(&account), 1);
clear(manager, ACCESS_KEY);
}
pub fn exec_test_accessibility_promotion_on_read(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
clear(manager, ACCESS_KEY);
seed_legacy(&account, "legacy value");
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
info!("read of legacy item: {:?}", out);
assert_eq!(out, "legacy value");
assert_class(&account, &target_class(), "after promoting read");
for _ in 0..2 {
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
assert_eq!(out, "legacy value");
}
assert_class(&account, &target_class(), "after repeated reads");
let count = item_count(&account);
info!("item count expected 1 actual {}", count);
assert_eq!(count, 1);
clear(manager, ACCESS_KEY);
}
pub fn exec_test_accessibility_promotion_converges(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
let read = || {
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap()
};
clear(manager, ACCESS_KEY);
seed_legacy(&account, "legacy value");
read();
let promoted = crate::ios::promotion_count();
for _ in 0..5 {
read();
}
let after = crate::ios::promotion_count();
info!(
"promotions across 5 reads of a promoted item: {}",
after - promoted
);
assert_eq!(after, promoted, "a promoted item was promoted again");
clear(manager, ACCESS_KEY);
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.set_value("fresh"))
.unwrap();
let written = crate::ios::promotion_count();
for _ in 0..5 {
read();
}
let after = crate::ios::promotion_count();
info!(
"promotions across 5 reads of a fresh item: {}",
after - written
);
assert_eq!(after, written, "a fresh item was promoted");
clear(manager, ACCESS_KEY);
}
pub fn exec_test_accessibility_data_integrity(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
let fixtures = [
"", "\0", "\t", "\n", "\r", " ", "\\n ", "\\n\r ", ";", "$", "\\", "*",
];
for pass in fixtures {
clear(manager, ACCESS_KEY);
seed_legacy(&account, pass);
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
info!(
"fixture expected {:?} actual {:?}",
pass.as_bytes(),
out.as_bytes()
);
assert_eq!(out.as_bytes(), pass.as_bytes());
assert_class(&account, &target_class(), "after promoting read of fixture");
}
clear(manager, ACCESS_KEY);
}
pub fn exec_test_accessibility_write_paths(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
clear(manager, ACCESS_KEY);
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.set_value("one"))
.unwrap();
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.set_value("two"))
.unwrap();
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
info!("rewrite expected \"two\" actual {:?}", out);
assert_eq!(out, "two");
assert_class(&account, &target_class(), "after rewrite");
assert_eq!(item_count(&account), 1);
clear(manager, ACCESS_KEY);
seed_legacy(&account, "legacy value");
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.set_value("replaced"))
.unwrap();
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
info!("legacy overwrite expected \"replaced\" actual {:?}", out);
assert_eq!(out, "replaced");
assert_class(&account, &target_class(), "after overwriting legacy item");
assert_eq!(item_count(&account), 1);
clear(manager, ACCESS_KEY);
}
pub fn exec_test_accessibility_downgrade(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
clear(manager, ACCESS_KEY);
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.set_value("current"))
.unwrap();
assert_class(&account, &target_class(), "before downgrade");
set_generic_password("", &account, b"written by old build").unwrap();
let raw = get_generic_password("", &account).unwrap();
info!("old build read back {:?}", String::from_utf8_lossy(&raw));
assert_eq!(raw, b"written by old build");
assert_class(&account, &target_class(), "after old build wrote");
delete_generic_password("", &account).unwrap();
seed_legacy(&account, "recreated");
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
assert_eq!(out, "recreated");
assert_class(&account, &target_class(), "after re-promotion");
clear(manager, ACCESS_KEY);
}
pub fn exec_test_accessibility_absent(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
let account2 = account_name(TEST_SERVICE, ACCESS_KEY2);
clear(manager, ACCESS_KEY);
clear(manager, ACCESS_KEY2);
let before = all_item_count();
let res = manager.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value());
info!("read of absent key: {:?}", res.as_ref().err());
assert!(matches!(res, Err(KeyringError::NoPasswordFound)));
assert_eq!(item_count(&account), 0);
let after = all_item_count();
info!("items before {} after {}", before, after);
assert_eq!(before, after, "the absence probe left an item behind");
manager
.with_keyring(TEST_SERVICE, ACCESS_KEY2, |kr| kr.set_value("untouched"))
.unwrap();
assert!(!crate::ios::promote_accessibility(&account));
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY2, |kr| kr.get_value())
.unwrap();
info!("neighbor after failed repair: {:?}", out);
assert_eq!(out, "untouched");
assert_class(&account2, &target_class(), "neighbor after failed repair");
assert_eq!(item_count(&account), 0);
clear(manager, ACCESS_KEY2);
}
pub fn exec_test_accessibility_concurrent(manager: &KeyringManager) {
let account = account_name(TEST_SERVICE, ACCESS_KEY);
clear(manager, ACCESS_KEY);
seed_legacy(&account, "shared");
std::thread::scope(|s| {
for _ in 0..2 {
s.spawn(|| {
for _ in 0..100 {
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
assert_eq!(out, "shared");
}
});
}
});
let out = manager
.with_keyring(TEST_SERVICE, ACCESS_KEY, |kr| kr.get_value())
.unwrap();
info!("after concurrent reads: {:?}", out);
assert_eq!(out, "shared");
assert_class(&account, &target_class(), "after concurrent reads");
let count = item_count(&account);
info!("item count expected 1 actual {}", count);
assert_eq!(count, 1);
clear(manager, ACCESS_KEY);
}