mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
@@ -125,6 +125,7 @@ osx.frameworks(names = [
|
||||
"IOSurface",
|
||||
"IOKit",
|
||||
"Kernel",
|
||||
"LocalAuthentication",
|
||||
"Metal",
|
||||
"MetalKit",
|
||||
"OpenGL",
|
||||
|
||||
Generated
+1
File diff suppressed because one or more lines are too long
Generated
+19
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user