Add macOS user verification with Secure Enclave signing (#43624)

## Why

The user-verification API previously returned an unsupported provider on macOS. Enable native verification using biometric-protected credentials.

## What changed

- Implement credential status, creation, reuse, deletion, and challenge signing with P-256 Secure Enclave keys in the Data Protection Keychain.
- Require biometric authentication through the key's access-control policy and use a fresh `LAContext` for each signature.
- Report Touch ID hardware support independently of enrollment and current readiness, and map native failures to structured verification errors.
- Serialize credential operations across processes with cancellable file-lock waits. Invalidate pending authentication on cancellation and wait for the signer to exit before releasing the lock.

## Testing

Add unit tests for lock contention and cancellation, authentication teardown and late-result rejection, native error classification, and validation of Secure Enclave key attributes.

GitOrigin-RevId: 93268962cd824b2befc1a0c54b99dcb77a0f1044
This commit is contained in:
riley-oai
2026-09-08 00:13:28 +00:00
committed by copyberry
parent 530383e36d
commit e7637306bc
15 changed files with 870 additions and 4 deletions
+1
View File
@@ -125,6 +125,7 @@ osx.frameworks(names = [
"IOSurface",
"IOKit",
"Kernel",
"LocalAuthentication",
"Metal",
"MetalKit",
"OpenGL",
+1
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -4775,10 +4775,19 @@ name = "codex-user-verification"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"core-foundation 0.10.1",
"dirs",
"objc2",
"objc2-foundation",
"objc2-local-authentication",
"p256",
"pretty_assertions",
"security-framework 3.5.1",
"security-framework-sys",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.18",
"tracing",
]
[[package]]
@@ -10720,6 +10729,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-local-authentication"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48e0b8b339e0d9d2ed4416b7f93f9d4daadff7d4dd797f89867cde11aeac607"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
+11
View File
@@ -12,10 +12,21 @@ base64 = { workspace = true }
p256 = { version = "0.13", default-features = false, features = ["arithmetic", "pkcs8", "std"] }
sha2 = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
dirs = { workspace = true }
objc2 = "0.6.3"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString"] }
objc2-local-authentication = { version = "0.3.2", default-features = false, features = ["std", "LAContext", "LABiometryType"] }
security-framework = { version = "3.5", features = ["OSX_10_15"] }
security-framework-sys = "2.15"
[dev-dependencies]
p256 = { version = "0.13", features = ["ecdsa"] }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
[lib]
doctest = false
+25 -4
View File
@@ -4,6 +4,13 @@ mod credential;
mod error;
mod guard;
mod key_namespace;
#[cfg(any(target_os = "macos", test))]
mod lifecycle_lock;
#[cfg(any(target_os = "macos", test))]
mod native_operation;
#[cfg(any(target_os = "macos", test))]
mod platform_macos;
#[cfg(not(target_os = "macos"))]
mod unsupported;
pub use credential::UserVerificationKeyCreation;
@@ -53,18 +60,32 @@ pub trait UserVerificationProvider: Send + Sync {
/// Reports whether this build contains a native provider, independently of local readiness.
pub fn platform_supported() -> bool {
false
cfg!(target_os = "macos")
}
/// Probes biometric hardware without reading credentials, prompting, or checking enrollment.
/// This performs local OS work; async callers should run it off their executor during setup.
pub fn device_supported() -> bool {
false
#[cfg(target_os = "macos")]
{
platform_macos::device_supported()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
pub fn platform_provider(
namespace: UserVerificationKeyNamespace,
) -> Arc<dyn UserVerificationProvider> {
let _ = namespace.label;
Arc::new(unsupported::UnsupportedProvider)
#[cfg(target_os = "macos")]
{
Arc::new(platform_macos::NativeProvider { namespace })
}
#[cfg(not(target_os = "macos"))]
{
let _ = namespace.label;
Arc::new(unsupported::UnsupportedProvider)
}
}
@@ -0,0 +1,86 @@
//! Serializes operations for one account across local processes, with cancellable lock waits.
use crate::UserVerificationError;
use crate::UserVerificationFailureReason;
use crate::UserVerificationRequestGuard;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::path::Path;
use std::time::Duration;
use std::time::Instant;
pub(crate) struct LifecycleLock {
_file: File,
}
impl LifecycleLock {
#[cfg(target_os = "macos")]
pub(crate) fn acquire(
namespace: &crate::UserVerificationKeyNamespace,
guard: &UserVerificationRequestGuard,
) -> Result<Self, UserVerificationError> {
let directory = dirs::data_local_dir().ok_or_else(|| UserVerificationError::Failed {
reason: UserVerificationFailureReason::ProviderError,
message: "could not locate the credential lock directory".to_string(),
})?;
Self::acquire_at(
&directory
.join("com.openai.codex")
.join("user-verification")
.join(format!("{}.lock", namespace.label)),
Duration::from_secs(/*secs*/ 60),
guard,
)
}
fn acquire_at(
path: &Path,
timeout: Duration,
guard: &UserVerificationRequestGuard,
) -> Result<Self, UserVerificationError> {
guard.check()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(lock_error)?;
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.map_err(lock_error)?;
let started = Instant::now();
loop {
guard.check()?;
match file.try_lock() {
Ok(()) => {
guard.check()?;
return Ok(Self { _file: file });
}
Err(std::fs::TryLockError::WouldBlock) if started.elapsed() >= timeout => {
return Err(UserVerificationError::Failed {
reason: UserVerificationFailureReason::Timeout,
message: "timed out waiting for another credential operation".to_string(),
});
}
Err(std::fs::TryLockError::WouldBlock) => {
std::thread::sleep(Duration::from_millis(/*millis*/ 50).min(timeout));
}
Err(std::fs::TryLockError::Error(error)) => return Err(lock_error(error)),
}
}
}
}
fn lock_error(error: std::io::Error) -> UserVerificationError {
tracing::warn!(%error, "user-verification lifecycle lock failed");
UserVerificationError::Failed {
reason: UserVerificationFailureReason::ProviderError,
message: "could not acquire the credential lock".to_string(),
}
}
#[cfg(test)]
#[path = "lifecycle_lock_tests.rs"]
mod tests;
@@ -0,0 +1,60 @@
//! Exercises real file-lock contention and cancellation without releasing the held lock.
use super::*;
use crate::UserVerificationCancellationReason;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
#[test]
fn lock_serializes_operations_and_releases_on_drop() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("credential.lock");
let guard = UserVerificationRequestGuard::default();
let first = LifecycleLock::acquire_at(&path, Duration::ZERO, &guard).expect("first lock");
assert_eq!(
LifecycleLock::acquire_at(&path, Duration::ZERO, &guard).err(),
Some(UserVerificationError::Failed {
reason: UserVerificationFailureReason::Timeout,
message: "timed out waiting for another credential operation".to_string(),
})
);
drop(first);
LifecycleLock::acquire_at(&path, Duration::ZERO, &guard).expect("lock after release");
}
#[test]
fn cancelled_waiter_stops_while_another_operation_still_holds_lock() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("credential.lock");
let holder = LifecycleLock::acquire_at(
&path,
Duration::ZERO,
&UserVerificationRequestGuard::default(),
)
.expect("held lock");
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let checks = AtomicUsize::new(/*v*/ 0);
let guard = UserVerificationRequestGuard::with_activity_check(move || {
if checks.fetch_add(/*val*/ 1, Ordering::Relaxed) == 1 {
ready_tx.send(()).expect("notify lock wait");
}
true
});
let queued = guard.clone();
let worker = std::thread::spawn(move || {
LifecycleLock::acquire_at(&path, Duration::from_secs(/*secs*/ 5), &queued).err()
});
ready_rx
.recv_timeout(Duration::from_secs(/*secs*/ 5))
.expect("waiter entered lock loop");
guard.cancel();
assert_eq!(
worker.join().expect("waiter stopped"),
Some(UserVerificationError::Cancelled {
reason: UserVerificationCancellationReason::Interrupted,
message: "the verification operation is no longer active".to_string(),
})
);
drop(holder);
}
@@ -0,0 +1,54 @@
//! Keeps the authentication context on its owner thread while a native operation blocks.
use crate::UserVerificationError;
use crate::UserVerificationFailureReason;
use crate::UserVerificationRequestGuard;
use std::sync::mpsc;
use std::time::Duration;
pub(crate) fn run_with_cancellation<T: Send>(
guard: &UserVerificationRequestGuard,
operation: impl FnOnce() -> Result<T, UserVerificationError> + Send,
cancel: impl FnOnce(),
) -> Result<T, UserVerificationError> {
guard.check()?;
let (done_tx, done_rx) = mpsc::sync_channel(1);
std::thread::scope(|scope| {
let worker = std::thread::Builder::new()
.name("user-verification-sign".to_string())
.spawn_scoped(scope, move || {
let _ = done_tx.send(guard.check().and_then(|()| operation()));
})
.map_err(|error| {
tracing::warn!(%error, "could not start user-verification signer");
worker_error()
})?;
let result = loop {
match done_rx.recv_timeout(Duration::from_millis(/*millis*/ 50)) {
Ok(result) => break Some(result),
Err(mpsc::RecvTimeoutError::Timeout) => {
if !guard.is_active() {
cancel();
break None;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break Some(Err(worker_error())),
}
};
// Join before returning so no signer outlives the context or its lifecycle lock.
worker.join().map_err(|_| worker_error())?;
guard.check()?;
result.unwrap_or_else(|| Err(worker_error()))
})
}
fn worker_error() -> UserVerificationError {
UserVerificationError::Failed {
reason: UserVerificationFailureReason::ProviderError,
message: "the user-verification signer could not complete".to_string(),
}
}
#[cfg(test)]
#[path = "native_operation_tests.rs"]
mod tests;
@@ -0,0 +1,83 @@
//! Exercises prompt teardown and late-result rejection without requiring biometric hardware.
use super::*;
use crate::UserVerificationCancellationReason;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
#[test]
fn cancellation_tears_down_the_prompt_on_the_context_owner_thread() {
let guard = UserVerificationRequestGuard::default();
let queued = guard.clone();
let (started_tx, started_rx) = mpsc::channel();
let (dismissed_tx, dismissed_rx) = mpsc::channel();
let (exited_tx, exited_rx) = mpsc::channel();
let owner = std::thread::spawn(move || {
let owner_thread = std::thread::current().id();
run_with_cancellation(
&queued,
move || {
started_tx.send(()).expect("authentication started");
dismissed_rx
.recv_timeout(Duration::from_secs(/*secs*/ 5))
.expect("prompt dismissed before the deadline");
exited_tx.send(()).expect("signer exited");
Ok("late signature")
},
|| {
assert_eq!(std::thread::current().id(), owner_thread);
dismissed_tx.send(()).expect("dismiss authentication");
},
)
});
started_rx
.recv_timeout(Duration::from_secs(/*secs*/ 5))
.expect("signer is waiting for authentication");
guard.cancel();
assert_eq!(
owner.join().expect("owner returned"),
Err(UserVerificationError::Cancelled {
reason: UserVerificationCancellationReason::Interrupted,
message: "the verification operation is no longer active".to_string(),
})
);
exited_rx.try_recv().expect("signer exited before return");
}
#[test]
fn completed_operation_does_not_cancel_its_context() {
let cancelled = AtomicBool::new(/*v*/ false);
assert_eq!(
run_with_cancellation(
&UserVerificationRequestGuard::default(),
|| Ok("signature"),
|| cancelled.store(/*val*/ true, Ordering::Release),
),
Ok("signature")
);
assert!(!cancelled.load(Ordering::Acquire));
}
#[test]
fn already_cancelled_operation_never_starts_a_signer() {
let guard = UserVerificationRequestGuard::default();
guard.cancel();
let started = AtomicBool::new(/*v*/ false);
let result = run_with_cancellation(
&guard,
|| {
started.store(/*val*/ true, Ordering::Release);
Ok(())
},
|| panic!("no context needs cancellation"),
);
assert_eq!(
result,
Err(UserVerificationError::Cancelled {
reason: UserVerificationCancellationReason::Interrupted,
message: "the verification operation is no longer active".to_string(),
})
);
assert!(!started.load(Ordering::Acquire));
}
@@ -0,0 +1,12 @@
//! macOS Security and LocalAuthentication integration.
mod error;
#[cfg(target_os = "macos")]
mod key_protection;
#[cfg(target_os = "macos")]
mod provider;
#[cfg(target_os = "macos")]
pub(crate) use provider::NativeProvider;
#[cfg(target_os = "macos")]
pub(crate) use provider::device_supported;
@@ -0,0 +1,54 @@
//! Maps macOS Security and LocalAuthentication failures to stable errors without exposing localized OS diagnostics.
use crate::UserVerificationCancellationReason;
use crate::UserVerificationError;
use crate::UserVerificationFailureReason;
use crate::UserVerificationUnavailableReason;
pub(crate) fn classify(domain: &str, code: i64) -> UserVerificationError {
tracing::debug!(domain, code, "native user-verification operation failed");
match (domain, code) {
("NSOSStatusErrorDomain", -128) | ("com.apple.LocalAuthentication", -2 | -3) => {
UserVerificationError::Cancelled {
reason: UserVerificationCancellationReason::UserCancelled,
message: "authentication was cancelled".to_string(),
}
}
("com.apple.LocalAuthentication", -4 | -9) => UserVerificationError::Cancelled {
reason: UserVerificationCancellationReason::Interrupted,
message: "authentication was interrupted".to_string(),
},
("NSOSStatusErrorDomain", -25293) | ("com.apple.LocalAuthentication", -1) => {
UserVerificationError::Failed {
reason: UserVerificationFailureReason::AuthenticationFailed,
message: "biometric authentication failed".to_string(),
}
}
("com.apple.LocalAuthentication", -5 | -6 | -7 | -8 | -12 | -13) => {
UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::BiometricsUnavailable,
message: "biometric authentication is not available right now".to_string(),
}
}
("com.apple.LocalAuthentication", -1004) => UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
message: "authentication UI is not permitted in this process".to_string(),
},
("NSOSStatusErrorDomain", -34018) => UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
message: "this binary is missing the required keychain entitlements".to_string(),
},
("NSOSStatusErrorDomain", -25291 | -25308) => UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
message: "the platform keychain is not available right now".to_string(),
},
_ => UserVerificationError::Failed {
reason: UserVerificationFailureReason::ProviderError,
message: "the platform could not complete user verification".to_string(),
},
}
}
#[cfg(test)]
#[path = "error_tests.rs"]
mod tests;
@@ -0,0 +1,51 @@
//! Native failure classification is based on the domain and code, never localized text.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn cancellation_is_distinct_from_authentication_failure() {
assert_eq!(
classify("com.apple.LocalAuthentication", /*code*/ -2),
UserVerificationError::Cancelled {
reason: UserVerificationCancellationReason::UserCancelled,
message: "authentication was cancelled".to_string(),
}
);
assert_eq!(
classify("NSOSStatusErrorDomain", /*code*/ -25293),
UserVerificationError::Failed {
reason: UserVerificationFailureReason::AuthenticationFailed,
message: "biometric authentication failed".to_string(),
}
);
}
#[test]
fn biometric_lockout_and_missing_entitlements_report_distinct_unavailability() {
assert_eq!(
classify("com.apple.LocalAuthentication", /*code*/ -8),
UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::BiometricsUnavailable,
message: "biometric authentication is not available right now".to_string(),
}
);
assert_eq!(
classify("NSOSStatusErrorDomain", /*code*/ -34018),
UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
message: "this binary is missing the required keychain entitlements".to_string(),
}
);
}
#[test]
fn unknown_error_domain_cannot_impersonate_cancellation() {
assert_eq!(
classify("unknown", /*code*/ -2),
UserVerificationError::Failed {
reason: UserVerificationFailureReason::ProviderError,
message: "the platform could not complete user verification".to_string(),
}
);
}
@@ -0,0 +1,61 @@
//! Create biometric signing policies and validate Secure Enclave key attributes.
use super::error;
use crate::UserVerificationError;
use crate::UserVerificationUnavailableReason;
use core_foundation::base::CFEqual;
use core_foundation::base::TCFType as _;
use core_foundation::dictionary::CFDictionary;
use core_foundation::number::CFNumber;
use security_framework::access_control::ProtectionMode;
use security_framework::access_control::SecAccessControl;
use security_framework_sys::access_control::kSecAccessControlBiometryAny;
use security_framework_sys::access_control::kSecAccessControlPrivateKeyUsage;
use security_framework_sys::item::kSecAttrKeyClass;
use security_framework_sys::item::kSecAttrKeyClassPrivate;
use security_framework_sys::item::kSecAttrKeySizeInBits;
use security_framework_sys::item::kSecAttrKeyType;
use security_framework_sys::item::kSecAttrKeyTypeECSECPrimeRandom;
use security_framework_sys::item::kSecAttrTokenID;
use security_framework_sys::item::kSecAttrTokenIDSecureEnclave;
pub(super) fn access_control() -> Result<SecAccessControl, UserVerificationError> {
SecAccessControl::create_with_protection(
Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
kSecAccessControlBiometryAny | kSecAccessControlPrivateKeyUsage,
)
.map_err(|err| error::classify("NSOSStatusErrorDomain", i64::from(err.code())))
}
pub(super) fn validate(attributes: &CFDictionary) -> Result<(), UserVerificationError> {
let size = CFNumber::from(/*value*/ 256);
// Do not compare opaque SecAccessControl objects: macOS can change their persisted
// representation. Biometric signing is enforced by the policy set at key creation.
// Reuse trusts that keys in this namespace, within the process's entitled keychain
// access groups, were created with that policy; these checks do not revalidate it.
let expected = unsafe {
[
(kSecAttrTokenID, kSecAttrTokenIDSecureEnclave.cast()),
(kSecAttrKeyClass, kSecAttrKeyClassPrivate.cast()),
(kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom.cast()),
(kSecAttrKeySizeInBits, size.as_CFTypeRef()),
]
};
if expected.into_iter().all(|(name, expected)| {
attributes
.find(name.cast())
.is_some_and(|value| unsafe { CFEqual(*value, expected) != 0 })
}) {
Ok(())
} else {
Err(UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
message: "the local credential has incompatible key protection; delete it before enrolling again"
.to_string(),
})
}
}
#[cfg(test)]
#[path = "key_protection_tests.rs"]
mod tests;
@@ -0,0 +1,59 @@
//! Reject keys without the required Secure Enclave key attributes.
use super::*;
use core_foundation::base::CFType;
use core_foundation::dictionary::CFMutableDictionary;
use core_foundation::string::CFString;
use pretty_assertions::assert_eq;
fn protected_attributes() -> CFMutableDictionary<CFString, CFType> {
unsafe {
CFMutableDictionary::from_CFType_pairs(&[
(
CFString::wrap_under_get_rule(kSecAttrTokenID),
CFString::wrap_under_get_rule(kSecAttrTokenIDSecureEnclave).as_CFType(),
),
(
CFString::wrap_under_get_rule(kSecAttrKeyClass),
CFString::wrap_under_get_rule(kSecAttrKeyClassPrivate).as_CFType(),
),
(
CFString::wrap_under_get_rule(kSecAttrKeyType),
CFString::wrap_under_get_rule(kSecAttrKeyTypeECSECPrimeRandom).as_CFType(),
),
(
CFString::wrap_under_get_rule(kSecAttrKeySizeInBits),
CFNumber::from(/*value*/ 256).as_CFType(),
),
])
}
}
#[test]
fn reuse_requires_secure_enclave_key_attributes() {
let attributes = protected_attributes();
assert_eq!(validate(&attributes.to_immutable().to_untyped()), Ok(()));
let alternatives = unsafe {
[
(kSecAttrTokenID, CFString::new("software").as_CFType()),
(kSecAttrKeyClass, CFString::new("public").as_CFType()),
(kSecAttrKeyType, CFString::new("RSA").as_CFType()),
(
kSecAttrKeySizeInBits,
CFNumber::from(/*value*/ 384).as_CFType(),
),
]
};
for (attribute, value) in alternatives {
let mut attributes = protected_attributes();
attributes.set(unsafe { CFString::wrap_under_get_rule(attribute) }, value);
assert_eq!(
validate(&attributes.to_immutable().to_untyped()),
Err(UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
message: "the local credential has incompatible key protection; delete it before enrolling again"
.to_string(),
})
);
}
}
@@ -0,0 +1,293 @@
//! Secure Enclave keys; biometric access is enforced by the key, not a separate UI check.
use super::error;
use super::key_protection;
use crate::UserVerificationError;
use crate::UserVerificationFailureReason;
use crate::UserVerificationKeyCreation;
use crate::UserVerificationKeyDeletion;
use crate::UserVerificationKeyInfo;
use crate::UserVerificationKeyNamespace;
use crate::UserVerificationProof;
use crate::UserVerificationProvider;
use crate::UserVerificationRequest;
use crate::UserVerificationRequestGuard;
use crate::UserVerificationStatus;
use crate::UserVerificationUnavailableReason;
use crate::lifecycle_lock::LifecycleLock;
use crate::native_operation::run_with_cancellation;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use core_foundation::base::CFType;
use core_foundation::base::TCFType as _;
use objc2::rc::Retained;
use objc2_foundation::NSString;
use objc2_local_authentication::LABiometryType;
use objc2_local_authentication::LAContext;
use objc2_local_authentication::LAPolicy;
use security_framework::base::Error as SecurityError;
use security_framework::item::ItemClass;
use security_framework::item::ItemSearchOptions;
use security_framework::item::KeyClass;
use security_framework::item::Limit;
use security_framework::item::Location;
use security_framework::item::Reference;
use security_framework::item::SearchResult;
use security_framework::key::Algorithm;
use security_framework::key::GenerateKeyOptions;
use security_framework::key::KeyType;
use security_framework::key::SecKey;
use security_framework::key::Token;
use security_framework_sys::base::errSecItemNotFound;
pub(crate) struct NativeProvider {
pub(crate) namespace: UserVerificationKeyNamespace,
}
pub(crate) fn device_supported() -> bool {
let context = noninteractive_context();
// canEvaluatePolicy populates biometryType even when enrollment or temporary lockout
// prevents authentication. Advertise hardware support independently of current readiness.
unsafe {
let _ = context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics);
context.biometryType() == LABiometryType::TouchID
}
}
impl UserVerificationProvider for NativeProvider {
fn status(
&self,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationStatus, UserVerificationError> {
let _lock = LifecycleLock::acquire(&self.namespace, guard)?;
let context = noninteractive_context();
let credential = find_key(&self.namespace.label, &context, KeyUse::Protected)?
.as_ref()
.map(key_info)
.transpose()?;
let unavailable = if credential.is_none() {
Some((
UserVerificationUnavailableReason::CredentialMissing,
"no user-verification credential has been created".to_string(),
))
} else {
match check_biometrics(&context) {
Ok(()) => None,
Err(UserVerificationError::Unavailable { reason, message }) => {
Some((reason, message))
}
Err(error) => return Err(error),
}
};
let (unavailable_reason, unavailable_message) = match unavailable {
Some((reason, message)) => (Some(reason), Some(message)),
None => (None, None),
};
guard.check()?;
Ok(UserVerificationStatus {
credential,
unavailable_reason,
unavailable_message,
})
}
fn ensure_key(
&self,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationKeyCreation, UserVerificationError> {
let _lock = LifecycleLock::acquire(&self.namespace, guard)?;
let context = noninteractive_context();
let existing = find_key(&self.namespace.label, &context, KeyUse::Protected)?;
guard.check()?;
let (key, created) = match existing {
Some(key) => (key, false),
None => (create_key(&self.namespace.label)?, true),
};
let credential = key_info(&key)?;
guard.check()?;
Ok(UserVerificationKeyCreation {
created,
credential,
})
}
fn delete(
&self,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationKeyDeletion, UserVerificationError> {
let _lock = LifecycleLock::acquire(&self.namespace, guard)?;
let context = noninteractive_context();
// The credential ID is optional information for the caller. A colliding key may
// have no P-256 public key, or its lookup may fail because authentication is
// unavailable; neither should prevent removing this namespace's key items.
let credential = find_key(&self.namespace.label, &context, KeyUse::Deletion)
.ok()
.flatten()
.and_then(|key| key_info(&key).ok());
guard.check()?;
let mut options = ItemSearchOptions::new();
// GenerateKeyOptions persists both halves on macOS. Remove all key items under
// this exact namespace, including public keys left by an interrupted deletion.
options
.ignore_legacy_keychains()
.class(ItemClass::key())
.label(&self.namespace.label);
match options.delete() {
Ok(()) => {}
Err(error) if error.code() == errSecItemNotFound => {}
Err(error) => return Err(keychain_error(error)),
}
guard.check()?;
Ok(UserVerificationKeyDeletion {
deleted_credential_id: credential.map(|key| key.credential_id),
})
}
fn verify(
&self,
request: &UserVerificationRequest,
guard: &UserVerificationRequestGuard,
) -> Result<UserVerificationProof, UserVerificationError> {
guard.check()?;
if request.challenge.is_empty()
|| request.challenge.len() > 4096
|| request.title.is_empty()
|| request.title.len() > 256
|| request.description.len() > 4096
{
return Err(UserVerificationError::Failed {
reason: UserVerificationFailureReason::ProviderError,
message: "invalid user-verification challenge or display text".to_string(),
});
}
let _lock = LifecycleLock::acquire(&self.namespace, guard)?;
// A fresh context for every signature prevents authentication reuse between actions.
let context = unsafe { LAContext::new() };
let reason = if request.description.is_empty() {
request.title.clone()
} else {
format!("{}\n\n{}", request.title, request.description)
};
// The keychain query retains this context and uses its text for the signing prompt.
unsafe {
context.setLocalizedFallbackTitle(Some(&NSString::from_str("")));
context.setLocalizedReason(&NSString::from_str(&reason));
}
check_biometrics(&context)?;
guard.check()?;
let key =
find_key(&self.namespace.label, &context, KeyUse::Protected)?.ok_or_else(|| {
UserVerificationError::Unavailable {
reason: UserVerificationUnavailableReason::CredentialMissing,
message: "no user-verification credential has been created".to_string(),
}
})?;
let credential = key_info(&key)?;
guard.check()?;
let signature = run_with_cancellation(
guard,
|| {
key.create_signature(
Algorithm::ECDSASignatureMessageX962SHA256,
&request.challenge,
)
.map_err(|error| error::classify(&error.domain().to_string(), error.code() as i64))
},
|| {
// LAContext remains on this thread. Invalidation cancels its pending
// keychain authentication, allowing the signer and lifecycle lock to exit.
unsafe { context.invalidate() };
},
);
guard.check()?;
Ok(UserVerificationProof {
credential_id: credential.credential_id,
signature: URL_SAFE_NO_PAD.encode(signature?),
})
}
}
fn noninteractive_context() -> Retained<LAContext> {
// Each context is confined to this blocking operation and never shared across threads.
unsafe {
let context = LAContext::new();
context.setInteractionNotAllowed(/*interaction_not_allowed*/ true);
context
}
}
fn check_biometrics(context: &LAContext) -> Result<(), UserVerificationError> {
// canEvaluatePolicy only checks availability; the protected key triggers authentication.
unsafe { context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) }
.map_err(|error| error::classify(&error.domain().to_string(), error.code() as i64))
}
enum KeyUse {
Protected,
Deletion,
}
fn find_key(
label: &str,
context: &LAContext,
key_use: KeyUse,
) -> Result<Option<SecKey>, UserVerificationError> {
// Security accepts an LAContext object as kSecUseAuthenticationContext. Wrapping under
// the get rule retains it, and ItemSearchOptions owns that retain for the query lifetime.
let authentication = unsafe { CFType::wrap_under_get_rule(std::ptr::from_ref(context).cast()) };
let mut options = ItemSearchOptions::new();
options
.ignore_legacy_keychains()
.key_class(KeyClass::private())
.label(label)
.local_authentication_context(Some(authentication))
.load_refs(true)
.limit(Limit::Max(1));
match options.search() {
Ok(results) => {
let key = results.into_iter().find_map(|result| match result {
SearchResult::Ref(Reference::Key(key)) => Some(key),
_ => None,
});
if let Some(key) = &key
&& matches!(key_use, KeyUse::Protected)
{
key_protection::validate(&key.attributes())?;
}
Ok(key)
}
Err(error) if error.code() == errSecItemNotFound => Ok(None),
Err(error) => Err(keychain_error(error)),
}
}
fn create_key(label: &str) -> Result<SecKey, UserVerificationError> {
let access = key_protection::access_control()?;
let mut options = GenerateKeyOptions::default();
options
.set_key_type(KeyType::ec_sec_prime_random())
.set_size_in_bits(256)
.set_label(label)
.set_token(Token::SecureEnclave)
.set_location(Location::DataProtectionKeychain)
.set_access_control(access);
let key = SecKey::new(&options)
.map_err(|error| error::classify(&error.domain().to_string(), error.code() as i64))?;
key_protection::validate(&key.attributes())?;
Ok(key)
}
fn key_info(key: &SecKey) -> Result<UserVerificationKeyInfo, UserVerificationError> {
let bytes = key
.public_key()
.and_then(|key| key.external_representation())
.ok_or_else(|| UserVerificationError::Failed {
reason: UserVerificationFailureReason::ProviderError,
message: "could not export the user-verification public key".to_string(),
})?;
UserVerificationKeyInfo::from_sec1_public_key(&bytes)
}
fn keychain_error(error: SecurityError) -> UserVerificationError {
error::classify("NSOSStatusErrorDomain", i64::from(error.code()))
}