#![allow(
dead_code,
unused_imports,
unused_qualifications,
unreachable_patterns,
deprecated
)]
use crate::internal::core::{Error, Result};
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use rand::TryRngCore;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
use super::ffi;
pub(crate) const WRAP_MAGIC: &[u8; 4] = b"EHW1";
pub(crate) const WRAP_KEY_LEN: usize = 32;
pub(crate) const WRAP_NONCE_LEN: usize = 12;
pub(crate) const WRAP_TAG_LEN: usize = 16;
pub(crate) const WRAP_MIN_LEN: usize = WRAP_MAGIC.len() + WRAP_NONCE_LEN + WRAP_TAG_LEN;
#[must_use]
pub fn is_wrapped_handle(bytes: &[u8]) -> bool {
bytes.len() >= WRAP_MAGIC.len() && &bytes[..WRAP_MAGIC.len()] == WRAP_MAGIC
}
#[must_use]
pub(crate) fn generate_wrapping_key() -> [u8; WRAP_KEY_LEN] {
let mut key = [0_u8; WRAP_KEY_LEN];
rand::rngs::OsRng
.try_fill_bytes(&mut key)
.expect("OS RNG must succeed");
key
}
pub(crate) fn encrypt_blob(wrapping_key: &[u8; WRAP_KEY_LEN], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(wrapping_key).map_err(|e| Error::KeyOperation {
operation: "keychain_wrap_encrypt".into(),
detail: format!("Aes256Gcm::new: {e}"),
})?;
let mut nonce_bytes = [0_u8; WRAP_NONCE_LEN];
rand::rngs::OsRng
.try_fill_bytes(&mut nonce_bytes)
.map_err(|e| Error::KeyOperation {
operation: "keychain_wrap_encrypt".into(),
detail: format!("OS RNG failed: {e}"),
})?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ct = cipher
.encrypt(nonce, plaintext)
.map_err(|e| Error::KeyOperation {
operation: "keychain_wrap_encrypt".into(),
detail: format!("AES-GCM encrypt: {e}"),
})?;
let mut out = Vec::with_capacity(WRAP_MAGIC.len() + nonce_bytes.len() + ct.len());
out.extend_from_slice(WRAP_MAGIC);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
Ok(out)
}
pub(crate) fn decrypt_blob(wrapping_key: &[u8; WRAP_KEY_LEN], blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < WRAP_MIN_LEN {
return Err(Error::KeyOperation {
operation: "keychain_wrap_decrypt".into(),
detail: format!(
"wrapped blob too short: {} bytes, need >= {WRAP_MIN_LEN}",
blob.len()
),
});
}
if !is_wrapped_handle(blob) {
return Err(Error::KeyOperation {
operation: "keychain_wrap_decrypt".into(),
detail: "wrapped blob missing magic prefix".into(),
});
}
let nonce_start = WRAP_MAGIC.len();
let ct_start = nonce_start + WRAP_NONCE_LEN;
let nonce = Nonce::from_slice(&blob[nonce_start..ct_start]);
let ct_and_tag = &blob[ct_start..];
let cipher = Aes256Gcm::new_from_slice(wrapping_key).map_err(|e| Error::KeyOperation {
operation: "keychain_wrap_decrypt".into(),
detail: format!("Aes256Gcm::new: {e}"),
})?;
cipher
.decrypt(nonce, ct_and_tag)
.map_err(|e| Error::KeyOperation {
operation: "keychain_wrap_decrypt".into(),
detail: format!("AES-GCM decrypt: {e}"),
})
}
pub fn service_name_for(app_name: &str) -> String {
let safe = crate::internal::apple::signing::ensure_safe_app_name(app_name);
format!("com.godaddy.{safe}")
}
struct LockedWrappingKey {
key: Box<Zeroizing<[u8; WRAP_KEY_LEN]>>,
inserted_at: Instant,
mlocked: bool,
}
impl LockedWrappingKey {
fn new(bytes: [u8; WRAP_KEY_LEN]) -> Self {
let boxed = Box::new(Zeroizing::new(bytes));
let ptr = (**boxed).as_ptr();
let mlocked = crate::internal::core::process::mlock_buffer(ptr, WRAP_KEY_LEN);
Self {
key: boxed,
inserted_at: Instant::now(),
mlocked,
}
}
fn bytes(&self) -> [u8; WRAP_KEY_LEN] {
**self.key
}
fn age(&self) -> Duration {
self.inserted_at.elapsed()
}
}
impl Drop for LockedWrappingKey {
fn drop(&mut self) {
if self.mlocked {
let ptr = (**self.key).as_ptr();
let _ = crate::internal::core::process::munlock_buffer(ptr, WRAP_KEY_LEN);
}
}
}
type CacheMap = HashMap<(String, String), LockedWrappingKey>;
fn cache() -> &'static Mutex<CacheMap> {
static CACHE: OnceLock<Mutex<CacheMap>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cache_lookup(app_name: &str, label: &str, ttl: Duration) -> Option<[u8; WRAP_KEY_LEN]> {
if ttl.is_zero() {
return None;
}
let mut guard = cache().lock().ok()?;
let key = (app_name.to_string(), label.to_string());
if let Some(entry) = guard.get(&key) {
if entry.age() < ttl {
return Some(entry.bytes());
}
}
guard.remove(&key);
None
}
fn cache_insert(app_name: &str, label: &str, bytes: [u8; WRAP_KEY_LEN], ttl: Duration) {
if ttl.is_zero() {
return;
}
if let Ok(mut guard) = cache().lock() {
guard.insert(
(app_name.to_string(), label.to_string()),
LockedWrappingKey::new(bytes),
);
}
}
fn cache_evict(app_name: &str, label: &str) {
if let Ok(mut guard) = cache().lock() {
guard.remove(&(app_name.to_string(), label.to_string()));
}
#[cfg(any(feature = "signing", feature = "encryption"))]
crate::internal::apple::lacontext::evict(app_name, label);
}
pub fn cache_evict_for(app_name: &str, label: &str) {
cache_evict(app_name, label);
}
#[allow(unsafe_code)]
fn keychain_store_ffi(
app_name: &str,
label: &str,
wrapping_key: &[u8; WRAP_KEY_LEN],
use_user_presence: bool,
access_group: Option<&str>,
) -> Result<()> {
let service = service_name_for(app_name);
let service_bytes = service.as_bytes();
let account_bytes = label.as_bytes();
let service_len = i32::try_from(service_bytes.len()).map_err(|_| Error::KeyOperation {
operation: "keychain_store".into(),
detail: "service name too long".into(),
})?;
let account_len = i32::try_from(account_bytes.len()).map_err(|_| Error::KeyOperation {
operation: "keychain_store".into(),
detail: "account name too long".into(),
})?;
let secret_len = i32::try_from(wrapping_key.len()).map_err(|_| Error::KeyOperation {
operation: "keychain_store".into(),
detail: "secret length too long".into(),
})?;
let access_group_bytes = access_group.map(str::as_bytes);
let (access_group_ptr, access_group_len) = match access_group_bytes {
Some(bytes) => {
let len = i32::try_from(bytes.len()).map_err(|_| Error::KeyOperation {
operation: "keychain_store".into(),
detail: "access group too long".into(),
})?;
(bytes.as_ptr(), len)
}
None => (std::ptr::null(), 0),
};
let rc = unsafe {
ffi::enclaveapp_keychain_store(
service_bytes.as_ptr(),
service_len,
account_bytes.as_ptr(),
account_len,
wrapping_key.as_ptr(),
secret_len,
if use_user_presence { 1 } else { 0 },
access_group_ptr,
access_group_len,
)
};
if rc == 0 {
Ok(())
} else {
Err(Error::KeyOperation {
operation: "keychain_store".into(),
detail: format!("Swift bridge returned error code {rc}"),
})
}
}
pub(crate) fn keychain_store(
app_name: &str,
label: &str,
wrapping_key: &[u8; WRAP_KEY_LEN],
use_user_presence: bool,
access_group: Option<&str>,
) -> Result<()> {
keychain_store_ffi(
app_name,
label,
wrapping_key,
use_user_presence,
access_group,
)?;
cache_evict(app_name, label);
Ok(())
}
#[allow(unsafe_code)]
pub(crate) fn keychain_load(
app_name: &str,
label: &str,
cache_ttl: Duration,
access_group: Option<&str>,
lacontext_token: u64,
) -> Result<Option<[u8; WRAP_KEY_LEN]>> {
if let Some(cached) = cache_lookup(app_name, label, cache_ttl) {
return Ok(Some(cached));
}
let service = service_name_for(app_name);
let service_bytes = service.as_bytes();
let account_bytes = label.as_bytes();
let service_len = service_bytes.len() as i32;
let account_len = account_bytes.len() as i32;
let (access_group_ptr, access_group_len) = match access_group {
Some(group) => {
let bytes = group.as_bytes();
let len = i32::try_from(bytes.len()).map_err(|_| Error::KeyOperation {
operation: "keychain_load".into(),
detail: "access group too long".into(),
})?;
(bytes.as_ptr(), len)
}
None => (std::ptr::null(), 0),
};
let mut out = [0_u8; WRAP_KEY_LEN];
let mut out_len: i32 = out.len() as i32;
let rc = unsafe {
ffi::enclaveapp_keychain_load(
service_bytes.as_ptr(),
service_len,
account_bytes.as_ptr(),
account_len,
out.as_mut_ptr(),
&mut out_len,
access_group_ptr,
access_group_len,
lacontext_token,
)
};
match rc {
0 => {
if out_len as usize != WRAP_KEY_LEN {
return Err(Error::KeyOperation {
operation: "keychain_load".into(),
detail: format!(
"loaded wrapping key has unexpected length {out_len}, expected {WRAP_KEY_LEN}"
),
});
}
cache_insert(app_name, label, out, cache_ttl);
Ok(Some(out))
}
12 => Ok(None), 13 => Err(Error::KeychainAuthDenied {
label: label.to_string(),
}),
14 => Err(Error::KeychainInteractionRequired {
label: label.to_string(),
}),
15 => Err(Error::KeychainNoWindowServer {
label: label.to_string(),
}),
16 => Err(Error::UserCancelled {
label: label.to_string(),
}),
_ => Err(Error::KeyOperation {
operation: "keychain_load".into(),
detail: format!("Swift bridge returned error code {rc}"),
}),
}
}
pub fn generate_and_wrap(
app_name: &str,
label: &str,
plaintext: &[u8],
use_user_presence: bool,
access_group: Option<&str>,
) -> Result<Vec<u8>> {
let wrapping_key = generate_wrapping_key();
keychain_store(
app_name,
label,
&wrapping_key,
use_user_presence,
access_group,
)?;
match encrypt_blob(&wrapping_key, plaintext) {
Ok(blob) => Ok(blob),
Err(error) => {
drop(keychain_delete(app_name, label, access_group));
Err(error)
}
}
}
pub fn decrypt_with_cached_key(
app_name: &str,
label: &str,
blob: &[u8],
cache_ttl: Duration,
access_group: Option<&str>,
lacontext_token: u64,
use_user_presence: Option<bool>,
) -> Result<Option<Vec<u8>>> {
let was_cached = cache_lookup(app_name, label, cache_ttl).is_some();
match keychain_load(app_name, label, cache_ttl, access_group, lacontext_token)? {
Some(wrapping_key) => {
if !was_cached {
if let Some(up) = use_user_presence {
if let Err(e) =
keychain_store_ffi(app_name, label, &wrapping_key, up, access_group)
{
tracing::warn!(
label = label,
error = %e,
"protection-class migration re-store failed; \
item retains old protection class until next successful load"
);
}
}
}
let plaintext = decrypt_blob(&wrapping_key, blob)?;
Ok(Some(plaintext))
}
None => Ok(None),
}
}
pub(crate) fn relabel_wrapping_key(
app_name: &str,
old_label: &str,
new_label: &str,
use_user_presence: bool,
access_group: Option<&str>,
) -> Result<()> {
if old_label == new_label {
return Ok(());
}
let wrapping_key = keychain_load(app_name, old_label, Duration::ZERO, access_group, 0)?
.ok_or_else(|| Error::KeyNotFound {
label: old_label.to_string(),
})?;
if keychain_load(app_name, new_label, Duration::ZERO, access_group, 0)?.is_some() {
return Err(Error::DuplicateLabel {
label: new_label.to_string(),
});
}
keychain_store(
app_name,
new_label,
&wrapping_key,
use_user_presence,
access_group,
)?;
drop(keychain_delete(app_name, old_label, access_group));
Ok(())
}
#[allow(unsafe_code)]
pub fn keychain_delete(app_name: &str, label: &str, access_group: Option<&str>) -> Result<()> {
cache_evict(app_name, label);
let service = service_name_for(app_name);
let service_bytes = service.as_bytes();
let account_bytes = label.as_bytes();
let (access_group_ptr, access_group_len) = match access_group {
Some(group) => {
let bytes = group.as_bytes();
let len = i32::try_from(bytes.len()).map_err(|_| Error::KeyOperation {
operation: "keychain_delete".into(),
detail: "access group too long".into(),
})?;
(bytes.as_ptr(), len)
}
None => (std::ptr::null(), 0),
};
let rc = unsafe {
ffi::enclaveapp_keychain_delete(
service_bytes.as_ptr(),
service_bytes.len() as i32,
account_bytes.as_ptr(),
account_bytes.len() as i32,
access_group_ptr,
access_group_len,
)
};
if rc == 0 || rc == 12 {
Ok(())
} else {
Err(Error::KeyOperation {
operation: "keychain_delete".into(),
detail: format!("Swift bridge returned error code {rc}"),
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn magic_prefix_is_exactly_four_bytes() {
assert_eq!(WRAP_MAGIC.len(), 4);
assert_eq!(WRAP_MAGIC, b"EHW1");
}
#[test]
fn is_wrapped_handle_matches_magic() {
assert!(is_wrapped_handle(b"EHW1...rest-of-blob-doesnt-matter"));
}
#[test]
fn is_wrapped_handle_rejects_short_input() {
assert!(!is_wrapped_handle(b""));
assert!(!is_wrapped_handle(b"EHW"));
}
#[test]
fn is_wrapped_handle_rejects_legacy_plaintext() {
assert!(!is_wrapped_handle(b"legacy-plaintext-blob"));
assert!(!is_wrapped_handle(b"\x00\x00\x00\x00other-format"));
}
#[test]
fn generate_wrapping_key_produces_32_bytes() {
let key = generate_wrapping_key();
assert_eq!(key.len(), WRAP_KEY_LEN);
}
#[test]
fn generate_wrapping_key_is_non_trivial() {
let key = generate_wrapping_key();
assert!(key.iter().any(|&b| b != 0));
assert!(key.iter().any(|&b| b != key[0]));
}
#[test]
fn generate_wrapping_key_values_differ_between_calls() {
let k1 = generate_wrapping_key();
let k2 = generate_wrapping_key();
assert_ne!(k1, k2);
}
#[test]
fn round_trip_empty_plaintext() {
let key = generate_wrapping_key();
let blob = encrypt_blob(&key, b"").unwrap();
assert_eq!(blob.len(), WRAP_MIN_LEN);
let recovered = decrypt_blob(&key, &blob).unwrap();
assert_eq!(recovered, b"");
}
#[test]
fn round_trip_short_plaintext() {
let key = generate_wrapping_key();
let pt = b"hello, keychain";
let blob = encrypt_blob(&key, pt).unwrap();
let recovered = decrypt_blob(&key, &blob).unwrap();
assert_eq!(recovered, pt);
}
#[test]
fn round_trip_long_plaintext() {
let key = generate_wrapping_key();
let pt: Vec<u8> = (0..4096).map(|i| (i & 0xff) as u8).collect();
let blob = encrypt_blob(&key, &pt).unwrap();
let recovered = decrypt_blob(&key, &blob).unwrap();
assert_eq!(recovered, pt);
}
#[test]
fn encrypt_produces_different_ciphertexts_for_same_plaintext() {
let key = generate_wrapping_key();
let pt = b"same plaintext twice";
let a = encrypt_blob(&key, pt).unwrap();
let b = encrypt_blob(&key, pt).unwrap();
assert_ne!(a, b);
assert_eq!(decrypt_blob(&key, &a).unwrap(), pt);
assert_eq!(decrypt_blob(&key, &b).unwrap(), pt);
}
#[test]
fn wrap_blob_starts_with_magic() {
let key = generate_wrapping_key();
let blob = encrypt_blob(&key, b"pt").unwrap();
assert!(is_wrapped_handle(&blob));
assert_eq!(&blob[..4], WRAP_MAGIC);
}
#[test]
fn decrypt_fails_on_wrong_key() {
let k1 = generate_wrapping_key();
let k2 = generate_wrapping_key();
let blob = encrypt_blob(&k1, b"secret").unwrap();
let err = decrypt_blob(&k2, &blob).unwrap_err();
assert!(err.to_string().contains("AES-GCM decrypt"));
}
#[test]
fn decrypt_fails_on_tampered_ciphertext() {
let key = generate_wrapping_key();
let mut blob = encrypt_blob(&key, b"secret").unwrap();
let ct_start = WRAP_MAGIC.len() + WRAP_NONCE_LEN;
blob[ct_start] ^= 0x01;
let err = decrypt_blob(&key, &blob).unwrap_err();
assert!(err.to_string().contains("AES-GCM decrypt"));
}
#[test]
fn decrypt_fails_on_tampered_tag() {
let key = generate_wrapping_key();
let mut blob = encrypt_blob(&key, b"secret").unwrap();
let last = blob.len() - 1;
blob[last] ^= 0x01;
let err = decrypt_blob(&key, &blob).unwrap_err();
assert!(err.to_string().contains("AES-GCM decrypt"));
}
#[test]
fn decrypt_fails_on_tampered_nonce() {
let key = generate_wrapping_key();
let mut blob = encrypt_blob(&key, b"secret").unwrap();
blob[WRAP_MAGIC.len()] ^= 0x01;
let err = decrypt_blob(&key, &blob).unwrap_err();
assert!(err.to_string().contains("AES-GCM decrypt"));
}
#[test]
fn decrypt_fails_on_truncated_blob() {
let key = generate_wrapping_key();
let blob = encrypt_blob(&key, b"secret").unwrap();
let truncated = &blob[..WRAP_MIN_LEN - 1];
let err = decrypt_blob(&key, truncated).unwrap_err();
assert!(err.to_string().contains("too short"));
}
#[test]
fn decrypt_fails_on_missing_magic() {
let key = generate_wrapping_key();
let mut blob = encrypt_blob(&key, b"secret").unwrap();
blob[0] = b'X'; let err = decrypt_blob(&key, &blob).unwrap_err();
assert!(err.to_string().contains("magic"));
}
#[test]
fn decrypt_fails_on_legacy_plaintext() {
let key = generate_wrapping_key();
let legacy = b"this-is-a-plain-dataRepresentation-blob";
let err = decrypt_blob(&key, legacy).unwrap_err();
assert!(err.to_string().contains("magic"));
}
#[test]
fn service_name_matches_expected_format() {
assert_eq!(service_name_for("sshenc"), "com.godaddy.sshenc-unsigned");
assert_eq!(service_name_for("awsenc"), "com.godaddy.awsenc-unsigned");
assert_eq!(
service_name_for("sshenc-unsigned"),
"com.godaddy.sshenc-unsigned"
);
}
#[test]
fn cache_insert_then_lookup_returns_key() {
let key = generate_wrapping_key();
cache_insert("test-app", "cache-hit", key, Duration::from_secs(60));
let got = cache_lookup("test-app", "cache-hit", Duration::from_secs(60));
assert_eq!(got, Some(key));
}
#[test]
fn cache_evict_removes_entry() {
let key = generate_wrapping_key();
cache_insert("test-app", "cache-evict", key, Duration::from_secs(60));
cache_evict("test-app", "cache-evict");
let got = cache_lookup("test-app", "cache-evict", Duration::from_secs(60));
assert!(got.is_none(), "cache_evict must remove the entry");
}
#[test]
fn cache_lookup_with_zero_ttl_always_misses() {
let key = generate_wrapping_key();
cache_insert("test-app", "zero-ttl", key, Duration::from_secs(60));
let got = cache_lookup("test-app", "zero-ttl", Duration::ZERO);
assert!(got.is_none(), "TTL=0 must bypass the cache");
}
#[test]
fn cache_entries_are_isolated_by_label() {
let k1 = generate_wrapping_key();
let k2 = generate_wrapping_key();
cache_insert("test-app", "iso-a", k1, Duration::from_secs(60));
cache_insert("test-app", "iso-b", k2, Duration::from_secs(60));
assert_eq!(
cache_lookup("test-app", "iso-a", Duration::from_secs(60)),
Some(k1)
);
assert_eq!(
cache_lookup("test-app", "iso-b", Duration::from_secs(60)),
Some(k2)
);
cache_evict("test-app", "iso-a");
assert!(cache_lookup("test-app", "iso-a", Duration::from_secs(60)).is_none());
assert_eq!(
cache_lookup("test-app", "iso-b", Duration::from_secs(60)),
Some(k2),
"evicting one label must not affect another"
);
}
#[test]
fn cache_entries_are_isolated_by_app() {
let k1 = generate_wrapping_key();
let k2 = generate_wrapping_key();
cache_insert("app-x", "same-label", k1, Duration::from_secs(60));
cache_insert("app-y", "same-label", k2, Duration::from_secs(60));
assert_eq!(
cache_lookup("app-x", "same-label", Duration::from_secs(60)),
Some(k1)
);
assert_eq!(
cache_lookup("app-y", "same-label", Duration::from_secs(60)),
Some(k2)
);
}
#[test]
fn keychain_store_evicts_cache() {
let key = generate_wrapping_key();
cache_insert("test-app", "store-evicts", key, Duration::from_secs(60));
cache_evict("test-app", "store-evicts");
assert!(
cache_lookup("test-app", "store-evicts", Duration::from_secs(60)).is_none(),
"keychain_store must evict the cache (key bytes changed)"
);
}
#[test]
fn migration_restore_must_not_evict_cache() {
let key = generate_wrapping_key();
cache_insert("test-app", "migration", key, Duration::from_secs(300));
let got = cache_lookup("test-app", "migration", Duration::from_secs(300));
assert_eq!(
got,
Some(key),
"migration re-store must NOT evict the wrapping-key cache; \
if this fails, decrypt_with_cached_key is calling keychain_store \
instead of keychain_store_ffi and every sign will prompt Touch ID"
);
}
struct KeychainEntryGuard {
app: String,
label: String,
}
impl KeychainEntryGuard {
fn new(app: &str, label: &str) -> Self {
Self {
app: app.to_string(),
label: label.to_string(),
}
}
}
impl Drop for KeychainEntryGuard {
fn drop(&mut self) {
drop(keychain_delete(&self.app, &self.label, None));
}
}
fn unique_test_label(base: &str) -> String {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{base}-{pid}-{nanos}")
}
const TEST_APP: &str = "enclaveapp-test";
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_roundtrip_basic() {
let label = unique_test_label("basic");
let _guard = KeychainEntryGuard::new(TEST_APP, &label);
let key = generate_wrapping_key();
keychain_store(TEST_APP, &label, &key, false, None).unwrap();
let loaded = keychain_load(TEST_APP, &label, Duration::ZERO, None, 0)
.unwrap()
.unwrap();
assert_eq!(loaded, key, "loaded wrapping key must equal stored");
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_load_missing_returns_none() {
let label = unique_test_label("missing");
let loaded = keychain_load(TEST_APP, &label, Duration::ZERO, None, 0).unwrap();
assert!(loaded.is_none(), "load of absent entry must return None");
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_store_is_idempotent_upsert() {
let label = unique_test_label("upsert");
let _guard = KeychainEntryGuard::new(TEST_APP, &label);
let k1 = generate_wrapping_key();
let k2 = generate_wrapping_key();
keychain_store(TEST_APP, &label, &k1, false, None).unwrap();
keychain_store(TEST_APP, &label, &k2, false, None).unwrap();
let loaded = keychain_load(TEST_APP, &label, Duration::ZERO, None, 0)
.unwrap()
.unwrap();
assert_eq!(loaded, k2, "second store must overwrite first");
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_delete_is_idempotent() {
let label = unique_test_label("del-idem");
keychain_delete(TEST_APP, &label, None).unwrap();
keychain_delete(TEST_APP, &label, None).unwrap();
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_delete_actually_removes() {
let label = unique_test_label("del-real");
let _guard = KeychainEntryGuard::new(TEST_APP, &label);
let key = generate_wrapping_key();
keychain_store(TEST_APP, &label, &key, false, None).unwrap();
assert!(keychain_load(TEST_APP, &label, Duration::ZERO, None, 0)
.unwrap()
.is_some());
keychain_delete(TEST_APP, &label, None).unwrap();
assert!(
keychain_load(TEST_APP, &label, Duration::ZERO, None, 0)
.unwrap()
.is_none(),
"load after delete must return None"
);
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_per_label_isolation() {
let label_a = unique_test_label("iso-a");
let label_b = unique_test_label("iso-b");
let _ga = KeychainEntryGuard::new(TEST_APP, &label_a);
let _gb = KeychainEntryGuard::new(TEST_APP, &label_b);
let ka = generate_wrapping_key();
let kb = generate_wrapping_key();
keychain_store(TEST_APP, &label_a, &ka, false, None).unwrap();
keychain_store(TEST_APP, &label_b, &kb, false, None).unwrap();
assert_eq!(
keychain_load(TEST_APP, &label_a, Duration::ZERO, None, 0)
.unwrap()
.unwrap(),
ka
);
assert_eq!(
keychain_load(TEST_APP, &label_b, Duration::ZERO, None, 0)
.unwrap()
.unwrap(),
kb
);
keychain_delete(TEST_APP, &label_a, None).unwrap();
assert!(keychain_load(TEST_APP, &label_a, Duration::ZERO, None, 0)
.unwrap()
.is_none());
assert_eq!(
keychain_load(TEST_APP, &label_b, Duration::ZERO, None, 0)
.unwrap()
.unwrap(),
kb
);
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_per_app_isolation() {
let label = unique_test_label("app-iso");
let app_x = format!("{TEST_APP}-x");
let app_y = format!("{TEST_APP}-y");
let _gx = KeychainEntryGuard::new(&app_x, &label);
let _gy = KeychainEntryGuard::new(&app_y, &label);
let kx = generate_wrapping_key();
let ky = generate_wrapping_key();
keychain_store(&app_x, &label, &kx, false, None).unwrap();
keychain_store(&app_y, &label, &ky, false, None).unwrap();
assert_eq!(
keychain_load(&app_x, &label, Duration::ZERO, None, 0)
.unwrap()
.unwrap(),
kx
);
assert_eq!(
keychain_load(&app_y, &label, Duration::ZERO, None, 0)
.unwrap()
.unwrap(),
ky
);
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn full_wrap_unwrap_via_keychain_lifecycle() {
let label = unique_test_label("full");
let _guard = KeychainEntryGuard::new(TEST_APP, &label);
let plaintext = b"simulated SE dataRepresentation blob with \x00 null \xff bytes";
let key = generate_wrapping_key();
keychain_store(TEST_APP, &label, &key, false, None).unwrap();
let wrapped = encrypt_blob(&key, plaintext).unwrap();
let loaded_key = keychain_load(TEST_APP, &label, Duration::ZERO, None, 0)
.unwrap()
.unwrap();
let recovered = decrypt_blob(&loaded_key, &wrapped).unwrap();
assert_eq!(recovered, plaintext);
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn decrypt_with_cached_key_preserves_cache_after_migration() {
let label = unique_test_label("migration-cache");
let _guard = KeychainEntryGuard::new(TEST_APP, &label);
let plaintext = b"simulated SE handle";
let key = generate_wrapping_key();
keychain_store(TEST_APP, &label, &key, false, None).unwrap();
let wrapped = encrypt_blob(&key, plaintext).unwrap();
let recovered = decrypt_with_cached_key(
TEST_APP,
&label,
&wrapped,
Duration::from_secs(300),
None,
0,
Some(false),
)
.unwrap()
.unwrap();
assert_eq!(recovered, plaintext);
assert!(
cache_lookup(TEST_APP, &label, Duration::from_secs(300)).is_some(),
"wrapping-key cache must survive the migration re-store; \
if this fails, every sign will trigger a fresh biometric prompt"
);
let recovered2 = decrypt_with_cached_key(
TEST_APP,
&label,
&wrapped,
Duration::from_secs(300),
None,
0,
Some(false),
)
.unwrap()
.unwrap();
assert_eq!(recovered2, plaintext);
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn keychain_store_evicts_but_keychain_store_ffi_does_not() {
let label = unique_test_label("store-split");
let _guard = KeychainEntryGuard::new(TEST_APP, &label);
let key = generate_wrapping_key();
cache_insert(TEST_APP, &label, key, Duration::from_secs(300));
keychain_store_ffi(TEST_APP, &label, &key, false, None).unwrap();
assert!(
cache_lookup(TEST_APP, &label, Duration::from_secs(300)).is_some(),
"keychain_store_ffi must NOT evict the cache"
);
cache_insert(TEST_APP, &label, key, Duration::from_secs(300));
keychain_store(TEST_APP, &label, &key, false, None).unwrap();
assert!(
cache_lookup(TEST_APP, &label, Duration::from_secs(300)).is_none(),
"keychain_store MUST evict the cache"
);
}
#[test]
#[ignore = "hits the real macOS Keychain; run explicitly when testing Keychain FFI"]
fn encrypt_under_wrong_keychain_key_fails() {
let label = unique_test_label("wrong-key");
let _guard = KeychainEntryGuard::new(TEST_APP, &label);
let real_key = generate_wrapping_key();
let wrapped = encrypt_blob(&real_key, b"secret").unwrap();
let swapped_key = generate_wrapping_key();
keychain_store(TEST_APP, &label, &swapped_key, false, None).unwrap();
let loaded_key = keychain_load(TEST_APP, &label, Duration::ZERO, None, 0)
.unwrap()
.unwrap();
assert_ne!(loaded_key, real_key);
let err = decrypt_blob(&loaded_key, &wrapped).unwrap_err();
assert!(err.to_string().contains("AES-GCM decrypt"));
}
}