keyring-manager 0.10.0

Cross-platform library for managing passwords
Documentation
// Accessibility tests for the iOS keychain backend. These live outside tests/mod.rs because the
// other four backends have no protection classes.
//
// The class is read back through security-framework's search API rather than the raw query the
// backend writes with, so a bug in the backend's own FFI shows up as a disagreement between the
// two paths rather than as a test that agrees with itself.
//
// The simulator has no lock screen and does not enforce protection classes, so these prove what
// was written and that the migration is idempotent and non-destructive. Locked-device behavior is
// ios_locked.rs, driven by hand on a physical phone.

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);
}

/// Write an item with no accessibility attribute, as a build predating the invariant did.
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");

    // idempotent, and promotion never forks the item
    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);
}

/// A promoted item must stop being promoted. If the read cannot see the class it holds, every read
/// rewrites the item forever and only a counter shows it.
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);

    // write over an already-promoted item
    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);

    // write over a legacy item with no read first
    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);
}

/// An older build's write path cannot revert the class, and a delete plus a legacy write recreates
/// an unmarked item, so the item's own attribute is the only migration state that cannot go stale.
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");

    // an item deleted and written again by an older build is unmarked and must be re-promoted
    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);

    // an unlocked simulator can prove absence, and the probe that proves it leaves nothing behind
    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");

    // a failed repair is inert: it must not disturb an unrelated item
    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);
}