Skip to main content

auc_tool/
authenticator.rs

1use std::sync::{Arc, Condvar, Mutex};
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use anyhow::{Result, anyhow, bail};
5use soft_fido2::{
6    Authenticator, AuthenticatorCallbacks, AuthenticatorConfig, AuthenticatorOptions, Credential,
7    CredentialBackupState, CredentialRef, CtapCommand, Error, UpResult, UvResult,
8};
9
10use crate::application::TouchReceipt;
11use crate::vault::Vault;
12
13const PRESENCE_TIMEOUT: Duration = Duration::from_secs(30);
14// UUIDv5 of the canonical auc-tool repository URL in the UUID URL namespace.
15const AUC_AAGUID: [u8; 16] = [
16    0x5e, 0x16, 0x05, 0xda, 0x48, 0x1c, 0x5a, 0xb1, 0xa8, 0x8d, 0x5c, 0x33, 0x23, 0x32, 0xe0, 0xb7,
17];
18
19#[derive(Clone)]
20pub struct PresenceGate {
21    shared: Arc<PresenceShared>,
22    timeout: Duration,
23}
24
25struct PresenceShared {
26    state: Mutex<PresenceState>,
27    changed: Condvar,
28}
29
30#[derive(Default)]
31struct PresenceState {
32    command: Option<ActiveCommand>,
33    pending: Option<PendingPresence>,
34}
35
36struct ActiveCommand {
37    channel: u32,
38    cancelled: bool,
39}
40
41struct PendingPresence {
42    channel: u32,
43    operation: String,
44    rp_id: String,
45    deadline: Instant,
46    outcome: Option<PresenceOutcome>,
47}
48
49#[derive(Clone, Copy)]
50enum PresenceOutcome {
51    Accepted,
52    Cancelled,
53}
54
55impl PresenceGate {
56    pub fn new() -> Self {
57        Self::with_timeout(PRESENCE_TIMEOUT)
58    }
59
60    fn with_timeout(timeout: Duration) -> Self {
61        Self {
62            shared: Arc::new(PresenceShared {
63                state: Mutex::new(PresenceState::default()),
64                changed: Condvar::new(),
65            }),
66            timeout,
67        }
68    }
69
70    pub fn begin_command(&self, channel: u32) -> Result<()> {
71        let mut state = self
72            .shared
73            .state
74            .lock()
75            .map_err(|_| anyhow!("auc presence lock was poisoned"))?;
76        if state.command.is_some() {
77            bail!("another CTAP command is already active");
78        }
79        state.command = Some(ActiveCommand {
80            channel,
81            cancelled: false,
82        });
83        Ok(())
84    }
85
86    pub fn finish_command(&self, channel: u32) {
87        if let Ok(mut state) = self.shared.state.lock()
88            && state
89                .command
90                .as_ref()
91                .is_some_and(|command| command.channel == channel)
92        {
93            state.command = None;
94            state.pending = None;
95            self.shared.changed.notify_all();
96        }
97    }
98
99    pub fn cancel(&self, channel: u32) -> bool {
100        let Ok(mut state) = self.shared.state.lock() else {
101            return false;
102        };
103        let Some(command) = state
104            .command
105            .as_mut()
106            .filter(|command| command.channel == channel)
107        else {
108            return false;
109        };
110        command.cancelled = true;
111        if let Some(pending) = state
112            .pending
113            .as_mut()
114            .filter(|pending| pending.channel == channel && pending.outcome.is_none())
115        {
116            pending.outcome = Some(PresenceOutcome::Cancelled);
117        }
118        self.shared.changed.notify_all();
119        true
120    }
121
122    pub fn is_cancelled(&self, channel: u32) -> bool {
123        self.shared
124            .state
125            .lock()
126            .ok()
127            .and_then(|state| {
128                state
129                    .command
130                    .as_ref()
131                    .filter(|command| command.channel == channel)
132                    .map(|command| command.cancelled)
133            })
134            .unwrap_or(true)
135    }
136
137    pub fn is_waiting(&self, channel: u32) -> bool {
138        self.shared
139            .state
140            .lock()
141            .ok()
142            .and_then(|state| {
143                state
144                    .pending
145                    .as_ref()
146                    .filter(|pending| pending.channel == channel)
147                    .map(|pending| pending.outcome.is_none() && Instant::now() < pending.deadline)
148            })
149            .unwrap_or(false)
150    }
151
152    pub fn has_pending_touch(&self) -> bool {
153        self.shared
154            .state
155            .lock()
156            .ok()
157            .and_then(|state| {
158                state
159                    .pending
160                    .as_ref()
161                    .map(|pending| pending.outcome.is_none() && Instant::now() < pending.deadline)
162            })
163            .unwrap_or(false)
164    }
165
166    pub fn touch(&self) -> Result<TouchReceipt> {
167        let mut state = self
168            .shared
169            .state
170            .lock()
171            .map_err(|_| anyhow!("auc presence lock was poisoned"))?;
172        let pending = state
173            .pending
174            .as_mut()
175            .filter(|pending| pending.outcome.is_none() && Instant::now() < pending.deadline)
176            .ok_or_else(|| anyhow!("no auc operation is waiting for presence"))?;
177        pending.outcome = Some(PresenceOutcome::Accepted);
178        let receipt = TouchReceipt {
179            operation: pending.operation.clone(),
180            rp_id: pending.rp_id.clone(),
181        };
182        self.shared.changed.notify_all();
183        Ok(receipt)
184    }
185
186    fn request(&self, information: &str, rp_id: &str) -> soft_fido2::Result<UpResult> {
187        let mut state = self.shared.state.lock().map_err(|_| Error::Other)?;
188        let channel = match &state.command {
189            Some(command) if !command.cancelled => command.channel,
190            _ => return Ok(UpResult::Denied),
191        };
192        if state.pending.is_some() {
193            return Ok(UpResult::Denied);
194        }
195        let deadline = Instant::now() + self.timeout;
196        state.pending = Some(PendingPresence {
197            channel,
198            operation: operation_label(information).to_string(),
199            rp_id: bounded_rp_id(rp_id),
200            deadline,
201            outcome: None,
202        });
203        loop {
204            let now = Instant::now();
205            let outcome = state
206                .pending
207                .as_ref()
208                .filter(|pending| pending.channel == channel)
209                .and_then(|pending| pending.outcome);
210            match outcome {
211                Some(PresenceOutcome::Accepted) => {
212                    state.pending = None;
213                    return Ok(UpResult::Accepted);
214                }
215                Some(PresenceOutcome::Cancelled) => {
216                    state.pending = None;
217                    return Ok(UpResult::Denied);
218                }
219                None if now >= deadline => {
220                    state.pending = None;
221                    return Ok(UpResult::Timeout);
222                }
223                None => {
224                    let wait = deadline.saturating_duration_since(now);
225                    let (next, _) = self
226                        .shared
227                        .changed
228                        .wait_timeout(state, wait)
229                        .map_err(|_| Error::Other)?;
230                    state = next;
231                }
232            }
233        }
234    }
235}
236
237impl Default for PresenceGate {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243pub struct AuthenticatorEngine {
244    authenticator: Authenticator<AucCallbacks>,
245}
246
247impl AuthenticatorEngine {
248    pub fn new(vault: Vault, presence: PresenceGate) -> Result<Self> {
249        let options = AuthenticatorOptions::new()
250            .with_resident_keys(true)
251            .with_user_presence(true)
252            .with_user_verification(Some(false))
253            .with_platform_device(false)
254            .with_client_pin(Some(true))
255            .with_pin_uv_auth_token(Some(true))
256            .with_credential_management(Some(true))
257            .with_biometric_enrollment(Some(false))
258            .with_large_blobs(Some(false))
259            .with_enterprise_attestation(Some(false));
260        let config = AuthenticatorConfig::builder()
261            .aaguid(AUC_AAGUID)
262            .commands(vec![
263                CtapCommand::MakeCredential,
264                CtapCommand::GetAssertion,
265                CtapCommand::GetInfo,
266                CtapCommand::ClientPin,
267                CtapCommand::GetNextAssertion,
268                CtapCommand::CredentialManagement,
269                CtapCommand::Selection,
270            ])
271            .options(options)
272            .max_credentials(4096)
273            .force_resident_keys(true)
274            .constant_sign_count(true)
275            .default_credential_backup_state(CredentialBackupState::Eligible)
276            .algorithms(vec![-7])
277            .device_name("auc software authenticator".to_string())
278            .vendor_id(0x1209)
279            .product_id(0xa0c0)
280            .device_version(0x0001)
281            .max_pin_retries(8)
282            .build();
283        let pin_storage = vault.pin_storage();
284        let callbacks = AucCallbacks { vault, presence };
285        Ok(Self {
286            authenticator: Authenticator::with_config_and_pin_storage(
287                callbacks,
288                config,
289                pin_storage,
290            )
291            .map_err(|_| anyhow!("failed to initialize soft-fido2 authenticator"))?,
292        })
293    }
294
295    pub fn handle(&mut self, request: &[u8]) -> Result<Vec<u8>> {
296        if !request
297            .first()
298            .is_some_and(|command| ALLOWED_COMMANDS.contains(command))
299        {
300            return Ok(vec![soft_fido2::StatusCode::InvalidCommand as u8]);
301        }
302        let mut response = Vec::new();
303        self.authenticator
304            .handle(request, &mut response)
305            .map_err(|_| anyhow!("soft-fido2 command dispatch failed"))?;
306        Ok(response)
307    }
308}
309
310const ALLOWED_COMMANDS: &[u8] = &[0x01, 0x02, 0x04, 0x06, 0x08, 0x0a, 0x0b];
311
312struct AucCallbacks {
313    vault: Vault,
314    presence: PresenceGate,
315}
316
317impl AuthenticatorCallbacks for AucCallbacks {
318    fn request_up(
319        &self,
320        information: &str,
321        _user_name: Option<&str>,
322        rp_id: &str,
323    ) -> soft_fido2::Result<UpResult> {
324        self.presence.request(information, rp_id)
325    }
326
327    fn request_uv(
328        &self,
329        _information: &str,
330        _user_name: Option<&str>,
331        _rp_id: &str,
332    ) -> soft_fido2::Result<UvResult> {
333        Ok(UvResult::Denied)
334    }
335
336    fn write_credential(&self, credential: &CredentialRef<'_>) -> soft_fido2::Result<()> {
337        self.vault.write_credential(credential).map_err(vault_error)
338    }
339
340    fn read_credential(&self, credential_id: &[u8]) -> soft_fido2::Result<Option<Credential>> {
341        self.vault
342            .read_credential(credential_id)
343            .map_err(vault_error)
344    }
345
346    fn delete_credential(&self, credential_id: &[u8]) -> soft_fido2::Result<()> {
347        self.vault
348            .delete_credential(credential_id)
349            .map(|_| ())
350            .map_err(vault_error)
351    }
352
353    fn list_credentials(
354        &self,
355        rp_id: &str,
356        user_id: Option<&[u8]>,
357    ) -> soft_fido2::Result<Vec<Credential>> {
358        self.vault
359            .list_credentials(rp_id, user_id)
360            .map_err(vault_error)
361    }
362
363    fn select_credential(
364        &self,
365        _rp_id: &str,
366        credentials: &[Credential],
367    ) -> soft_fido2::Result<usize> {
368        if credentials.is_empty() {
369            Err(Error::Other)
370        } else {
371            Ok(0)
372        }
373    }
374
375    fn enumerate_rps(&self) -> soft_fido2::Result<Vec<(String, Option<String>, usize)>> {
376        self.vault.enumerate_rps().map_err(vault_error)
377    }
378
379    fn credential_count(&self) -> soft_fido2::Result<usize> {
380        self.vault.credential_count().map_err(vault_error)
381    }
382
383    fn get_timestamp_ms(&self) -> u64 {
384        SystemTime::now()
385            .duration_since(UNIX_EPOCH)
386            .map(|duration| duration.as_millis() as u64)
387            .unwrap_or(0)
388    }
389}
390
391fn vault_error(error: anyhow::Error) -> Error {
392    eprintln!("auc vault operation failed: {error:#}");
393    Error::Other
394}
395
396fn operation_label(information: &str) -> &'static str {
397    let information = information.to_ascii_lowercase();
398    if information.contains("registration") || information.contains("make credential") {
399        "register passkey"
400    } else if information.contains("authentication") || information.contains("assertion") {
401        "authenticate"
402    } else {
403        "confirm authenticator operation"
404    }
405}
406
407fn bounded_rp_id(rp_id: &str) -> String {
408    rp_id.chars().take(253).collect()
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn early_touch_is_never_cached_and_touch_is_consumed_once() {
417        let gate = PresenceGate::with_timeout(Duration::from_secs(1));
418        assert!(gate.touch().is_err());
419        gate.begin_command(7).unwrap();
420        let callback = gate.clone();
421        let thread = std::thread::spawn(move || callback.request("authentication", "example.test"));
422        while !gate.has_pending_touch() {
423            std::thread::yield_now();
424        }
425        let receipt = gate.touch().unwrap();
426        assert_eq!(receipt.operation, "authenticate");
427        assert_eq!(receipt.rp_id, "example.test");
428        assert!(gate.touch().is_err());
429        assert_eq!(thread.join().unwrap().unwrap(), UpResult::Accepted);
430        gate.finish_command(7);
431    }
432
433    #[test]
434    fn cancellation_wakes_presence_wait_immediately() {
435        let gate = PresenceGate::with_timeout(Duration::from_secs(10));
436        gate.begin_command(9).unwrap();
437        let callback = gate.clone();
438        let thread = std::thread::spawn(move || callback.request("registration", "example.test"));
439        while !gate.has_pending_touch() {
440            std::thread::yield_now();
441        }
442        assert!(gate.cancel(9));
443        assert_eq!(thread.join().unwrap().unwrap(), UpResult::Denied);
444        assert!(!gate.has_pending_touch());
445        gate.finish_command(9);
446    }
447
448    #[test]
449    fn operation_labels_do_not_echo_arbitrary_core_text() {
450        assert_eq!(
451            operation_label("Make Credential registration"),
452            "register passkey"
453        );
454        assert_eq!(operation_label("get assertion"), "authenticate");
455        assert_eq!(
456            operation_label("attacker controlled detail"),
457            "confirm authenticator operation"
458        );
459    }
460}