Skip to main content

sequel_mcp/vault/
touchid.rs

1//! Touch ID via LocalAuthentication (objc2 bindings). Fail closed:
2//! unavailable, failed, cancelled and internal errors all deny.
3
4use std::sync::Mutex;
5use std::time::{Duration, Instant};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TouchIdState {
9    /// Prompt available and biometrics enrolled.
10    Available,
11    /// Not available on this platform/hardware.
12    Unavailable,
13}
14
15/// Outcome of one prompt attempt.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum PromptOutcome {
18    Authenticated,
19    Failed,
20    Cancelled,
21    SystemCancelled,
22    Unavailable(String),
23}
24
25pub trait TouchIdPrompt: Send + Sync {
26    fn state(&self) -> TouchIdState;
27    fn prompt(&self, reason: &str) -> PromptOutcome;
28}
29
30/// Session cache: a short, visible, revocable user-presence window.
31pub struct SessionAuthenticator {
32    prompt: Box<dyn TouchIdPrompt>,
33    idle: Mutex<Option<Instant>>,
34    idle_window: Duration,
35}
36
37pub const DEFAULT_IDLE_WINDOW: Duration = Duration::from_secs(15 * 60);
38
39impl SessionAuthenticator {
40    pub fn new(prompt: Box<dyn TouchIdPrompt>) -> Self {
41        Self {
42            prompt,
43            idle: Mutex::new(None),
44            idle_window: DEFAULT_IDLE_WINDOW,
45        }
46    }
47
48    pub fn with_window(mut self, window: Duration) -> Self {
49        self.idle_window = window;
50        self
51    }
52
53    pub fn invalidate(&self) {
54        *self.idle.lock().unwrap() = None;
55    }
56
57    /// FAIL CLOSED: required-but-unavailable means deny (legacy returned
58    /// true — the documented fail-open defect, fixed here).
59    pub fn ensure_authenticated(&self, reason: &str) -> bool {
60        if self.prompt.state() != TouchIdState::Available {
61            return false;
62        }
63        let mut last = self.idle.lock().unwrap();
64        if let Some(t) = *last
65            && t.elapsed() < self.idle_window
66        {
67            return true;
68        }
69        match self.prompt.prompt(reason) {
70            PromptOutcome::Authenticated => {
71                *last = Some(Instant::now());
72                true
73            }
74            _ => {
75                *last = None;
76                false
77            }
78        }
79    }
80}
81
82#[cfg(target_os = "macos")]
83pub fn system_touch_id() -> Box<dyn TouchIdPrompt> {
84    Box::new(macos::MacTouchId)
85}
86
87#[cfg(not(target_os = "macos"))]
88pub fn system_touch_id() -> Box<dyn TouchIdPrompt> {
89    Box::new(NoTouchId)
90}
91
92/// Non-macOS / unavailable prompt.
93pub struct NoTouchId;
94impl TouchIdPrompt for NoTouchId {
95    fn state(&self) -> TouchIdState {
96        TouchIdState::Unavailable
97    }
98    fn prompt(&self, _reason: &str) -> PromptOutcome {
99        PromptOutcome::Unavailable("Touch ID not available on this platform".into())
100    }
101}
102
103#[cfg(target_os = "macos")]
104mod macos {
105    use super::*;
106    use objc2::rc::Retained;
107    use objc2::runtime::Bool;
108    use objc2_foundation::NSString;
109    use objc2_local_authentication::{
110        LAContext, LAPolicy, kLAErrorBiometryLockout, kLAErrorBiometryNotAvailable,
111        kLAErrorBiometryNotEnrolled, kLAErrorPasscodeNotSet, kLAErrorSystemCancel,
112        kLAErrorUserCancel, kLAErrorUserFallback,
113    };
114
115    pub struct MacTouchId;
116
117    impl MacTouchId {
118        fn fresh_context() -> Retained<LAContext> {
119            // A fresh context per evaluation so a previous failed attempt
120            // never satisfies a later prompt.
121            unsafe { LAContext::new() }
122        }
123
124        fn availability() -> TouchIdState {
125            let ctx = Self::fresh_context();
126            let policy = LAPolicy::DeviceOwnerAuthenticationWithBiometrics;
127            match unsafe { ctx.canEvaluatePolicy_error(policy) } {
128                Ok(()) => TouchIdState::Available,
129                Err(err) => {
130                    let code = err.code() as i32;
131                    if code == kLAErrorBiometryNotAvailable
132                        || code == kLAErrorBiometryNotEnrolled
133                        || code == kLAErrorBiometryLockout
134                        || code == kLAErrorPasscodeNotSet
135                    {
136                        TouchIdState::Unavailable
137                    } else {
138                        // Unknown preconditions also count as unavailable;
139                        // callers fail closed either way.
140                        TouchIdState::Unavailable
141                    }
142                }
143            }
144        }
145    }
146
147    impl TouchIdPrompt for MacTouchId {
148        fn state(&self) -> TouchIdState {
149            Self::availability()
150        }
151
152        fn prompt(&self, reason: &str) -> PromptOutcome {
153            let ctx = Self::fresh_context();
154            let policy = LAPolicy::DeviceOwnerAuthenticationWithBiometrics;
155            if let Err(e) = unsafe { ctx.canEvaluatePolicy_error(policy) } {
156                return PromptOutcome::Unavailable(format!(
157                    "biometrics unavailable: code {}",
158                    e.code()
159                ));
160            }
161            let reason_ns = NSString::from_str(reason);
162            let (tx, rx) = std::sync::mpsc::channel::<(bool, Option<i32>)>();
163            let tx = Mutex::new(Some(tx));
164            // evaluatePolicy invokes the reply block on a private queue;
165            // bridge it to a channel receive below.
166            let block =
167                block2::RcBlock::new(move |ok: Bool, err: *mut objc2_foundation::NSError| {
168                    let code = if err.is_null() {
169                        None
170                    } else {
171                        Some(unsafe { (*err).code() as i32 })
172                    };
173                    if let Some(tx) = tx.lock().unwrap().take() {
174                        let _ = tx.send((ok.as_bool(), code));
175                    }
176                });
177            let dyn_block: &block2::DynBlock<dyn Fn(Bool, *mut objc2_foundation::NSError)> = &block;
178            unsafe { ctx.evaluatePolicy_localizedReason_reply(policy, &reason_ns, dyn_block) }
179            match rx.recv_timeout(Duration::from_secs(120)) {
180                Ok((true, _)) => PromptOutcome::Authenticated,
181                Ok((false, Some(code))) => {
182                    if code == kLAErrorUserCancel || code == kLAErrorUserFallback {
183                        PromptOutcome::Cancelled
184                    } else if code == kLAErrorSystemCancel {
185                        PromptOutcome::SystemCancelled
186                    } else {
187                        PromptOutcome::Failed
188                    }
189                }
190                Ok((false, None)) => PromptOutcome::Failed,
191                Err(_) => PromptOutcome::Unavailable("prompt timed out".into()),
192            }
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    struct FakePrompt {
202        state: TouchIdState,
203        outcomes: Mutex<Vec<PromptOutcome>>,
204    }
205
206    impl TouchIdPrompt for FakePrompt {
207        fn state(&self) -> TouchIdState {
208            self.state
209        }
210        fn prompt(&self, _reason: &str) -> PromptOutcome {
211            self.outcomes.lock().unwrap().remove(0)
212        }
213    }
214
215    fn auth(state: TouchIdState, outcomes: Vec<PromptOutcome>) -> SessionAuthenticator {
216        SessionAuthenticator::new(Box::new(FakePrompt {
217            state,
218            outcomes: Mutex::new(outcomes),
219        }))
220    }
221
222    #[test]
223    fn unavailable_fails_closed() {
224        let a = auth(TouchIdState::Unavailable, vec![]);
225        assert!(!a.ensure_authenticated("test"), "unavailable must deny");
226    }
227
228    #[test]
229    fn success_caches_within_window() {
230        let a = auth(
231            TouchIdState::Available,
232            vec![PromptOutcome::Authenticated, PromptOutcome::Failed],
233        );
234        assert!(a.ensure_authenticated("test"));
235        // Second call inside the window must NOT consume the failed prompt.
236        assert!(a.ensure_authenticated("test"));
237    }
238
239    #[test]
240    fn failure_does_not_cache() {
241        let a = auth(
242            TouchIdState::Available,
243            vec![PromptOutcome::Cancelled, PromptOutcome::Authenticated],
244        );
245        assert!(!a.ensure_authenticated("test"));
246        assert!(a.ensure_authenticated("test"));
247    }
248
249    #[test]
250    fn invalidate_forces_reprompt() {
251        let a = auth(
252            TouchIdState::Available,
253            vec![PromptOutcome::Authenticated, PromptOutcome::Authenticated],
254        );
255        assert!(a.ensure_authenticated("test"));
256        a.invalidate();
257        assert!(a.ensure_authenticated("test"));
258    }
259}