sequel_mcp/vault/
touchid.rs1use std::sync::Mutex;
5use std::time::{Duration, Instant};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TouchIdState {
9 Available,
11 Unavailable,
13}
14
15#[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
30pub 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 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
92pub 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 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 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 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)> = █
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 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}