#[cfg(target_os = "macos")]
pub(crate) mod macos;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AuthPolicy {
DeviceOwner,
BiometricsOnly,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AuthOutcome {
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
Authorized,
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
Denied(String),
NoAuthenticator(String),
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
PolicyUnsatisfiable(String),
}
pub(crate) const SESSION_HAS_GRAPHIC_ACCESS: u32 = 0x0010;
pub(crate) const SESSION_IS_REMOTE: u32 = 0x1000;
pub(crate) const LA_ERROR_DOMAIN: &str = "com.apple.LocalAuthentication";
const LA_ERROR_NOT_INTERACTIVE: isize = -1004;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LaError {
pub domain: String,
pub code: isize,
pub description: String,
}
impl LaError {
fn is_not_interactive(&self) -> bool {
self.domain == LA_ERROR_DOMAIN && self.code == LA_ERROR_NOT_INTERACTIVE
}
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn classify_session(attrs: Result<u32, i32>) -> Option<AuthOutcome> {
match attrs {
Ok(bits) if bits & SESSION_HAS_GRAPHIC_ACCESS != 0 => None,
Ok(bits) => Some(AuthOutcome::NoAuthenticator(format!(
"this {} has no graphical access, so no authentication prompt can be shown",
if bits & SESSION_IS_REMOTE != 0 {
"remote session (e.g. SSH)"
} else {
"session"
}
))),
Err(status) => Some(AuthOutcome::PolicyUnsatisfiable(format!(
"could not determine the security session's attributes (SessionGetInfo \
returned OSStatus {status})"
))),
}
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn classify_preflight_error(err: LaError) -> AuthOutcome {
if err.is_not_interactive() {
AuthOutcome::NoAuthenticator(err.description)
} else {
AuthOutcome::PolicyUnsatisfiable(err.description)
}
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn classify_reply_error(err: Option<LaError>) -> AuthOutcome {
match err {
Some(err) if err.is_not_interactive() => AuthOutcome::NoAuthenticator(err.description),
Some(err) => AuthOutcome::Denied(err.description),
None => AuthOutcome::Denied("authentication failed".to_string()),
}
}
pub(crate) trait Authenticator: Send + Sync {
fn authenticate(&self, reason: &str, policy: AuthPolicy) -> AuthOutcome;
}
#[cfg_attr(target_os = "macos", allow(dead_code))]
pub(crate) struct Unsupported;
impl Authenticator for Unsupported {
fn authenticate(&self, _reason: &str, _policy: AuthPolicy) -> AuthOutcome {
AuthOutcome::NoAuthenticator(
"no device-owner authenticator is available on this platform".to_string(),
)
}
}
pub(crate) fn platform_authenticator() -> Box<dyn Authenticator> {
#[cfg(target_os = "macos")]
{
Box::new(macos::LocalAuthenticator)
}
#[cfg(not(target_os = "macos"))]
{
Box::new(Unsupported)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_is_always_no_authenticator() {
let outcome = Unsupported.authenticate("test a write", AuthPolicy::DeviceOwner);
assert!(matches!(outcome, AuthOutcome::NoAuthenticator(_)));
let outcome = Unsupported.authenticate("test a write", AuthPolicy::BiometricsOnly);
assert!(matches!(outcome, AuthOutcome::NoAuthenticator(_)));
}
fn la(code: isize) -> LaError {
LaError {
domain: LA_ERROR_DOMAIN.to_string(),
code,
description: format!("code {code}"),
}
}
const CONSOLE_ATTRS: u32 = 0x6030;
const SSH_ATTRS: u32 = 0x5020;
#[test]
fn console_session_goes_on_to_prompt() {
assert_eq!(classify_session(Ok(CONSOLE_ATTRS)), None);
}
#[test]
fn ssh_session_has_no_authenticator_and_says_it_is_remote() {
let Some(AuthOutcome::NoAuthenticator(detail)) = classify_session(Ok(SSH_ATTRS)) else {
panic!("an SSH session must be waivable, never prompted");
};
assert!(detail.contains("remote session"), "{detail}");
}
#[test]
fn local_session_without_graphic_access_has_no_authenticator() {
let outcome = classify_session(Ok(SSH_ATTRS & !SESSION_IS_REMOTE));
assert!(matches!(outcome, Some(AuthOutcome::NoAuthenticator(_))));
}
#[test]
fn session_info_failure_fails_closed_unwaivably() {
assert!(matches!(
classify_session(Err(-60008)),
Some(AuthOutcome::PolicyUnsatisfiable(_))
));
}
#[test]
fn preflight_failures_in_an_attended_session_are_never_waivable() {
for code in [-5, -4, -6, -7, -8] {
assert!(
matches!(
classify_preflight_error(la(code)),
AuthOutcome::PolicyUnsatisfiable(_)
),
"LAError {code} must not be waivable"
);
}
}
#[test]
fn not_interactive_is_no_authenticator_wherever_it_surfaces() {
assert!(matches!(
classify_preflight_error(la(LA_ERROR_NOT_INTERACTIVE)),
AuthOutcome::NoAuthenticator(_)
));
assert!(matches!(
classify_reply_error(Some(la(LA_ERROR_NOT_INTERACTIVE))),
AuthOutcome::NoAuthenticator(_)
));
}
#[test]
fn not_interactive_code_from_another_domain_is_not_trusted() {
let foreign = LaError {
domain: "NSOSStatusErrorDomain".to_string(),
..la(LA_ERROR_NOT_INTERACTIVE)
};
assert!(matches!(
classify_preflight_error(foreign.clone()),
AuthOutcome::PolicyUnsatisfiable(_)
));
assert!(matches!(
classify_reply_error(Some(foreign)),
AuthOutcome::Denied(_)
));
}
#[test]
fn reply_failures_are_denials() {
for code in [-2, -1, -9] {
assert!(matches!(
classify_reply_error(Some(la(code))),
AuthOutcome::Denied(_)
));
}
assert_eq!(
classify_reply_error(None),
AuthOutcome::Denied("authentication failed".to_string())
);
}
#[test]
fn platform_authenticator_resolves_without_panicking() {
let _ = platform_authenticator();
}
}