Skip to main content

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}