Skip to content

Commit d9f5028

Browse files
fix(apple): handle errSecInteractionNotAllowed and change keychain protection class (#158)
* fix(apple): handle errSecInteractionNotAllowed (-25308) in keychain load The Swift bridge checked only errSecInteractionRequired (-25315, CSSM layer) but SecItemCopyMatching returns errSecInteractionNotAllowed (-25308) after sleep/wake. The mismatch caused all post-sleep keychain errors to fall through to the generic SE_ERR_KEYCHAIN_LOAD (code 10), bypassing the dedicated recovery paths for codes 14/15. Add errSecInteractionNotAllowed to the status check so the correct error code propagates to Rust. Also expose cache_evict_for() and add evict_wrapping_key_cache() to EnclaveSigner so callers can force a fresh keychain load with a new LAContext on transient failures. * fix(apple): use AfterFirstUnlockThisDeviceOnly for keychain protection class WhenUnlockedThisDeviceOnly purges the keybag class key from memory on device lock/sleep, making wrapping keys inaccessible to background agents after sleep/wake. AfterFirstUnlockThisDeviceOnly keeps the class key in memory from first unlock until reboot, which is the correct behavior for an SSH agent that must sign in the background. Changes: - keychain_store: all protection class references changed from WhenUnlockedThisDeviceOnly to AfterFirstUnlockThisDeviceOnly (userPresence path, non-userPresence path, and both fallback paths) - makeAccessControl: same protection class change for SE key generation with auth_policy, plus proper error capture via CFError - decrypt_with_cached_key: on non-cached keychain load, re-stores the wrapping key to transparently migrate existing items to the new protection class - Swift bridge: add last-error detail mechanism (setLastError / enclaveapp_se_last_error FFI) so Rust callers get CryptoKit and SecAccessControl error descriptions instead of bare error codes - ffi.rs: declare enclaveapp_se_last_error extern - keychain.rs: read and surface bridge error details in GenerateFailed --------- Co-authored-by: Jay Gowdy <jay@gowdy.me>
1 parent 5c5a420 commit d9f5028

6 files changed

Lines changed: 75 additions & 21 deletions

File tree

crates/enclaveapp-apple/src/ffi.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,13 @@ extern "C" {
100100
// 0 SE_OK
101101
// 4 SE_ERR_BUFFER_TOO_SMALL
102102
// 9 SE_ERR_KEYCHAIN_STORE
103-
// 10 SE_ERR_KEYCHAIN_LOAD
103+
// 10 SE_ERR_KEYCHAIN_LOAD (generic load failure)
104104
// 11 SE_ERR_KEYCHAIN_DELETE
105105
// 12 SE_ERR_KEYCHAIN_NOT_FOUND
106+
// 13 SE_ERR_KEYCHAIN_AUTH_DENIED (errSecAuthFailed -25293)
107+
// 14 SE_ERR_KEYCHAIN_INTERACTION_REQUIRED (errSecInteractionNotAllowed -25308, has CG session)
108+
// 15 SE_ERR_KEYCHAIN_NO_WINDOW_SERVER (errSecInteractionNotAllowed -25308, no CG session)
109+
// 16 SE_ERR_USER_CANCEL (errSecUserCanceled -128)
106110
// `access_group` (UTF-8 pointer) + `access_group_len`: when
107111
// non-null with len > 0, the bridge routes SecItemAdd through the
108112
// Data Protection keychain with `kSecAttrAccessGroup` set —

crates/enclaveapp-apple/src/keychain.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,7 @@ pub fn load_handle_with_context(
786786
config.wrapping_key_cache_ttl,
787787
config.keychain_access_group.as_deref(),
788788
lacontext_token,
789+
Some(config.wrapping_key_user_presence),
789790
)? {
790791
Some(plaintext) => Ok(Zeroizing::new(plaintext)),
791792
None => Err(Error::KeyOperation {

crates/enclaveapp-apple/src/keychain_wrap.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@ fn cache_evict(app_name: &str, label: &str) {
297297
crate::lacontext::evict(app_name, label);
298298
}
299299

300+
/// Evict the cached wrapping key and LAContext for `label`, forcing the
301+
/// next operation to reload from the keychain with a fresh LAContext.
302+
pub fn cache_evict_for(app_name: &str, label: &str) {
303+
cache_evict(app_name, label);
304+
}
305+
300306
/// Store a wrapping key in the login keychain. Replaces any existing
301307
/// entry for the same service+account pair.
302308
///
@@ -456,14 +462,14 @@ pub(crate) fn keychain_load(
456462
}),
457463
14 => Err(Error::KeychainInteractionRequired {
458464
// SE_ERR_KEYCHAIN_INTERACTION_REQUIRED: macOS returned
459-
// errSecInteractionRequired (-25308) and the process has a CG
465+
// errSecInteractionNotAllowed (-25308) and the process has a CG
460466
// session — screen is locked or biometric was cancelled.
461467
// Transient: retry after the user unlocks the screen.
462468
label: label.to_string(),
463469
}),
464470
15 => Err(Error::KeychainNoWindowServer {
465471
// SE_ERR_KEYCHAIN_NO_WINDOW_SERVER: macOS returned
466-
// errSecInteractionRequired (-25308) and CGSessionCopyCurrentDictionary()
472+
// errSecInteractionNotAllowed (-25308) and CGSessionCopyCurrentDictionary()
467473
// returned nil — the process has no window server connection.
468474
// Touch ID UI cannot be displayed. Recovery: restart the agent
469475
// via launchd so it inherits the user's GUI session.
@@ -542,6 +548,12 @@ pub fn generate_and_wrap(
542548
/// Returns `Ok(None)` if no wrapping-key entry exists — the caller can
543549
/// distinguish that from a decrypt/IO error.
544550
///
551+
/// When `use_user_presence` is `Some(bool)` and the wrapping key was
552+
/// freshly loaded from the keychain (not served from the in-process
553+
/// cache), the item is re-stored to migrate it to the current data
554+
/// protection class (`AfterFirstUnlockThisDeviceOnly`). This is a
555+
/// one-time transparent upgrade for items created before the fix.
556+
///
545557
/// Raw wrapping-key bytes are fetched, used, and dropped inside this
546558
/// function — they do not escape `keychain_wrap`.
547559
pub fn decrypt_with_cached_key(
@@ -551,9 +563,24 @@ pub fn decrypt_with_cached_key(
551563
cache_ttl: Duration,
552564
access_group: Option<&str>,
553565
lacontext_token: u64,
566+
use_user_presence: Option<bool>,
554567
) -> Result<Option<Vec<u8>>> {
568+
let was_cached = cache_lookup(app_name, label, cache_ttl).is_some();
555569
match keychain_load(app_name, label, cache_ttl, access_group, lacontext_token)? {
556570
Some(wrapping_key) => {
571+
if !was_cached {
572+
if let Some(up) = use_user_presence {
573+
if let Err(e) = keychain_store(app_name, label, &wrapping_key, up, access_group)
574+
{
575+
tracing::warn!(
576+
label = label,
577+
error = %e,
578+
"protection-class migration re-store failed; \
579+
item retains old protection class until next successful load"
580+
);
581+
}
582+
}
583+
}
557584
let plaintext = decrypt_blob(&wrapping_key, blob)?;
558585
Ok(Some(plaintext))
559586
}

crates/enclaveapp-apple/src/sign.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,4 +226,8 @@ impl EnclaveSigner for SecureEnclaveSigner {
226226
}
227227
}
228228
}
229+
230+
fn evict_wrapping_key_cache(&self, label: &str) {
231+
crate::keychain_wrap::cache_evict_for(&self.config.app_name, label);
232+
}
229233
}

crates/enclaveapp-apple/swift/bridge.swift

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,16 @@ let SE_ERR_KEYCHAIN_NOT_FOUND: Int32 = 12
4949
/// ("open Keychain Access and change Deny → Always Allow") rather than
5050
/// the generic "keychain load error".
5151
let SE_ERR_KEYCHAIN_AUTH_DENIED: Int32 = 13
52-
/// Returned when `SecItemCopyMatching` returns `errSecInteractionRequired`
53-
/// (-25308) — the item requires user presence but no authenticated LAContext
54-
/// was provided. This happens when `lacontext_token=0` is passed and the
55-
/// keychain item was stored with `.userPresence` access control. The screen
56-
/// may be locked or biometric auth was cancelled; the caller should retry
57-
/// after the user unlocks the screen.
52+
/// Returned when `SecItemCopyMatching` returns `errSecInteractionNotAllowed`
53+
/// (-25308) or `errSecInteractionRequired` (-25315) — the item requires user
54+
/// presence but no authenticated LAContext was provided. This happens when
55+
/// `lacontext_token=0` is passed and the keychain item was stored with
56+
/// `.userPresence` access control. The screen may be locked or biometric auth
57+
/// was cancelled; the caller should retry after the user unlocks the screen.
5858
let SE_ERR_KEYCHAIN_INTERACTION_REQUIRED: Int32 = 14
59-
/// Returned when `SecItemCopyMatching` returns `errSecInteractionRequired`
60-
/// and `CGSessionCopyCurrentDictionary()` returns nil — meaning the process
61-
/// has no window server session at all. Touch ID requires a window server
59+
/// Returned when `SecItemCopyMatching` returns `errSecInteractionNotAllowed`
60+
/// or `errSecInteractionRequired` and `CGSessionCopyCurrentDictionary()`
61+
/// returns nil — meaning the process has no window server session at all. Touch ID requires a window server
6262
/// connection to display its prompt; background processes started outside of
6363
/// launchd (e.g. `sshenc-agent &` in a shell) never get one. Recovery:
6464
/// restart the agent via launchd so it inherits the user's GUI session.
@@ -71,6 +71,11 @@ let SE_ERR_KEYCHAIN_NO_WINDOW_SERVER: Int32 = 15
7171
let SE_ERR_USER_CANCEL: Int32 = 16
7272

7373
// MARK: - Thread-local last error detail
74+
//
75+
// Captures the underlying error message (e.g. CryptoKit exception,
76+
// SecAccessControlCreateWithFlags failure) so the Rust side can include
77+
// it in diagnostics. Set by any function that returns a non-zero code;
78+
// cleared on success. Read via enclaveapp_se_last_error().
7479

7580
private var _lastError: String = ""
7681
private let _lastErrorLock = NSLock()
@@ -273,7 +278,7 @@ func makeAccessControl(_ authPolicy: Int32) -> SecAccessControl? {
273278
}
274279
var error: Unmanaged<CFError>?
275280
let ac = SecAccessControlCreateWithFlags(
276-
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &error
281+
nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, flags, &error
277282
)
278283
if ac == nil {
279284
let desc = error.map { $0.takeRetainedValue().localizedDescription } ?? "unknown"
@@ -722,9 +727,11 @@ public func enclaveapp_se_decrypt(
722727
// errSecMissingEntitlement (-34018). The legacy keychain accepts
723728
// unsigned callers.
724729
//
725-
// Items are created with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`:
726-
// the device must be unlocked to read them, and they do NOT sync via
727-
// iCloud / migrate across devices.
730+
// Items are created with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
731+
// available from first unlock until reboot, and they do NOT sync via
732+
// iCloud / migrate across devices. The AfterFirstUnlock class keeps the
733+
// class key in memory across sleep/wake cycles so background agents can
734+
// access wrapping keys without requiring the screen to be unlocked.
728735

729736
/// Open the invoking user's login keychain by explicit path.
730737
///
@@ -960,10 +967,17 @@ public func enclaveapp_keychain_store(
960967
// Bind access to user presence (Touch ID or device passcode)
961968
// via LocalAuthentication. `kSecAttrAccessControl` implies
962969
// accessibility, so `kSecAttrAccessible` must NOT also be set.
970+
//
971+
// AfterFirstUnlockThisDeviceOnly: the class key stays in memory
972+
// from first unlock until reboot, so the wrapping key survives
973+
// sleep/wake cycles. The .userPresence flag still requires
974+
// Touch ID per-access. WhenUnlockedThisDeviceOnly would purge
975+
// the class key on lock/sleep, making the wrapping key
976+
// inaccessible to a background agent after sleep/wake.
963977
var acError: Unmanaged<CFError>?
964978
guard let ac = SecAccessControlCreateWithFlags(
965979
nil,
966-
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
980+
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
967981
.userPresence,
968982
&acError
969983
) else {
@@ -972,7 +986,7 @@ public func enclaveapp_keychain_store(
972986
}
973987
addQuery[kSecAttrAccessControl as String] = ac
974988
} else {
975-
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
989+
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
976990
}
977991
if let kc = kc { addQuery[kSecUseKeychain as String] = kc }
978992
var status = SecItemAdd(addQuery as CFDictionary, nil)
@@ -1008,7 +1022,7 @@ public func enclaveapp_keychain_store(
10081022
// Userpresence on legacy keychain is errSecParam → flatten now
10091023
addQuery.removeValue(forKey: kSecAttrAccessControl as String)
10101024
addQuery[kSecAttrAccessible as String] =
1011-
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
1025+
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
10121026
}
10131027
// The DP-keychain initial path runs with `useExplicit = false`,
10141028
// so we never opened a SecKeychain handle. If the default
@@ -1059,7 +1073,7 @@ public func enclaveapp_keychain_store(
10591073
).utf8))
10601074
addQuery.removeValue(forKey: kSecAttrAccessControl as String)
10611075
addQuery[kSecAttrAccessible as String] =
1062-
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
1076+
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
10631077
status = SecItemAdd(addQuery as CFDictionary, nil)
10641078
keychainTrace(
10651079
"op=store_add_fallback service=\(serviceStr) account=\(accountStr) status=\(status)"
@@ -1142,7 +1156,7 @@ public func enclaveapp_keychain_load(
11421156
if status == errSecAuthFailed {
11431157
return SE_ERR_KEYCHAIN_AUTH_DENIED
11441158
}
1145-
if status == errSecInteractionRequired {
1159+
if status == errSecInteractionNotAllowed || status == errSecInteractionRequired {
11461160
// Distinguish "no window server" (agent started outside launchd)
11471161
// from "screen locked" (agent is fine, user needs to unlock).
11481162
// CGSessionCopyCurrentDictionary() returns nil when the process

crates/enclaveapp-core/src/traits.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ pub trait EnclaveSigner: EnclaveKeyManager {
101101
let _ = (mode, cache_ttl_secs, reason);
102102
self.sign(label, data)
103103
}
104+
105+
/// Evict cached wrapping key and LAContext for `label`, forcing the next
106+
/// operation to reload from the keychain with fresh authentication.
107+
fn evict_wrapping_key_cache(&self, _label: &str) {}
104108
}
105109

106110
/// ECIES encryption operations. Used by awsenc and sso-jwt for credential caching.

0 commit comments

Comments
 (0)