netget/server/usb/fido2/mod.rs
1//! USB FIDO2/U2F Security Key server implementation
2//!
3//! A virtual FIDO2/U2F security key exported over USB/IP. The device presents an HID interface
4//! (class 0x03) speaking CTAPHID, and answers CTAP1 (U2F) and CTAP2 (FIDO2) commands. The one
5//! decision the model makes is the one a human makes on a real key: **approve or deny this
6//! registration / authentication**.
7//!
8//! ## What was wrong here, and is worth remembering
9//!
10//! This protocol was registered, advertised as `Experimental`, and could not work in any way:
11//!
12//! 1. **No LLM integration at all.** `spawn_with_llm_actions` took `_llm_client` and
13//! `_app_state` and used neither. `get_sync_actions()` returned `vec![]` and did not
14//! delegate, so the model had no vocabulary. All three declared events had zero emit sites.
15//! 2. **The model-facing actions panicked.** `execute_action({"type":"approve_request"})` did
16//! `tokio::runtime::Handle::current().block_on(...)` — *"Cannot block the current thread from
17//! within a runtime"* — and the event's own example taught the model to answer with exactly
18//! that action. The CTAP2 handler did the same thing on every MakeCredential and
19//! GetAssertion.
20//! 3. **`spawn` could not report failure.** `usbip::server(listen_addr, …)` was called *inside*
21//! `tokio::spawn`, after `Ok(listen_addr)` had already been returned, so a port conflict was
22//! invisible and the server sat in `Running` having bound nothing.
23//!
24//! The `block_on` shape is the same one documented in `src/server/usb/msc/handler.rs`: `usbip`
25//! calls the synchronous `UsbInterfaceHandler::handle_urb` from a tokio worker, so the whole
26//! handler path must be synchronous. It is now.
27//!
28//! ## How approval works without blocking
29//!
30//! CTAPHID is asynchronous by design — that is what its `KEEPALIVE(UPNEEDED)` status is for.
31//!
32//! ```text
33//! host handler (sync) connection task (async)
34//! │ CBOR MakeCredential │ │
35//! ├──────────────────────────►│ parse, decide it needs UP │
36//! │ ├── ApprovalDetails ──────────►│ open() → approval id
37//! │◄── KEEPALIVE(UPNEEDED) ───┤ │ raise fido2_register_request
38//! │ (polls IN) │ │ call_llm → approve_request
39//! │ │◄──── resolve(Approved) ──────┤ wait() → decision
40//! │◄── CBOR response ─────────┤ replay the command │
41//! ```
42//!
43//! Nothing is created or signed before the decision: the handler parses the command, answers
44//! the *question*, and only replays it once a decision exists. A denial therefore leaves the
45//! credential store exactly as it was, and an unanswered request denies (see `approval.rs`).
46//!
47//! ## Events
48//!
49//! * `fido2_device_attached` — a host connected. Informational (`with_no_actions()`).
50//! * `fido2_register_request` — a credential is being created. Answer `approve_request` or
51//! `deny_request`.
52//! * `fido2_authenticate_request` — an assertion is being requested. Same two actions.
53//! * `fido2_device_detached` — the USB/IP session ended. Informational.
54
55pub mod actions;
56pub mod approval;
57pub mod ctap2;
58pub mod ctaphid;
59pub mod u2f;
60
61#[cfg(all(unix, feature = "libusb"))]
62use anyhow::Result;
63#[cfg(all(unix, feature = "libusb"))]
64use std::collections::HashMap;
65#[cfg(all(unix, feature = "libusb"))]
66use std::net::SocketAddr;
67#[cfg(all(unix, feature = "libusb"))]
68use std::sync::{Arc, Mutex};
69#[cfg(all(unix, feature = "libusb"))]
70use tokio::sync::mpsc;
71#[cfg(all(unix, feature = "libusb"))]
72use tracing::{debug, error, info, warn};
73
74#[cfg(all(unix, feature = "libusb"))]
75use crate::console_error;
76#[cfg(all(unix, feature = "libusb"))]
77use crate::llm::action_helper::call_llm;
78#[cfg(all(unix, feature = "libusb"))]
79use crate::llm::ollama_client::OllamaClient;
80#[cfg(all(unix, feature = "libusb"))]
81use crate::protocol::Event;
82#[cfg(all(unix, feature = "libusb"))]
83use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
84#[cfg(all(unix, feature = "libusb"))]
85use crate::server::connection::ConnectionId;
86#[cfg(all(unix, feature = "libusb"))]
87use crate::server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL};
88#[cfg(all(unix, feature = "libusb"))]
89use crate::state::app_state::AppState;
90
91#[cfg(all(unix, feature = "libusb"))]
92use crate::server::usb::descriptors::FIDO_HID_REPORT_DESCRIPTOR;
93
94#[cfg(all(unix, feature = "libusb"))]
95use actions::{
96 UsbFido2Protocol, FIDO2_AUTHENTICATE_REQUEST_EVENT, FIDO2_DEVICE_ATTACHED_EVENT,
97 FIDO2_DEVICE_DETACHED_EVENT, FIDO2_REGISTER_REQUEST_EVENT,
98};
99#[cfg(all(unix, feature = "libusb"))]
100use approval::{
101 ApprovalConfig, ApprovalDecision, ApprovalDetails, ApprovalManager, OperationType, UserPresence,
102};
103#[cfg(all(unix, feature = "libusb"))]
104use ctap2::{Ctap2Handler, Ctap2Outcome};
105#[cfg(all(unix, feature = "libusb"))]
106use ctaphid::{CtapHidCommand, CtapHidHandler, CtapHidPacket, KeepaliveStatus};
107#[cfg(all(unix, feature = "libusb"))]
108use u2f::{U2fHandler, U2fOutcome};
109
110/// USB FIDO2 Security Key server
111#[cfg(all(unix, feature = "libusb"))]
112pub struct UsbFido2Server;
113
114/// A CTAP command parked waiting for a user-presence decision.
115#[cfg(all(unix, feature = "libusb"))]
116struct ParkedCommand {
117 cid: u32,
118 cmd: CtapHidCommand,
119 /// The raw request, replayed verbatim once the decision arrives. Parsing is pure, so
120 /// replaying is exactly equivalent to resuming — and it keeps the two protocol handlers
121 /// free of any half-executed state.
122 data: Vec<u8>,
123}
124
125/// FIDO2 USB/IP HID handler
126#[cfg(all(unix, feature = "libusb"))]
127pub struct Fido2HidHandler {
128 /// CTAPHID protocol handler
129 ctaphid: CtapHidHandler,
130 /// U2F command handler
131 u2f: U2fHandler,
132 /// CTAP2 command handler
133 ctap2: Ctap2Handler,
134 /// Pending response packets
135 response_packets: Vec<Vec<u8>>,
136 /// Command waiting on a user-presence decision, if any.
137 parked: Option<ParkedCommand>,
138 /// Where approval questions are posted for the connection task to pick up.
139 approval_tx: Option<mpsc::UnboundedSender<ApprovalDetails>>,
140 /// Whether CTAP1/U2F (`MSG`) is answered at all.
141 support_u2f: bool,
142 /// Whether CTAP2/FIDO2 (`CBOR`) is answered at all.
143 support_fido2: bool,
144}
145
146#[cfg(all(unix, feature = "libusb"))]
147impl Fido2HidHandler {
148 pub fn new(support_u2f: bool, support_fido2: bool) -> Self {
149 Self {
150 ctaphid: CtapHidHandler::new(),
151 u2f: U2fHandler::new(),
152 ctap2: Ctap2Handler::new(),
153 response_packets: Vec::new(),
154 parked: None,
155 approval_tx: None,
156 support_u2f,
157 support_fido2,
158 }
159 }
160
161 /// Attach the channel the connection task listens on.
162 pub fn with_approvals(mut self, tx: mpsc::UnboundedSender<ApprovalDetails>) -> Self {
163 self.approval_tx = Some(tx);
164 self
165 }
166
167 /// Feed a decision back in and finish the parked command.
168 ///
169 /// Called by the connection task once the model has answered. A decision that arrives with
170 /// nothing parked is a no-op — the host may have cancelled, or the session may have ended.
171 pub fn resolve_approval(&mut self, decision: ApprovalDecision) {
172 let Some(parked) = self.parked.take() else {
173 debug!("FIDO2 approval decision arrived with no parked command");
174 return;
175 };
176
177 let presence = match decision {
178 ApprovalDecision::Approved => UserPresence::Approved,
179 ApprovalDecision::Denied => UserPresence::Denied,
180 };
181
182 let packets = self.run_command(parked.cid, parked.cmd, &parked.data, presence);
183 self.response_packets = packets;
184 }
185
186 /// Credentials currently held, for the `list_credentials` action.
187 ///
188 /// Reports relying party, user name and counter — never a key handle, a credential id or
189 /// any key material.
190 pub fn describe_credentials(&self) -> Vec<serde_json::Value> {
191 let mut out: Vec<serde_json::Value> = self
192 .ctap2
193 .store()
194 .describe_credentials()
195 .into_iter()
196 .map(|(rp_id, user_name, resident, counter)| {
197 serde_json::json!({
198 "protocol": "fido2",
199 "rp_id": rp_id,
200 "user_name": user_name,
201 "resident": resident,
202 "counter": counter,
203 })
204 })
205 .collect();
206 out.extend(
207 self.u2f
208 .store()
209 .describe_credentials()
210 .into_iter()
211 .map(|(label, counter)| {
212 serde_json::json!({
213 "protocol": "u2f",
214 "rp_id": label,
215 "counter": counter,
216 })
217 }),
218 );
219 out
220 }
221
222 /// Forget every credential for a relying party. Returns how many were removed.
223 pub fn delete_credentials(&mut self, rp_id: &str) -> usize {
224 self.ctap2.store_mut().delete_credentials(rp_id)
225 + self.u2f.store_mut().delete_credentials(rp_id)
226 }
227
228 /// Run one CTAPHID command and produce the packets to hand back.
229 ///
230 /// `presence` is `Ask` on the first attempt; a command that needs user presence parks
231 /// itself and answers KEEPALIVE instead.
232 fn run_command(
233 &mut self,
234 cid: u32,
235 cmd: CtapHidCommand,
236 data: &[u8],
237 presence: UserPresence,
238 ) -> Vec<Vec<u8>> {
239 debug!(
240 "CTAPHID command: {:?}, cid={:#010x}, data_len={}, presence={:?}",
241 cmd,
242 cid,
243 data.len(),
244 presence
245 );
246
247 let response_data = match cmd {
248 CtapHidCommand::Init => {
249 // INIT: 8-byte nonce in, nonce + new CID + versions + capabilities out.
250 if data.len() < 8 {
251 return vec![CtapHidPacket::build_error(
252 cid,
253 ctaphid::CtapHidError::InvalidLen,
254 )];
255 }
256
257 let new_cid = self.ctaphid.allocate_channel();
258 let nonce = &data[..8];
259
260 let mut response = Vec::new();
261 response.extend_from_slice(nonce); // Echo nonce
262 response.extend_from_slice(&new_cid.to_be_bytes()); // New CID
263 response.push(2); // CTAPHID protocol version
264 response.push(0); // Major device version
265 response.push(0); // Minor device version
266 response.push(0); // Build device version
267 // Capabilities: WINK (0x01), plus CBOR (0x04) when CTAP2 is on
268 // and NMSG (0x08) when CTAP1 is off. A host reads this to
269 // decide which protocol to speak, so it must follow the
270 // support flags rather than being a constant.
271 let mut capabilities = 0x01u8;
272 if self.support_fido2 {
273 capabilities |= 0x04;
274 }
275 if !self.support_u2f {
276 capabilities |= 0x08;
277 }
278 response.push(capabilities);
279
280 info!(
281 "CTAPHID INIT: allocated CID {:#010x} (capabilities {:#04x})",
282 new_cid, capabilities
283 );
284 response
285 }
286
287 CtapHidCommand::Ping => {
288 debug!("CTAPHID PING: {} bytes", data.len());
289 data.to_vec()
290 }
291
292 CtapHidCommand::Msg => {
293 if !self.support_u2f {
294 warn!("CTAPHID MSG received but U2F support is disabled");
295 return vec![CtapHidPacket::build_error(
296 cid,
297 ctaphid::CtapHidError::InvalidCmd,
298 )];
299 }
300 debug!("CTAPHID MSG (U2F): processing {} bytes", data.len());
301 match self.u2f.process_command(data, presence) {
302 U2fOutcome::Response(bytes) => bytes,
303 U2fOutcome::NeedsApproval(details) => {
304 return self.park(cid, cmd, data, details)
305 }
306 }
307 }
308
309 CtapHidCommand::Cbor => {
310 if !self.support_fido2 {
311 warn!("CTAPHID CBOR received but FIDO2 support is disabled");
312 return vec![CtapHidPacket::build_error(
313 cid,
314 ctaphid::CtapHidError::InvalidCmd,
315 )];
316 }
317 debug!("CTAPHID CBOR (CTAP2): processing {} bytes", data.len());
318 match self.ctap2.process_command(data, presence) {
319 Ctap2Outcome::Response(bytes) => bytes,
320 Ctap2Outcome::NeedsApproval(details) => {
321 return self.park(cid, cmd, data, details)
322 }
323 }
324 }
325
326 CtapHidCommand::Wink => {
327 debug!("CTAPHID WINK");
328 Vec::new()
329 }
330
331 CtapHidCommand::Cancel => {
332 // A cancelled command must not stay parked: the host has moved on, and
333 // resolving it later would sign something nobody is waiting for.
334 if self.parked.take().is_some() {
335 info!(
336 "CTAPHID CANCEL: dropped the parked command on cid {:#010x}",
337 cid
338 );
339 }
340 debug!("CTAPHID CANCEL");
341 Vec::new()
342 }
343
344 _ => {
345 warn!("Unsupported CTAPHID command: {:?}", cmd);
346 return vec![CtapHidPacket::build_error(
347 cid,
348 ctaphid::CtapHidError::InvalidCmd,
349 )];
350 }
351 };
352
353 self.ctaphid.fragment_response(cid, cmd, &response_data)
354 }
355
356 /// Park a command awaiting user presence and answer the host with KEEPALIVE.
357 fn park(
358 &mut self,
359 cid: u32,
360 cmd: CtapHidCommand,
361 data: &[u8],
362 details: ApprovalDetails,
363 ) -> Vec<Vec<u8>> {
364 let Some(ref tx) = self.approval_tx else {
365 // No connection task to ask means no user presence can ever be established.
366 // Refuse rather than proceed: an authenticator that signs without presence
367 // because nobody was listening is the fail-open shape this codebase keeps
368 // getting bitten by.
369 warn!(
370 "FIDO2 {:?} for '{}' needs user presence but no approval channel is attached; \
371 denying",
372 details.operation, details.rp_id
373 );
374 return self.deny_presence_command(cid, cmd, data);
375 };
376
377 info!(
378 "FIDO2 {:?} for '{}' parked awaiting user presence",
379 details.operation, details.rp_id
380 );
381
382 if tx.send(details).is_err() {
383 warn!("FIDO2 approval channel closed; denying the request");
384 return self.deny_presence_command(cid, cmd, data);
385 }
386
387 self.parked = Some(ParkedCommand {
388 cid,
389 cmd,
390 data: data.to_vec(),
391 });
392
393 vec![CtapHidPacket::build_keepalive(
394 cid,
395 KeepaliveStatus::UpNeeded,
396 )]
397 }
398
399 /// Replay a refused presence request exactly once, without entering dispatch/parking.
400 ///
401 /// Previously park called run_command again and relied on the CTAP handlers never
402 /// returning NeedsApproval for Denied. Keep their normal protocol errors, but refuse
403 /// explicitly if that invariant ever regresses; no handler outcome can recurse here.
404 fn deny_presence_command(
405 &mut self,
406 cid: u32,
407 cmd: CtapHidCommand,
408 data: &[u8],
409 ) -> Vec<Vec<u8>> {
410 let response = match cmd {
411 CtapHidCommand::Msg => match self.u2f.process_command(data, UserPresence::Denied) {
412 U2fOutcome::Response(bytes) => bytes,
413 U2fOutcome::NeedsApproval(_) => {
414 error!("U2F requested approval after denial; refusing without replay");
415 u2f::U2fResponse::error(u2f::SW_CONDITIONS_NOT_SATISFIED).to_bytes()
416 }
417 },
418 CtapHidCommand::Cbor => match self.ctap2.process_command(data, UserPresence::Denied) {
419 Ctap2Outcome::Response(bytes) => bytes,
420 Ctap2Outcome::NeedsApproval(_) => {
421 error!("CTAP2 requested approval after denial; refusing without replay");
422 ctap2::Ctap2Response::error(ctap2::Ctap2Status::OperationDenied).to_bytes()
423 }
424 },
425 _ => {
426 return vec![CtapHidPacket::build_error(
427 cid,
428 ctaphid::CtapHidError::InvalidCmd,
429 )];
430 }
431 };
432 self.ctaphid.fragment_response(cid, cmd, &response)
433 }
434}
435
436#[cfg(all(unix, feature = "libusb"))]
437/// Opaque on purpose.
438///
439/// `usbip::UsbInterfaceHandler` requires `Debug` as of 0.9. Deriving it would pull the CTAPHID,
440/// U2F and CTAP2 handlers into the output, and those hold credential and key material for a
441/// security key -- exactly the thing that must not reach a log line. The type name alone
442/// satisfies the bound and says nothing it should not.
443impl std::fmt::Debug for Fido2HidHandler {
444 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
445 f.write_str("Fido2HidHandler { .. }")
446 }
447}
448
449#[cfg(all(unix, feature = "libusb"))]
450impl usbip::UsbInterfaceHandler for Fido2HidHandler {
451 fn handle_urb(
452 &mut self,
453 _interface: &usbip::UsbInterface,
454 endpoint: usbip::UsbEndpoint,
455 // usbip 0.9 passes the host's declared transfer length. CTAPHID frames are a fixed
456 // 64 bytes, so this handler derives its lengths from the frame and not from here.
457 _transfer_buffer_length: u32,
458 setup: usbip::SetupPacket,
459 data: &[u8],
460 ) -> std::result::Result<Vec<u8>, std::io::Error> {
461 use crate::server::usb::common::{descriptor_type, hid_request, request, request_type};
462
463 // Control transfers arrive on endpoint 0 in either direction; the crate's control IN
464 // endpoint is 0x80, so testing `address == 0` alone misroutes it (the bug MSC had).
465 if endpoint.is_ep0() {
466 debug!(
467 "FIDO2 control request: type={:#04x}, request={:#04x}, value={:#06x}",
468 setup.request_type, setup.request, setup.value
469 );
470
471 match (setup.request_type, setup.request) {
472 // Get HID Report Descriptor
473 (
474 request_type::DEVICE_TO_HOST | request_type::STANDARD | request_type::INTERFACE,
475 request::GET_DESCRIPTOR,
476 ) => {
477 let desc_type = (setup.value >> 8) as u8;
478 if desc_type == descriptor_type::HID_REPORT {
479 debug!(
480 "GET_DESCRIPTOR: HID Report ({}bytes)",
481 FIDO_HID_REPORT_DESCRIPTOR.len()
482 );
483 Ok(FIDO_HID_REPORT_DESCRIPTOR.to_vec())
484 } else {
485 warn!("Unsupported descriptor type: {:#04x}", desc_type);
486 Err(std::io::Error::new(
487 std::io::ErrorKind::InvalidInput,
488 "Unsupported descriptor",
489 ))
490 }
491 }
492
493 // Get/Set Idle
494 (
495 request_type::DEVICE_TO_HOST | request_type::CLASS | request_type::INTERFACE,
496 hid_request::GET_IDLE,
497 ) => {
498 debug!("GET_IDLE");
499 Ok(vec![0])
500 }
501 (
502 request_type::HOST_TO_DEVICE | request_type::CLASS | request_type::INTERFACE,
503 hid_request::SET_IDLE,
504 ) => {
505 debug!("SET_IDLE");
506 Ok(vec![])
507 }
508
509 // Get/Set Protocol
510 (
511 request_type::DEVICE_TO_HOST | request_type::CLASS | request_type::INTERFACE,
512 hid_request::GET_PROTOCOL,
513 ) => {
514 debug!("GET_PROTOCOL");
515 Ok(vec![0]) // Report protocol
516 }
517 (
518 request_type::HOST_TO_DEVICE | request_type::CLASS | request_type::INTERFACE,
519 hid_request::SET_PROTOCOL,
520 ) => {
521 debug!("SET_PROTOCOL");
522 Ok(vec![])
523 }
524
525 _ => {
526 warn!(
527 "Unsupported FIDO2 control request: type={:#04x}, request={:#04x}",
528 setup.request_type, setup.request
529 );
530 Err(std::io::Error::new(
531 std::io::ErrorKind::InvalidInput,
532 "Unsupported control request",
533 ))
534 }
535 }
536 } else if endpoint.address & 0x80 == 0 {
537 // Interrupt OUT endpoint (host to device)
538 debug!(
539 "FIDO2 OUT: ep={:#04x}, {} bytes",
540 endpoint.address,
541 data.len()
542 );
543
544 match self.ctaphid.process_packet(data) {
545 Ok(Some(message)) => {
546 let (cid, cmd) = (message.cid, message.cmd);
547 let payload = message.into_data();
548 // A second command while one is parked is CTAPHID's channel-busy case. It
549 // must not silently replace the parked one, or a decision meant for the
550 // first request would be applied to the second.
551 if self.parked.is_some() && cmd != CtapHidCommand::Cancel {
552 warn!(
553 "CTAPHID command {:?} on cid {:#010x} while a command is awaiting \
554 user presence; answering CHANNEL_BUSY",
555 cmd, cid
556 );
557 self.response_packets = vec![CtapHidPacket::build_error(
558 cid,
559 ctaphid::CtapHidError::ChannelBusy,
560 )];
561 } else {
562 self.response_packets =
563 self.run_command(cid, cmd, &payload, UserPresence::Ask);
564 }
565 }
566 Ok(None) => {
567 debug!("CTAPHID: waiting for continuation packets");
568 }
569 Err(e) => {
570 // Answer on the channel the host used, with the code the refusal names.
571 // Every error used to become INVALID_SEQ on the broadcast channel, so a
572 // host that had declared an unframeable BCNT, or opened one channel too
573 // many, was told its sequence number was wrong on a channel it was not
574 // listening to.
575 let rejection = ctaphid::rejection_of(&e);
576 warn!("CTAPHID packet error: {:#}", e);
577 self.response_packets =
578 vec![CtapHidPacket::build_error(rejection.cid, rejection.error)];
579 }
580 }
581
582 Ok(vec![])
583 } else {
584 // Interrupt IN endpoint (device to host)
585 if !self.response_packets.is_empty() {
586 let packet = self.response_packets.remove(0);
587 debug!(
588 "FIDO2 IN: ep={:#04x}, sending {} bytes",
589 endpoint.address,
590 packet.len()
591 );
592 Ok(packet)
593 } else if let Some(ref parked) = self.parked {
594 // Still waiting on the model. Say so rather than going silent.
595 Ok(CtapHidPacket::build_keepalive(
596 parked.cid,
597 KeepaliveStatus::UpNeeded,
598 ))
599 } else {
600 Ok(vec![])
601 }
602 }
603 }
604
605 fn get_class_specific_descriptor(&self) -> Vec<u8> {
606 // HID descriptor is returned via GET_DESCRIPTOR on the control endpoint.
607 vec![]
608 }
609
610 fn as_any(&mut self) -> &mut dyn std::any::Any {
611 self
612 }
613}
614
615/// Everything an action needs to reach a *running* FIDO2 server.
616///
617/// Registered with `AppState::register_server_handle` and looked up by `server_id` in
618/// `execute_action_with_state`. This replaces a `LazyLock` global map of approval managers whose
619/// lookup was "first value in the map" — with two FIDO2 servers running, actions aimed at one
620/// resolved approvals on the other, and the map was never cleaned up.
621/// The handler `usbip` holds for one attached host, as this module shares it.
622#[cfg(all(unix, feature = "libusb"))]
623type SharedHandler = Arc<Mutex<Box<dyn usbip::UsbInterfaceHandler + Send>>>;
624
625#[cfg(all(unix, feature = "libusb"))]
626pub struct Fido2ServerHandle {
627 pub approvals: Arc<ApprovalManager>,
628 handlers: Mutex<HashMap<ConnectionId, SharedHandler>>,
629}
630
631#[cfg(all(unix, feature = "libusb"))]
632impl Fido2ServerHandle {
633 fn new(approvals: Arc<ApprovalManager>) -> Self {
634 Self {
635 approvals,
636 handlers: Mutex::new(HashMap::new()),
637 }
638 }
639
640 fn set_handler(&self, id: ConnectionId, handler: SharedHandler) {
641 self.lock_handlers().insert(id, handler);
642 }
643
644 fn remove_handler(&self, id: ConnectionId) {
645 self.lock_handlers().remove(&id);
646 }
647
648 /// Run `f` against every attached device's handler, collecting the results.
649 ///
650 /// Credentials live per USB/IP session, so `list_credentials` and `delete_credential` are
651 /// answered across all attached hosts rather than picking one arbitrarily.
652 pub fn with_each_handler<T>(&self, mut f: impl FnMut(&mut Fido2HidHandler) -> T) -> Vec<T> {
653 let handlers: Vec<_> = self.lock_handlers().values().cloned().collect();
654 handlers
655 .iter()
656 .filter_map(|h| {
657 let mut guard = h.lock().unwrap_or_else(|p| p.into_inner());
658 guard.as_any().downcast_mut::<Fido2HidHandler>().map(&mut f)
659 })
660 .collect()
661 }
662
663 fn lock_handlers(&self) -> std::sync::MutexGuard<'_, HashMap<ConnectionId, SharedHandler>> {
664 self.handlers.lock().unwrap_or_else(|p| p.into_inner())
665 }
666}
667
668#[cfg(all(unix, feature = "libusb"))]
669impl UsbFido2Server {
670 /// Spawn the USB FIDO2 server with LLM integration.
671 ///
672 /// Binds before returning, so a port conflict is an `Err` and the server never reports
673 /// `Running` on a listener it does not have.
674 #[allow(clippy::too_many_arguments)]
675 pub async fn spawn_with_llm_actions(
676 listen_addr: SocketAddr,
677 llm_client: OllamaClient,
678 app_state: Arc<AppState>,
679 status_tx: mpsc::UnboundedSender<String>,
680 server_id: crate::state::ServerId,
681 support_u2f: Option<bool>,
682 support_fido2: Option<bool>,
683 auto_approve: Option<bool>,
684 approval_timeout_secs: Option<u64>,
685 ) -> Result<SocketAddr> {
686 let support_u2f = support_u2f.unwrap_or(true);
687 let support_fido2 = support_fido2.unwrap_or(true);
688 if !support_u2f && !support_fido2 {
689 anyhow::bail!(
690 "usb-fido2 needs at least one of support_u2f / support_fido2; with both false \
691 the device would answer nothing but CTAPHID INIT"
692 );
693 }
694
695 let listener =
696 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
697 let local_addr = listener.local_addr()?;
698
699 let auto_approve = auto_approve.unwrap_or(false);
700 let approvals = Arc::new(ApprovalManager::new(ApprovalConfig {
701 auto_approve,
702 timeout: std::time::Duration::from_secs(approval_timeout_secs.unwrap_or(30)),
703 timeout_decision: ApprovalDecision::Denied,
704 }));
705
706 let handle = Arc::new(Fido2ServerHandle::new(approvals.clone()));
707 app_state
708 .register_server_handle(server_id, handle.clone())
709 .await;
710
711 info!(
712 "USB FIDO2/U2F security key listening on {} (u2f={}, fido2={}, auto_approve={})",
713 local_addr, support_u2f, support_fido2, auto_approve
714 );
715 let _ = status_tx.send(format!(
716 "USB FIDO2/U2F security key listening on {} - run: sudo usbip attach -r {} -b 0-0-0",
717 local_addr,
718 local_addr.ip()
719 ));
720
721 let protocol = Arc::new(UsbFido2Protocol::new());
722 let task_registrar = app_state.clone();
723
724 // One emulated device per connection, so the cap is small and lives with the screen.
725 let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
726
727 let accept_handle = tokio::spawn(async move {
728 loop {
729 match accept_bounded(
730 &listener,
731 &limiter,
732 USBIP_NO_REFUSAL,
733 "USB FIDO2",
734 Some(&status_tx),
735 )
736 .await
737 {
738 Ok((stream, remote_addr, permit)) => {
739 let connection_id =
740 ConnectionId::new(app_state.get_next_unified_id().await);
741 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
742 info!(
743 "USB/IP connection {} from {} (FIDO2 security key)",
744 connection_id, remote_addr
745 );
746
747 use crate::state::server::{
748 ConnectionState as ServerConnectionState, ConnectionStatus,
749 ProtocolConnectionInfo,
750 };
751 let now = crate::utils::clock::Instant::now();
752 let conn_state = ServerConnectionState {
753 id: connection_id,
754 remote_addr,
755 local_addr: local_addr_conn,
756 bytes_sent: 0,
757 bytes_received: 0,
758 packets_sent: 0,
759 packets_received: 0,
760 last_activity: now,
761 status: ConnectionStatus::Active,
762 status_changed_at: now,
763 protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
764 "state": "WaitingForImport",
765 "supports_u2f": support_u2f,
766 "supports_fido2": support_fido2,
767 "auto_approve": auto_approve,
768 })),
769 };
770 app_state
771 .add_connection_to_server(server_id, conn_state)
772 .await;
773 let _ = status_tx.send("__UPDATE_UI__".to_string());
774
775 let llm_client = llm_client.clone();
776 let app_state = app_state.clone();
777 let status_tx = status_tx.clone();
778 let protocol = protocol.clone();
779 let handle = handle.clone();
780
781 // Tracked, not detached: stop_server must abort this task too.
782 let task_owner = app_state.clone();
783 task_owner
784 .spawn_server_task(server_id, async move {
785 // Held for the life of the connection: dropping it early
786 // releases the slot while the device is still exported.
787 let _permit = permit;
788 if let Err(e) = Self::handle_connection(
789 stream,
790 connection_id,
791 remote_addr,
792 llm_client,
793 app_state,
794 status_tx,
795 protocol,
796 handle,
797 server_id,
798 support_u2f,
799 support_fido2,
800 )
801 .await
802 {
803 error!("USB FIDO2 connection {} error: {}", connection_id, e);
804 }
805 })
806 .await;
807 }
808 Err(e) => {
809 // A persistent accept error recurs immediately, so continuing spins a
810 // hot loop on an unbounded status channel. Give up the listener.
811 error!("USB FIDO2 accept failed, stopping accept loop: {}", e);
812 break;
813 }
814 }
815 }
816 });
817
818 task_registrar
819 .register_server_task(server_id, accept_handle)
820 .await;
821
822 Ok(local_addr)
823 }
824
825 /// Drive one USB/IP session and the approval round trips that hang off it.
826 #[allow(clippy::too_many_arguments)]
827 async fn handle_connection(
828 stream: tokio::net::TcpStream,
829 connection_id: ConnectionId,
830 remote_addr: SocketAddr,
831 llm_client: OllamaClient,
832 app_state: Arc<AppState>,
833 status_tx: mpsc::UnboundedSender<String>,
834 protocol: Arc<UsbFido2Protocol>,
835 handle: Arc<Fido2ServerHandle>,
836 server_id: crate::state::ServerId,
837 support_u2f: bool,
838 support_fido2: bool,
839 ) -> Result<()> {
840 // The handler asks; this task answers. `handle_urb` is synchronous, so the channel is
841 // the seam — the same shape as `usb/msc`'s io_tx.
842 let (approval_tx, mut approval_rx) = mpsc::unbounded_channel::<ApprovalDetails>();
843
844 let hid_handler = Arc::new(Mutex::new(Box::new(
845 Fido2HidHandler::new(support_u2f, support_fido2).with_approvals(approval_tx),
846 )
847 as Box<dyn usbip::UsbInterfaceHandler + Send>));
848
849 handle.set_handler(connection_id, hid_handler.clone());
850
851 let device = usbip::UsbDevice::new(0).with_interface(
852 0x03, // HID class
853 0x00, // No subclass
854 0x00, // No protocol
855 Some("NetGet FIDO2 Security Key"),
856 vec![
857 usbip::UsbEndpoint {
858 address: 0x81, // EP1 IN (interrupt)
859 attributes: 0x03, // Interrupt transfer
860 max_packet_size: 64, // CTAPHID frame size
861 interval: 5,
862 },
863 usbip::UsbEndpoint {
864 address: 0x01, // EP1 OUT (interrupt)
865 attributes: 0x03, // Interrupt transfer
866 max_packet_size: 64,
867 interval: 5,
868 },
869 ],
870 hid_handler.clone(),
871 );
872
873 let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
874
875 // Run the USB/IP session on the socket netget already accepted. Calling
876 // `usbip::server(addr, ...)` here would try to *bind* a second listener and drop this
877 // socket, which is what the previous version did.
878 // Not `usbip::handler(&mut stream, ...)` directly: the crate allocates from the peer's
879 // `transfer_buffer_length` and `number_of_packets` without checking either, on a socket
880 // USB/IP never authenticates. `run_guarded_usbip` screens both before a byte reaches it
881 // -- see `src/server/usb/guard.rs`.
882 //
883 // The attach event hangs off `import_rx`, not off the accept: see the loop below.
884 let guard_status_tx = status_tx.clone();
885 let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
886 let mut usbip_task = tokio::spawn(async move {
887 match crate::server::usb::guard::run_guarded_usbip(
888 stream,
889 usbip_server,
890 "USB FIDO2",
891 connection_id.to_string(),
892 guard_status_tx,
893 Some(import_tx),
894 // The defaults, argued in `src/server/usb/guard.rs`. The other five USB servers
895 // expose them as `first_byte_timeout_secs` / `idle_timeout_secs`; FIDO2 has not
896 // declared those startup parameters, so it takes the shipped numbers. Its peer
897 // is the same USB/IP client, so the same argument applies to it unchanged.
898 crate::server::usb::guard::UsbIpDeadlines::default(),
899 )
900 .await
901 {
902 Ok(()) => debug!(
903 "USB/IP session ended for FIDO2 connection {}",
904 connection_id
905 ),
906 Err(e) => debug!(
907 "USB/IP session for FIDO2 connection {} ended with error: {}",
908 connection_id, e
909 ),
910 }
911 });
912
913 // The attach event is raised in the loop below rather than here. USB/IP authenticates
914 // nothing, so a bare TCP connect must cost nothing: `import_rx` fires only once the
915 // screen has admitted an `OP_REQ_IMPORT`, which is the first moment the peer has asked
916 // for the key. `import_pending` is the select! guard — a `oneshot::Receiver` must not be
917 // polled again once it has resolved — and `imported` is the record, because the detach
918 // event is the other half of an attachment and must not fire without one.
919 let mut import_pending = true;
920 let mut imported = false;
921 loop {
922 tokio::select! {
923 attached = &mut import_rx, if import_pending => {
924 import_pending = false;
925 if attached.is_ok() {
926 imported = true;
927 Self::raise(
928 &llm_client,
929 &app_state,
930 &protocol,
931 &status_tx,
932 server_id,
933 connection_id,
934 Event::new(
935 &FIDO2_DEVICE_ATTACHED_EVENT,
936 serde_json::json!({
937 "connection_id": connection_id.to_string(),
938 "remote_addr": remote_addr.to_string(),
939 "supports_u2f": support_u2f,
940 "supports_fido2": support_fido2,
941 }),
942 ),
943 "attach",
944 )
945 .await;
946 }
947 }
948 asked = approval_rx.recv() => {
949 let Some(details) = asked else { break };
950 Self::decide(
951 &llm_client,
952 &app_state,
953 &protocol,
954 &handle,
955 &hid_handler,
956 &status_tx,
957 server_id,
958 connection_id,
959 details,
960 )
961 .await;
962 }
963 _ = &mut usbip_task => break,
964 }
965 }
966
967 info!(
968 "USB FIDO2 host detached on connection {} from {}",
969 connection_id, remote_addr
970 );
971
972 // Only for a host that actually attached: a peer that opened a socket and closed it
973 // again detached nothing.
974 if imported {
975 Self::raise(
976 &llm_client,
977 &app_state,
978 &protocol,
979 &status_tx,
980 server_id,
981 connection_id,
982 Event::new(
983 &FIDO2_DEVICE_DETACHED_EVENT,
984 serde_json::json!({ "connection_id": connection_id.to_string() }),
985 ),
986 "detach",
987 )
988 .await;
989 }
990
991 handle.remove_handler(connection_id);
992 app_state
993 .close_connection_on_server(server_id, connection_id)
994 .await;
995 let _ = status_tx.send("__UPDATE_UI__".to_string());
996
997 Ok(())
998 }
999
1000 /// Ask the model about one request, then feed its decision back into the handler.
1001 #[allow(clippy::too_many_arguments)]
1002 async fn decide(
1003 llm_client: &OllamaClient,
1004 app_state: &Arc<AppState>,
1005 protocol: &Arc<UsbFido2Protocol>,
1006 handle: &Arc<Fido2ServerHandle>,
1007 hid_handler: &SharedHandler,
1008 status_tx: &mpsc::UnboundedSender<String>,
1009 server_id: crate::state::ServerId,
1010 connection_id: ConnectionId,
1011 details: ApprovalDetails,
1012 ) {
1013 // Open first, so the event can quote the id the model must answer with.
1014 let (approval_id, rx) = handle
1015 .approvals
1016 .open(details.clone(), Some(connection_id.to_string()));
1017
1018 let event_type = match details.operation {
1019 OperationType::Register => &FIDO2_REGISTER_REQUEST_EVENT,
1020 OperationType::Authenticate => &FIDO2_AUTHENTICATE_REQUEST_EVENT,
1021 };
1022
1023 let event = Event::new(
1024 event_type,
1025 serde_json::json!({
1026 "connection_id": connection_id.to_string(),
1027 "approval_id": approval_id,
1028 "rp_id": details.rp_id,
1029 "user_name": details.user_name,
1030 "credential_count": details.credential_count,
1031 }),
1032 );
1033
1034 let what = match details.operation {
1035 OperationType::Register => "register",
1036 OperationType::Authenticate => "authenticate",
1037 };
1038
1039 let answered = Self::raise(
1040 llm_client,
1041 app_state,
1042 protocol,
1043 status_tx,
1044 server_id,
1045 connection_id,
1046 event,
1047 what,
1048 )
1049 .await;
1050
1051 // The LLM call itself failed — an outage, an overload, an unparseable reply. Waiting
1052 // out `approval_timeout_secs` would get to the same denial, but only after the host has
1053 // sat on KEEPALIVE(UPNEEDED) for up to 30s for an answer that is never coming. Say it
1054 // now, in CTAP2's own vocabulary: the parked command is refused and the handler replies
1055 // `CTAP2_ERR_OPERATION_DENIED` (0x27), or U2F `SW_CONDITIONS_NOT_SATISFIED`.
1056 //
1057 // There is deliberately no other branch here. A failed call must never become an
1058 // approval: nothing below can synthesise an assertion or an attestation, and the
1059 // denial is the same one an explicit `deny_request` produces. `auto_approve` still
1060 // wins, because `wait()` short-circuits before reading the channel — that mode is an
1061 // explicit development opt-in that never consults the model in the first place.
1062 if !answered {
1063 match handle.approvals.deny(approval_id) {
1064 Ok(()) => console_error!(
1065 status_tx,
1066 "USB FIDO2 {} request {} on connection {} DENIED (fail closed): the model \
1067 could not be reached, so the host gets CTAP2_ERR_OPERATION_DENIED rather \
1068 than waiting out the approval window",
1069 what,
1070 approval_id,
1071 connection_id
1072 ),
1073 // Already resolved (the TUI, or a racing action). Its decision stands.
1074 Err(e) => debug!(
1075 "FIDO2 approval {} was already resolved when the LLM failure path ran: {}",
1076 approval_id, e
1077 ),
1078 }
1079 }
1080
1081 // If the model answered inline, the decision is already on the channel and this
1082 // returns immediately. Otherwise it waits out the configured window — a user can still
1083 // approve from the TUI — and denies when it expires.
1084 let decision = handle.approvals.wait(approval_id, rx).await;
1085
1086 // `decision=` tags, as `src/server/radius/` does. CTAP2 answers
1087 // CTAP2_ERR_OPERATION_DENIED whether the model refused, said nothing, or could not be
1088 // reached, so the wire cannot carry the distinction and the log must -- and on an
1089 // authenticator that distinction is the difference between a policy decision and an
1090 // outage that looked like one.
1091 let tag = match (decision, answered) {
1092 (ApprovalDecision::Approved, _) => "model_approve",
1093 (ApprovalDecision::Denied, false) => "fail_closed_llm_error",
1094 (ApprovalDecision::Denied, true) => "model_reject_or_silent",
1095 };
1096
1097 info!(
1098 "FIDO2 {} request {} on connection {} decision={} ({:?})",
1099 what, approval_id, connection_id, tag, decision
1100 );
1101
1102 let mut guard = hid_handler.lock().unwrap_or_else(|p| p.into_inner());
1103 if let Some(fido2) = guard.as_any().downcast_mut::<Fido2HidHandler>() {
1104 fido2.resolve_approval(decision);
1105 } else {
1106 error!(
1107 "FIDO2 handler for connection {} is not a Fido2HidHandler; the decision is lost",
1108 connection_id
1109 );
1110 }
1111 }
1112
1113 /// Raise one event with the LLM. Returns whether the call produced an answer at all.
1114 ///
1115 /// Unlike the MSC server there is no Idle/Processing gate: a parked command blocks the
1116 /// device at the CTAPHID layer (a second command gets CHANNEL_BUSY), so overlapping calls
1117 /// on one connection cannot arise.
1118 ///
1119 /// `false` means the *call* failed, which is not the same as the model declining: a
1120 /// decision the model made arrives as an action and is invisible here. Only `decide` acts
1121 /// on the return value, and only ever by denying.
1122 #[allow(clippy::too_many_arguments)]
1123 async fn raise(
1124 llm_client: &OllamaClient,
1125 app_state: &Arc<AppState>,
1126 protocol: &Arc<UsbFido2Protocol>,
1127 status_tx: &mpsc::UnboundedSender<String>,
1128 server_id: crate::state::ServerId,
1129 connection_id: ConnectionId,
1130 event: Event,
1131 what: &str,
1132 ) -> bool {
1133 match call_llm(
1134 llm_client,
1135 app_state,
1136 server_id,
1137 Some(connection_id),
1138 &event,
1139 protocol.as_ref(),
1140 )
1141 .await
1142 {
1143 // Event kind before the id, so a test can wait on one specific event with a
1144 // substring match.
1145 Ok(_) => {
1146 info!(
1147 "USB FIDO2 LLM call completed ({}) for connection {}",
1148 what, connection_id
1149 );
1150 true
1151 }
1152 Err(e) => {
1153 console_error!(
1154 status_tx,
1155 "LLM call failed for USB FIDO2 connection {} ({}): {}",
1156 connection_id,
1157 what,
1158 e
1159 );
1160 false
1161 }
1162 }
1163 }
1164}