Skip to main content

netget/server/usb/smartcard/
mod.rs

1//! USB Smart Card (CCID) server — a virtual chip card interface device over USB/IP.
2//!
3//! NetGet exports a **real USB CCID class device** (interface class `0x0B`). A Linux host that
4//! imports it and runs `pcscd` sees a card reader with a card in it; anything that speaks
5//! USB/IP over TCP can drive it with no kernel module.
6//!
7//! This replaced a `vpicc`/`vpcd` design that never ran: `Server::spawn` was
8//! `bail!("not yet implemented")`, so the protocol was `Incomplete` and unreachable. The
9//! `usbip` 0.9 upgrade (0.3 pinned tokio 0.3 and panicked "there is no reactor running" on
10//! every attach) made the USB/IP path viable, and CCID removes the external daemon entirely.
11//!
12//! ## Layers
13//!
14//! ```text
15//! USB/IP over TCP                       ← usbip::handler on the socket we accepted
16//!   └─ USB CCID class 0x0B              ← handler.rs (UsbInterfaceHandler)
17//!        └─ ISO 7816-4 command APDUs    ← apdu.rs
18//!             └─ the handler's answer   ← call_llm / script / static
19//! ```
20//!
21//! ## Who decides what
22//!
23//! Rust owns the CCID framing, the sequence numbers, the slot state machine and the APDU
24//! parsing. The **handler** owns everything the card says: the ATR (`set_atr`), whether a card
25//! is in the slot (`set_card_present`), and every response APDU (`respond_to_apdu`). There is
26//! no file system, no PIN store and no key store here — the previous implementation had all
27//! three, which is storage inside a protocol and the root CLAUDE.md forbids it.
28//!
29//! ## Fail closed
30//!
31//! If the handler errors, returns no `respond_to_apdu`, or returns one that cannot be decoded,
32//! the card answers **`6F00`** (ISO 7816-4 "no precise diagnosis") and logs at ERROR. It never
33//! falls through to `9000`, so the model's own refusal (`6982`, `6A82`, …) stays structurally
34//! distinguishable from the model having said nothing.
35//!
36//! ## No connection state machine, deliberately
37//!
38//! `XfrBlock` payloads arrive on one channel and are answered one at a time before the next is
39//! taken, so a connection can never have two handler calls in flight and there is nothing to
40//! reassemble — the CCID header carries its own length.
41
42pub mod actions;
43pub mod apdu;
44pub mod ccid;
45pub mod handler;
46
47pub use actions::UsbSmartCardProtocol;
48
49use std::net::SocketAddr;
50use std::sync::{Arc, Mutex as StdMutex};
51
52use anyhow::{anyhow, Result};
53use serde_json::{json, Value};
54use tokio::sync::mpsc;
55use tracing::{debug, info, warn};
56
57use crate::llm::action_helper::call_llm;
58use crate::llm::ollama_client::OllamaClient;
59use crate::llm::ActionResult;
60use crate::logging::emit::Log;
61use crate::protocol::Event;
62use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
63use crate::server::connection::ConnectionId;
64use crate::server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL};
65use crate::state::app_state::AppState;
66use crate::state::ServerId;
67use crate::{console_debug, console_error};
68
69use actions::{
70    USB_SMARTCARD_APDU_RECEIVED_EVENT, USB_SMARTCARD_ATTACHED_EVENT, USB_SMARTCARD_DETACHED_EVENT,
71    USB_SMARTCARD_READER_READY_EVENT,
72};
73use apdu::{ApduCommand, ApduResponse};
74use handler::{CardState, PendingApdu, UsbCcidHandler};
75
76/// Answer To Reset used until the handler calls `set_atr`.
77///
78/// `3B 90 11 00` is a minimal, valid, T=0-only ATR: TS direct convention, T0 announcing TA1
79/// and TD1 with no historical bytes, TA1 = 372/1, TD1 = 0 (protocol T=0, no further interface
80/// bytes). T=0-only ATRs carry no TCK, so there is no checksum to get wrong.
81const DEFAULT_ATR: &[u8] = &[0x3B, 0x90, 0x11, 0x00];
82
83/// Boxed interface handler as `usbip` wants it.
84type SharedUsbHandler = Arc<StdMutex<Box<dyn usbip::UsbInterfaceHandler + Send>>>;
85
86pub struct UsbSmartCardServer;
87
88impl UsbSmartCardServer {
89    /// Bind the USB/IP listener and start exporting the virtual reader.
90    #[allow(clippy::too_many_arguments)]
91    pub async fn spawn_with_llm_actions(
92        listen_addr: SocketAddr,
93        llm_client: OllamaClient,
94        app_state: Arc<AppState>,
95        status_tx: mpsc::UnboundedSender<String>,
96        server_id: ServerId,
97        card_type: Option<String>,
98        first_byte_timeout_secs: Option<u64>,
99        idle_timeout_secs: Option<u64>,
100    ) -> Result<SocketAddr> {
101        let card_type = card_type.unwrap_or_else(|| "generic".to_string());
102
103        // Bind before anything else so a port conflict is reported as a startup failure
104        // rather than a server that claims to be Running with no socket.
105        let listener =
106            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
107        let local_addr = listener.local_addr()?;
108
109        Log::new(Some(&status_tx)).info(format!(
110            "USB smart card reader listening on {local_addr} (card_type={card_type}) - \
111             attach with: sudo usbip attach -r {} -b 0-0-0",
112            local_addr.ip()
113        ));
114
115        let card = Arc::new(StdMutex::new(CardState {
116            atr: DEFAULT_ATR.to_vec(),
117            card_present: true,
118        }));
119        let protocol = Arc::new(UsbSmartCardProtocol::new());
120
121        // Configure the card before the first host can reach it.
122        let ready_event = Event::new(
123            &USB_SMARTCARD_READER_READY_EVENT,
124            json!({
125                "listen_addr": local_addr.to_string(),
126                "card_type": card_type,
127                "atr_hex": hex::encode_upper(DEFAULT_ATR),
128            }),
129        );
130        // Opening a socket must not require the model to be reachable: the LLM answers
131        // traffic, it does not open listeners. This call used to propagate with `?`, so an
132        // Ollama outage made `spawn()` return `Err` and the reader never started at all.
133        //
134        // The reader is fully usable without it — `card` already holds `DEFAULT_ATR` and
135        // `card_present: true`, and every APDU from the host still goes through `call_llm`
136        // with its own failure handling — so a failure here means "unconfigured, not broken".
137        match call_llm(
138            &llm_client,
139            &app_state,
140            server_id,
141            None, // Server-level event, no connection yet
142            &ready_event,
143            protocol.as_ref(),
144        )
145        .await
146        {
147            Ok(execution) => {
148                for message in execution.messages {
149                    let _ = status_tx.send(message);
150                }
151                Self::apply_card_config(execution.protocol_results, &card, None, &status_tx);
152            }
153            Err(e) => {
154                Log::new(Some(&status_tx)).error(format!(
155                    "USB smart card startup configuration failed ({}); the reader is listening \
156                     on {} with the default ATR {} and a card inserted",
157                    e,
158                    local_addr,
159                    hex::encode_upper(DEFAULT_ATR)
160                ));
161            }
162        }
163
164        {
165            let snapshot = lock_card(&card);
166            debug!(
167                "Virtual smart card configured: atr={}, card_present={}",
168                hex::encode_upper(&snapshot.atr),
169                snapshot.card_present
170            );
171        }
172
173        // The two read deadlines, argued in `src/server/usb/guard.rs` and overridable per
174        // server. Copied into each connection task; the screen is the only thing that reads
175        // from the socket, so it is the only place they can be applied.
176        let deadlines = crate::server::usb::guard::UsbIpDeadlines::from_secs(
177            first_byte_timeout_secs,
178            idle_timeout_secs,
179        );
180        let task_registrar = app_state.clone();
181        // One emulated device per connection, so the cap is small and lives with the screen.
182        let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
183        let accept_handle = tokio::spawn(async move {
184            loop {
185                let (stream, remote_addr, permit) = match accept_bounded(
186                    &listener,
187                    &limiter,
188                    USBIP_NO_REFUSAL,
189                    "USB smart card",
190                    Some(&status_tx),
191                )
192                .await
193                {
194                    Ok(accepted) => accepted,
195                    Err(e) => {
196                        // A persistent accept error (EMFILE, socket torn down) recurs
197                        // immediately, so continuing spins a hot loop on an unbounded status
198                        // channel. Give up the listener instead.
199                        Log::new(Some(&status_tx)).error(format!(
200                            "USB smart card accept failed, stopping accept loop: {}",
201                            e
202                        ));
203                        break;
204                    }
205                };
206
207                let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
208                info!(
209                    "USB/IP connection {} from {} (USB smart card)",
210                    connection_id, remote_addr
211                );
212
213                {
214                    use crate::state::server::{
215                        ConnectionState, ConnectionStatus, ProtocolConnectionInfo,
216                    };
217                    let now = crate::utils::clock::Instant::now();
218                    let conn_state = ConnectionState {
219                        id: connection_id,
220                        remote_addr,
221                        local_addr: stream.local_addr().unwrap_or(local_addr),
222                        bytes_sent: 0,
223                        bytes_received: 0,
224                        packets_sent: 0,
225                        packets_received: 0,
226                        last_activity: now,
227                        status: ConnectionStatus::Active,
228                        status_changed_at: now,
229                        protocol_info: ProtocolConnectionInfo::new(json!({
230                            "transport": "usbip",
231                            "usb_class": "ccid",
232                            "card_type": card_type,
233                        })),
234                    };
235                    app_state
236                        .add_connection_to_server(server_id, conn_state)
237                        .await;
238                }
239                let _ = status_tx.send("__UPDATE_UI__".to_string());
240
241                let llm_client = llm_client.clone();
242                let conn_app_state = app_state.clone();
243                let status_tx = status_tx.clone();
244                let card = card.clone();
245                let protocol = protocol.clone();
246                let card_type = card_type.clone();
247                // Tracked, not detached: stop_server must abort this task too.
248                let task_owner = app_state.clone();
249                task_owner
250                    .spawn_server_task(server_id, async move {
251                        // Held for the life of the connection: dropping it early releases the
252                        // slot while the device is still exported.
253                        let _permit = permit;
254                        if let Err(e) = Self::handle_connection(
255                            stream,
256                            connection_id,
257                            server_id,
258                            card,
259                            card_type,
260                            llm_client,
261                            conn_app_state.clone(),
262                            status_tx.clone(),
263                            protocol,
264                            deadlines,
265                        )
266                        .await
267                        {
268                            console_error!(
269                                status_tx,
270                                "USB smart card connection {} error: {}",
271                                connection_id,
272                                e
273                            );
274                        }
275                        conn_app_state
276                            .close_connection_on_server(server_id, connection_id)
277                            .await;
278                        let _ = status_tx.send("__UPDATE_UI__".to_string());
279                    })
280                    .await;
281            }
282        });
283
284        task_registrar
285            .register_server_task(server_id, accept_handle)
286            .await;
287
288        Ok(local_addr)
289    }
290
291    /// Drive one USB/IP session and the CCID exchange that hangs off it.
292    #[allow(clippy::too_many_arguments)]
293    async fn handle_connection(
294        stream: tokio::net::TcpStream,
295        connection_id: ConnectionId,
296        server_id: ServerId,
297        card: Arc<StdMutex<CardState>>,
298        card_type: String,
299        llm_client: OllamaClient,
300        app_state: Arc<AppState>,
301        status_tx: mpsc::UnboundedSender<String>,
302        protocol: Arc<UsbSmartCardProtocol>,
303        deadlines: crate::server::usb::guard::UsbIpDeadlines,
304    ) -> Result<()> {
305        // `handle_urb` is synchronous and cannot await an LLM call, so XfrBlock payloads cross
306        // to this task on a channel and the answer is queued back on the handler.
307        let (apdu_tx, mut apdu_rx) = mpsc::unbounded_channel::<PendingApdu>();
308
309        let ccid_handler: SharedUsbHandler = Arc::new(StdMutex::new(Box::new(UsbCcidHandler::new(
310            card.clone(),
311            apdu_tx,
312        ))
313            as Box<dyn usbip::UsbInterfaceHandler + Send>));
314
315        let device = usbip::UsbDevice::new(0).with_interface(
316            usbip::ClassCode::SmartCard as u8,
317            0x00, // bInterfaceSubClass: no subclass
318            0x00, // bInterfaceProtocol: bulk transfer, CCID rev 1.1
319            Some("NetGet Virtual CCID Reader"),
320            UsbCcidHandler::endpoints(),
321            ccid_handler.clone(),
322        );
323
324        let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
325
326        // Not `usbip::handler(&mut stream, ...)` directly: the crate allocates from the peer's
327        // `transfer_buffer_length` and `number_of_packets` without checking either, on a socket
328        // USB/IP never authenticates. `run_guarded_usbip` screens both before a byte reaches it
329        // -- see `src/server/usb/guard.rs`.
330        //
331        // The attach event hangs off `import_rx`, not off the accept: see the loop below.
332        let guard_status_tx = status_tx.clone();
333        let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
334        let mut usbip_task = tokio::spawn(async move {
335            match crate::server::usb::guard::run_guarded_usbip(
336                stream,
337                usbip_server,
338                "USB smart card",
339                connection_id.to_string(),
340                guard_status_tx,
341                Some(import_tx),
342                deadlines,
343            )
344            .await
345            {
346                Ok(()) => debug!(
347                    "USB/IP session ended for smart card connection {}",
348                    connection_id
349                ),
350                Err(e) => debug!(
351                    "USB/IP session for smart card connection {} ended with error: {}",
352                    connection_id, e
353                ),
354            }
355        });
356
357        // Serve APDUs until the USB/IP session ends. Each is answered before the next is
358        // taken, so there is never more than one handler call in flight per connection.
359        //
360        // The attach event is raised here rather than on the accept. USB/IP authenticates
361        // nothing, so a bare TCP connect must cost nothing: `import_rx` fires only once the
362        // screen has admitted an `OP_REQ_IMPORT`, which is the first moment the peer has asked
363        // for the reader. `import_pending` is the select! guard — a `oneshot::Receiver` must not
364        // be polled again once it has resolved — and `imported` is the record, because the
365        // detach event is the other half of an attachment and must not fire without one.
366        let mut import_pending = true;
367        let mut imported = false;
368        loop {
369            tokio::select! {
370                attached = &mut import_rx, if import_pending => {
371                    import_pending = false;
372                    if attached.is_ok() {
373                        imported = true;
374                        // Tell the handler a host is here, and let it reconfigure the card for
375                        // this session.
376                        let (atr_hex, card_present) = {
377                            let snapshot = lock_card(&card);
378                            (hex::encode_upper(&snapshot.atr), snapshot.card_present)
379                        };
380                        let attached_event = Event::new(
381                            &USB_SMARTCARD_ATTACHED_EVENT,
382                            json!({
383                                "connection_id": connection_id.to_string(),
384                                "card_type": card_type,
385                                "card_present": card_present,
386                                "atr_hex": atr_hex,
387                            }),
388                        );
389                        match call_llm(
390                            &llm_client,
391                            &app_state,
392                            server_id,
393                            Some(connection_id),
394                            &attached_event,
395                            protocol.as_ref(),
396                        )
397                        .await
398                        {
399                            Ok(execution) => {
400                                for message in execution.messages {
401                                    let _ = status_tx.send(message);
402                                }
403                                Self::apply_card_config(
404                                    execution.protocol_results,
405                                    &card,
406                                    Some(&ccid_handler),
407                                    &status_tx,
408                                );
409                                info!(
410                                    "USB smart card LLM call completed for connection {} (attach)",
411                                    connection_id
412                                );
413                            }
414                            Err(e) => {
415                                console_error!(
416                                    status_tx,
417                                    "USB smart card attach handler failed for {}: {}",
418                                    connection_id,
419                                    e
420                                );
421                            }
422                        }
423                    }
424                }
425                pending = apdu_rx.recv() => {
426                    let Some(pending) = pending else { break };
427                    let response = Self::answer_apdu(
428                        &pending,
429                        connection_id,
430                        server_id,
431                        &card_type,
432                        &llm_client,
433                        &app_state,
434                        &status_tx,
435                        protocol.as_ref(),
436                    )
437                    .await;
438
439                    let status_word = response.status_word();
440                    let bytes = response.into_bytes();
441                    let _ = status_tx.send(format!(
442                        "→ USB smart card response SW={} ({} data byte(s)) to {}",
443                        status_word,
444                        bytes.len().saturating_sub(2),
445                        connection_id
446                    ));
447                    if with_ccid_handler(&ccid_handler, |h| {
448                        h.queue_apdu_response(pending.slot, pending.seq, &bytes)
449                    })
450                    .is_none()
451                    {
452                        console_error!(
453                            status_tx,
454                            "USB smart card could not reach the CCID handler for {}; the host \
455                             will not get an answer to seq {}",
456                            connection_id,
457                            pending.seq
458                        );
459                    }
460                }
461                _ = &mut usbip_task => break,
462            }
463        }
464
465        info!(
466            "USB smart card host detached on connection {}",
467            connection_id
468        );
469
470        // Only for a host that actually attached: a peer that opened a socket and closed it
471        // again detached nothing.
472        if imported {
473            let detached_event = Event::new(
474                &USB_SMARTCARD_DETACHED_EVENT,
475                json!({ "connection_id": connection_id.to_string() }),
476            );
477            match call_llm(
478                &llm_client,
479                &app_state,
480                server_id,
481                Some(connection_id),
482                &detached_event,
483                protocol.as_ref(),
484            )
485            .await
486            {
487                Ok(execution) => {
488                    for message in execution.messages {
489                        let _ = status_tx.send(message);
490                    }
491                    info!(
492                        "USB smart card LLM call completed for connection {} (detach)",
493                        connection_id
494                    );
495                }
496                Err(e) => {
497                    console_error!(
498                        status_tx,
499                        "USB smart card detach handler failed for {}: {}",
500                        connection_id,
501                        e
502                    );
503                }
504            }
505        }
506
507        Ok(())
508    }
509
510    /// Raise `usb_smartcard_apdu_received` and turn the handler's answer into a response APDU.
511    ///
512    /// Fails **closed**: a malformed command APDU is answered `6700` without reaching the
513    /// handler, and a handler that errors, says nothing, or returns something undecodable gets
514    /// `6F00`. Never `9000`.
515    #[allow(clippy::too_many_arguments)]
516    async fn answer_apdu(
517        pending: &PendingApdu,
518        connection_id: ConnectionId,
519        server_id: ServerId,
520        card_type: &str,
521        llm_client: &OllamaClient,
522        app_state: &Arc<AppState>,
523        status_tx: &mpsc::UnboundedSender<String>,
524        protocol: &UsbSmartCardProtocol,
525    ) -> ApduResponse {
526        let command = match ApduCommand::parse(&pending.apdu) {
527            Ok(command) => command,
528            Err(e) => {
529                console_error!(
530                    status_tx,
531                    "USB smart card host {} sent a malformed APDU: {}",
532                    connection_id,
533                    e
534                );
535                return ApduResponse::wrong_length();
536            }
537        };
538
539        let mut event_data = json!({
540            "connection_id": connection_id.to_string(),
541            "card_type": card_type,
542            "ins_name": command.ins_name(),
543            "cla": format!("{:02X}", command.cla),
544            "ins": format!("{:02X}", command.ins),
545            "p1": format!("{:02X}", command.p1),
546            "p2": format!("{:02X}", command.p2),
547            "lc": command.data.len(),
548            "data_hex": hex::encode_upper(&command.data),
549            "le": command.le,
550        });
551        if let Some(map) = event_data.as_object_mut() {
552            if let Some(text) = apdu::printable_text(&command.data) {
553                map.insert("data_text".into(), json!(text));
554            }
555            if command.is_select_by_aid() {
556                map.insert(
557                    "application_id".into(),
558                    json!(hex::encode_upper(&command.data)),
559                );
560            }
561        }
562
563        let event = Event::new(&USB_SMARTCARD_APDU_RECEIVED_EVENT, event_data);
564        console_debug!(
565            status_tx,
566            "USB smart card {} on {} (Lc={}, Le={:?})",
567            command.ins_name(),
568            connection_id,
569            command.data.len(),
570            command.le
571        );
572
573        let execution = match call_llm(
574            llm_client,
575            app_state,
576            server_id,
577            Some(connection_id),
578            &event,
579            protocol,
580        )
581        .await
582        {
583            Ok(execution) => execution,
584            Err(e) => {
585                // `decision=` tags, as `src/server/radius/` does. 6F00 is what the card
586                // answers whether the model refused, said nothing, or could not be reached,
587                // so the wire cannot carry the distinction and the log must.
588                console_error!(
589                    status_tx,
590                    "USB smart card handler failed for {} on {} \
591                     decision=fail_closed_llm_error, answering 6F00: {}",
592                    event.id(),
593                    connection_id,
594                    e
595                );
596                return ApduResponse::card_error();
597            }
598        };
599
600        for message in execution.messages {
601            let _ = status_tx.send(message);
602        }
603
604        let mut response = None;
605        let mut results = Vec::new();
606        flatten_results(execution.protocol_results, &mut results);
607        for result in results {
608            let ActionResult::Custom { name, data } = result else {
609                continue;
610            };
611            if name != "respond_to_apdu" {
612                continue;
613            }
614            match decode_apdu_response(&data) {
615                Ok(decoded) => {
616                    if response.is_none() {
617                        response = Some(decoded);
618                    } else {
619                        console_error!(
620                            status_tx,
621                            "USB smart card handler returned more than one respond_to_apdu for \
622                             {}; ignoring the extra one",
623                            connection_id
624                        );
625                    }
626                }
627                Err(e) => {
628                    console_error!(
629                        status_tx,
630                        "USB smart card respond_to_apdu could not be decoded on {} \
631                         decision=fail_closed_undecodable, falling through to 6F00: {}",
632                        connection_id,
633                        e
634                    );
635                }
636            }
637        }
638
639        match response {
640            Some(response) => response,
641            None => {
642                console_error!(
643                    status_tx,
644                    "USB smart card handler produced no respond_to_apdu for {} on {} \
645                     decision=model_silent, answering 6F00",
646                    event.id(),
647                    connection_id
648                );
649                ApduResponse::card_error()
650            }
651        }
652    }
653
654    /// Apply `set_atr` / `set_card_present` results to the shared card state, and tell a live
655    /// CCID handler about a slot change so the host is notified.
656    fn apply_card_config(
657        results: Vec<ActionResult>,
658        card: &Arc<StdMutex<CardState>>,
659        ccid_handler: Option<&SharedUsbHandler>,
660        status_tx: &mpsc::UnboundedSender<String>,
661    ) {
662        let mut flattened = Vec::new();
663        flatten_results(results, &mut flattened);
664        for result in flattened {
665            let ActionResult::Custom { name, data } = result else {
666                continue;
667            };
668            match name.as_str() {
669                "set_atr" => match decode_atr(&data) {
670                    Ok(atr) => {
671                        let atr_hex = hex::encode_upper(&atr);
672                        lock_card(card).atr = atr;
673                        Log::new(Some(status_tx))
674                            .info(format!("USB smart card ATR set: {atr_hex}"));
675                    }
676                    Err(e) => {
677                        console_error!(status_tx, "USB smart card set_atr rejected: {}", e);
678                    }
679                },
680                "set_card_present" => match data.get("present").and_then(|v| v.as_bool()) {
681                    Some(present) => {
682                        lock_card(card).card_present = present;
683                        if let Some(handler) = ccid_handler {
684                            with_ccid_handler(handler, |h| h.note_slot_change());
685                        }
686                        Log::new(Some(status_tx)).info(format!(
687                            "USB smart card slot: card {}",
688                            if present { "inserted" } else { "removed" }
689                        ));
690                    }
691                    None => {
692                        console_error!(
693                            status_tx,
694                            "USB smart card set_card_present had no boolean 'present'"
695                        );
696                    }
697                },
698                "respond_to_apdu" => {
699                    console_error!(
700                        status_tx,
701                        "USB smart card respond_to_apdu is only valid in response to \
702                         usb_smartcard_apdu_received; ignoring it"
703                    );
704                }
705                other => {
706                    warn!(
707                        "USB smart card ignoring unhandled action result '{}'",
708                        other
709                    );
710                }
711            }
712        }
713    }
714}
715
716/// Lock the shared card state, recovering rather than propagating a poisoned mutex: a poisoned
717/// lock here would otherwise take the reader down for the rest of the process.
718fn lock_card(card: &Arc<StdMutex<CardState>>) -> std::sync::MutexGuard<'_, CardState> {
719    match card.lock() {
720        Ok(guard) => guard,
721        Err(poisoned) => poisoned.into_inner(),
722    }
723}
724
725/// Run `f` against the live CCID handler, or return `None` if it cannot be reached.
726fn with_ccid_handler<R>(
727    handler: &SharedUsbHandler,
728    f: impl FnOnce(&mut UsbCcidHandler) -> R,
729) -> Option<R> {
730    let mut guard = match handler.lock() {
731        Ok(guard) => guard,
732        Err(poisoned) => poisoned.into_inner(),
733    };
734    let ccid = guard.as_any().downcast_mut::<UsbCcidHandler>()?;
735    Some(f(ccid))
736}
737
738/// `ExecutionResult::protocol_results` can nest via `ActionResult::Multiple`; flatten so a
739/// nested action is not silently dropped.
740fn flatten_results(results: Vec<ActionResult>, out: &mut Vec<ActionResult>) {
741    for result in results {
742        match result {
743            ActionResult::Multiple(nested) => flatten_results(nested, out),
744            other => out.push(other),
745        }
746    }
747}
748
749/// Decode the normalised `set_atr` payload produced by `execute_action`.
750fn decode_atr(data: &Value) -> Result<Vec<u8>> {
751    let atr_hex = data
752        .get("atr_hex")
753        .and_then(|v| v.as_str())
754        .ok_or_else(|| anyhow!("Missing atr_hex"))?;
755    let atr = actions::parse_hex("atr_hex", atr_hex)?;
756    if atr.is_empty() {
757        return Err(anyhow!("ATR must not be empty"));
758    }
759    if atr.len() > ccid::MAX_PAYLOAD_LEN {
760        return Err(anyhow!(
761            "ATR is {} bytes; a CCID data block carries at most {}",
762            atr.len(),
763            ccid::MAX_PAYLOAD_LEN
764        ));
765    }
766    Ok(atr)
767}
768
769/// Decode the normalised `respond_to_apdu` payload produced by `execute_action`.
770fn decode_apdu_response(data: &Value) -> Result<ApduResponse> {
771    let data_hex = data
772        .get("data_hex")
773        .and_then(|v| v.as_str())
774        .unwrap_or_default();
775    let body = if data_hex.is_empty() {
776        Vec::new()
777    } else {
778        actions::parse_hex("data_hex", data_hex)?
779    };
780
781    // Response body plus SW1/SW2 has to fit in one CCID data block.
782    if body.len() + 2 > ccid::MAX_PAYLOAD_LEN {
783        return Err(anyhow!(
784            "Response body of {} bytes does not fit in a CCID data block (max {} including SW)",
785            body.len(),
786            ccid::MAX_PAYLOAD_LEN
787        ));
788    }
789
790    let sw1 = decode_status_byte(data, "sw1")?;
791    let sw2 = decode_status_byte(data, "sw2")?;
792    Ok(ApduResponse::new(body, sw1, sw2))
793}
794
795/// Decode one status byte of a normalised `respond_to_apdu` payload.
796///
797/// **There is deliberately no default.** `execute_action` already refuses an action that names
798/// no status word; this is the second layer, so nothing between the model's answer and the wire
799/// can invent one and 9000 cannot be produced except by a handler that spelled it out. A
800/// missing field here is an error, which the caller logs as `decision=fail_closed_undecodable`
801/// and answers `6F00`.
802fn decode_status_byte(data: &Value, field: &str) -> Result<u8> {
803    let Some(value) = data.get(field).and_then(|v| v.as_str()) else {
804        return Err(anyhow!(
805            "'{field}' is missing from the respond_to_apdu payload; a status word is never \
806             defaulted, because the default would be success"
807        ));
808    };
809    match actions::parse_hex(field, value)?.as_slice() {
810        [byte] => Ok(*byte),
811        other => Err(anyhow!(
812            "'{field}' must be exactly one hex byte, got {} byte(s)",
813            other.len()
814        )),
815    }
816}