Skip to main content

hardware_enclave/
auth.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4use crate::error::{Error, Result};
5use crate::types::BackendKind;
6
7/// Capabilities of the current platform's authentication subsystem.
8#[derive(Debug, Clone)]
9pub struct AuthCapabilities {
10    /// Biometric authenticator available (Touch ID, Windows Hello fingerprint).
11    pub biometric_available: bool,
12    /// Password/PIN fallback available in the same auth flow.
13    pub password_available: bool,
14    /// Presence prompts can be cached across ops within a TTL (macOS LAContext only).
15    pub presence_caching: bool,
16    /// Human-readable authenticator name, if known.
17    pub authenticator_name: Option<String>,
18}
19
20/// Handle to the platform authentication subsystem.
21/// Obtained from `create_auth()`.
22pub struct AuthHandle {
23    backend_kind: BackendKind,
24    /// Windows Hello verification cache. Each `AuthHandle` owns its own gate
25    /// so that `evict_presence_cache()` only clears verifications acquired
26    /// through this handle and does not affect other handles or the key
27    /// sign/decrypt paths (which manage their own Hello state).
28    #[cfg(target_os = "windows")]
29    hello_gate: crate::internal::windows::hello_gate::HelloGate,
30}
31
32impl std::fmt::Debug for AuthHandle {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("AuthHandle")
35            .field("backend_kind", &self.backend_kind)
36            .finish_non_exhaustive()
37    }
38}
39
40impl AuthHandle {
41    pub(crate) fn new(backend_kind: BackendKind) -> Self {
42        Self {
43            backend_kind,
44            #[cfg(target_os = "windows")]
45            hello_gate: crate::internal::windows::hello_gate::HelloGate::new(),
46        }
47    }
48
49    /// Return the platform's authentication capabilities. Equivalent to
50    /// [`platform_auth_capabilities()`][crate::platform_auth_capabilities].
51    pub fn capabilities(&self) -> AuthCapabilities {
52        platform_auth_capabilities()
53    }
54
55    /// Request user-presence verification. Returns `Ok(())` if the user
56    /// authenticated successfully.
57    ///
58    /// Platform behavior:
59    /// - **macOS**: Fires the Touch ID / passcode dialog synchronously via
60    ///   `LAContext.evaluatePolicy(.deviceOwnerAuthentication)`. Blocks until
61    ///   the user responds. Returns `Err(PresenceNotAvailable)` if no
62    ///   biometric or passcode is enrolled, or `Err(UserCancelled)` if the
63    ///   user dismisses the prompt.
64    /// - **Windows**: Calls `UserConsentVerifier.RequestVerificationAsync(reason)`.
65    ///   Falls back to a password gate when Windows Hello is not enrolled.
66    ///   Gracefully degrades to `Ok(())` on headless sessions where neither
67    ///   Hello nor a verifiable password is available (credentials remain
68    ///   TPM-encrypted regardless).
69    /// - **Linux / other**: Always returns `Err(PresenceNotAvailable)`.
70    #[allow(clippy::needless_return, unreachable_code)]
71    pub fn request_presence(&self, reason: &str) -> Result<()> {
72        #[cfg(target_os = "macos")]
73        {
74            return crate::internal::apple::evaluate_presence(reason).map_err(|e| {
75                use crate::internal::core::Error as CE;
76                match e {
77                    CE::NotAvailable => Error::PresenceNotAvailable,
78                    CE::UserCancelled { label } => Error::UserCancelled { label },
79                    other => Error::from(other),
80                }
81            });
82        }
83        #[cfg(target_os = "windows")]
84        {
85            return self
86                .hello_gate
87                .ensure_verified("__standalone_presence__", reason, std::time::Duration::ZERO)
88                .map_err(Error::from);
89        }
90        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
91        {
92            let _ = reason;
93            return Err(Error::PresenceNotAvailable);
94        }
95    }
96
97    /// Evict any cached presence token, forcing re-authentication on the
98    /// next signing or decryption operation that uses a cached presence mode.
99    ///
100    /// Platform behavior:
101    /// - **macOS**: Clears all cached `LAContext` handles from the global
102    ///   registry. The next `sign_with_presence(Cached, ...)` call will fire
103    ///   a fresh Touch ID prompt.
104    /// - **Windows**: Clears all Windows Hello verifications cached in this
105    ///   `AuthHandle`. The sign/decrypt paths manage their own `HelloGate`
106    ///   state and are unaffected.
107    /// - **Linux / other**: No-op.
108    #[allow(clippy::needless_return, unreachable_code)]
109    pub fn evict_presence_cache(&self) {
110        #[cfg(target_os = "macos")]
111        {
112            crate::internal::apple::evict_all_contexts();
113            return;
114        }
115        #[cfg(target_os = "windows")]
116        {
117            self.hello_gate.invalidate_all();
118            return;
119        }
120        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
121        {}
122    }
123
124    /// Which hardware security backend this handle targets.
125    pub fn backend_kind(&self) -> BackendKind {
126        self.backend_kind
127    }
128}
129
130/// Standalone helper — no handle required.
131#[allow(clippy::needless_return, unreachable_code)]
132pub fn platform_auth_capabilities() -> AuthCapabilities {
133    #[cfg(target_os = "macos")]
134    return AuthCapabilities {
135        biometric_available: crate::internal::apple::touch_id_available(),
136        password_available: true,
137        presence_caching: true,
138        authenticator_name: Some("Touch ID".into()),
139    };
140
141    #[cfg(target_os = "windows")]
142    return AuthCapabilities {
143        // Checked at runtime via UserConsentVerifier::CheckAvailabilityAsync.
144        biometric_available: crate::internal::windows::hello_gate::is_available(),
145        password_available: true,
146        presence_caching: false,
147        authenticator_name: Some("Windows Hello".into()),
148    };
149
150    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
151    AuthCapabilities {
152        biometric_available: false,
153        password_available: false,
154        presence_caching: false,
155        authenticator_name: None,
156    }
157}
158
159#[cfg(test)]
160#[allow(clippy::unwrap_used)]
161mod tests {
162    use super::*;
163    use crate::config::EnclaveConfig;
164    use crate::factory::create_auth;
165
166    #[test]
167    #[cfg(not(target_os = "windows"))]
168    fn request_presence_never_panics() {
169        // Skip on Windows entirely: request_presence() always requires a GUI prompt
170        // (Hello or password dialog). Even when Hello is not enrolled, the Windows
171        // fallback shows CredUIPromptForWindowsCredentialsW which blocks on headless CI.
172        //
173        // On macOS: skip if Touch ID is available (would block waiting for biometric).
174        // On Linux: always safe — returns PresenceNotAvailable immediately.
175        if platform_auth_capabilities().biometric_available {
176            return;
177        }
178        let config = EnclaveConfig::new("testapp", "key");
179        let handle = create_auth(&config).unwrap();
180        drop(handle.request_presence("test reason"));
181    }
182
183    #[test]
184    fn evict_presence_cache_never_panics() {
185        let config = EnclaveConfig::new("testapp", "key");
186        let handle = create_auth(&config).unwrap();
187        handle.evict_presence_cache(); // Must not panic.
188    }
189
190    #[test]
191    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
192    fn request_presence_returns_not_available_on_linux() {
193        let config = EnclaveConfig::new("testapp", "key");
194        let handle = create_auth(&config).unwrap();
195        let result = handle.request_presence("test");
196        assert!(
197            matches!(result, Err(Error::PresenceNotAvailable)),
198            "Linux must return PresenceNotAvailable, got {result:?}"
199        );
200    }
201
202    #[test]
203    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
204    fn evict_presence_cache_is_noop_on_linux() {
205        let config = EnclaveConfig::new("testapp", "key");
206        let handle = create_auth(&config).unwrap();
207        handle.evict_presence_cache(); // Explicit no-op path; verify no panic.
208                                       // Call twice to confirm idempotency.
209        handle.evict_presence_cache();
210    }
211
212    #[test]
213    fn platform_capabilities_does_not_panic() {
214        let caps = platform_auth_capabilities();
215        let _ = caps.biometric_available;
216        let _ = caps.password_available;
217        let _ = caps.presence_caching;
218    }
219
220    #[test]
221    #[cfg(not(target_os = "windows"))]
222    fn request_presence_returns_not_available_when_no_biometric() {
223        // Skipped on Windows: request_presence always requires GUI interaction
224        // (Hello or password dialog) — no fast-path error for missing biometrics.
225        if platform_auth_capabilities().biometric_available {
226            return;
227        }
228        let config = EnclaveConfig::new("testapp", "key");
229        let handle = create_auth(&config).unwrap();
230        let result = handle.request_presence("ci test");
231        assert!(
232            matches!(result, Err(Error::PresenceNotAvailable)),
233            "platform without biometric must return PresenceNotAvailable, got {result:?}"
234        );
235    }
236}