From ef03f9329dbd805b19e9126e41a6804d72f2de74 Mon Sep 17 00:00:00 2001 From: Jay Gowdy Date: Thu, 21 May 2026 14:02:20 -0700 Subject: [PATCH] fix(apple): don't evict LAContext and wrapping-key cache during protection-class migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decrypt_with_cached_key re-stores the wrapping key on every cache miss to migrate items to AfterFirstUnlockThisDeviceOnly. It called keychain_store(), which evicts both the wrapping-key cache and the LAContext registry. This created a self-perpetuating cycle: every sign saw a cache miss, triggered the migration, evicted both caches, and forced a fresh Touch ID prompt on the next sign. Extract keychain_store_ffi (the raw FFI call without eviction) and use it in the migration path. The key bytes are unchanged — only the protection class is updated — so the cached entries remain valid. keychain_store retains the eviction for callers that change key bytes (generate_and_wrap, relabel_wrapping_key). Restores single-prompt-per-cache-window behavior: first sign triggers Touch ID, subsequent signs within the TTL reuse the cached LAContext. --- crates/enclaveapp-apple/src/keychain_wrap.rs | 57 ++++++++++++++------ 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/crates/enclaveapp-apple/src/keychain_wrap.rs b/crates/enclaveapp-apple/src/keychain_wrap.rs index 1c10a3c..49ddc42 100644 --- a/crates/enclaveapp-apple/src/keychain_wrap.rs +++ b/crates/enclaveapp-apple/src/keychain_wrap.rs @@ -303,19 +303,11 @@ pub fn cache_evict_for(app_name: &str, label: &str) { cache_evict(app_name, label); } -/// Store a wrapping key in the login keychain. Replaces any existing -/// entry for the same service+account pair. -/// -/// When `use_user_presence` is `true` the item is stored with a -/// `.userPresence` access-control flag so subsequent reads trigger a -/// LocalAuthentication prompt (Touch ID or device passcode) instead of -/// the legacy code-signature ACL dialog. -/// -/// **Internal helper.** External callers get raw key access through -/// [`generate_and_wrap`] (generate path) and [`relabel_wrapping_key`] -/// (rename path). No public API hands out raw wrapping-key bytes. +/// Write a wrapping key to the login keychain via the Swift bridge. +/// Does NOT touch any process-local caches — callers decide whether +/// to evict. Returns `Ok(())` on success. #[allow(unsafe_code)] -pub(crate) fn keychain_store( +fn keychain_store_ffi( app_name: &str, label: &str, wrapping_key: &[u8; WRAP_KEY_LEN], @@ -365,8 +357,6 @@ pub(crate) fn keychain_store( ) }; if rc == 0 { - // Overwriting an item — any cached copy is stale. - cache_evict(app_name, label); Ok(()) } else { Err(Error::KeyOperation { @@ -376,6 +366,36 @@ pub(crate) fn keychain_store( } } +/// Store a wrapping key in the login keychain. Replaces any existing +/// entry for the same service+account pair. +/// +/// When `use_user_presence` is `true` the item is stored with a +/// `.userPresence` access-control flag so subsequent reads trigger a +/// LocalAuthentication prompt (Touch ID or device passcode) instead of +/// the legacy code-signature ACL dialog. +/// +/// **Internal helper.** External callers get raw key access through +/// [`generate_and_wrap`] (generate path) and [`relabel_wrapping_key`] +/// (rename path). No public API hands out raw wrapping-key bytes. +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, + )?; + // Overwriting an item — any cached copy is stale. + cache_evict(app_name, label); + Ok(()) +} + /// Load a wrapping key from the login keychain, consulting the /// process-local cache first. /// @@ -570,7 +590,14 @@ pub fn decrypt_with_cached_key( Some(wrapping_key) => { if !was_cached { if let Some(up) = use_user_presence { - if let Err(e) = keychain_store(app_name, label, &wrapping_key, up, access_group) + // Re-store with the current protection class. Use + // keychain_store_ffi (not keychain_store) to skip + // cache eviction — the key bytes are unchanged, so + // the entry keychain_load just cached is still valid, + // and evicting the LAContext would force a redundant + // Touch ID prompt on the next sign. + if let Err(e) = + keychain_store_ffi(app_name, label, &wrapping_key, up, access_group) { tracing::warn!( label = label,