Skip to main content

netget/server/usb/smartcard/
apdu.rs

1//! ISO 7816-4 command APDU parsing for the virtual smart card.
2//!
3//! The CCID layer (`ccid.rs`) delivers the contents of a `PC_to_RDR_XfrBlock` verbatim: at
4//! short-APDU level of exchange that payload *is* a command APDU. This module turns it into
5//! named fields so the event handed to the handler is structured rather than a byte blob,
6//! and turns the handler's answer back into response bytes.
7//!
8//! ## Command APDU layout
9//!
10//! ```text
11//! | CLA | INS | P1 | P2 | [Lc] | [Data] | [Le] |
12//! ```
13//!
14//! Both the short form (1-byte Lc/Le) and the extended form (`00` marker plus a 2-byte
15//! Lc/Le) are accepted.
16//!
17//! ## Hostile input
18//!
19//! These bytes come from whoever attached over USB/IP. Nothing here indexes without checking
20//! the length first, and nothing panics — a malformed APDU is an `Err` the caller answers
21//! with a status word.
22
23use anyhow::{bail, Result};
24
25/// ISO 7816-4 instruction bytes the card can name in an event.
26pub mod ins {
27    pub const SELECT: u8 = 0xA4;
28    pub const READ_BINARY: u8 = 0xB0;
29    pub const READ_RECORD: u8 = 0xB2;
30    pub const UPDATE_BINARY: u8 = 0xD6;
31    pub const UPDATE_RECORD: u8 = 0xDC;
32    pub const ERASE_BINARY: u8 = 0x0E;
33    pub const VERIFY: u8 = 0x20;
34    pub const CHANGE_REFERENCE_DATA: u8 = 0x24;
35    pub const RESET_RETRY_COUNTER: u8 = 0x2C;
36    pub const GET_RESPONSE: u8 = 0xC0;
37    pub const GET_DATA: u8 = 0xCA;
38    pub const PUT_DATA: u8 = 0xDA;
39    pub const ENVELOPE: u8 = 0xC2;
40    pub const INTERNAL_AUTHENTICATE: u8 = 0x88;
41    pub const EXTERNAL_AUTHENTICATE: u8 = 0x82;
42    pub const GET_CHALLENGE: u8 = 0x84;
43    pub const MANAGE_SECURITY_ENVIRONMENT: u8 = 0x22;
44    pub const PERFORM_SECURITY_OPERATION: u8 = 0x2A;
45    pub const GENERATE_ASYMMETRIC_KEY_PAIR: u8 = 0x46;
46}
47
48/// `P1` value of SELECT meaning "select by DF name", i.e. by application ID.
49pub const SELECT_P1_BY_AID: u8 = 0x04;
50
51/// Status word returned when the handler produced no usable response.
52///
53/// `6F00` is ISO 7816-4 "no precise diagnosis": a card-side failure. It is the deliberate
54/// fail-*closed* answer and is structurally distinct from a status word the model chose
55/// itself (`6982`, `6A82`, …), so an LLM outage can never be mistaken for approval.
56pub const SW_NO_PRECISE_DIAGNOSIS: (u8, u8) = (0x6F, 0x00);
57
58/// Status word for an APDU that could not be parsed at all.
59pub const SW_WRONG_LENGTH: (u8, u8) = (0x67, 0x00);
60
61/// A parsed ISO 7816-4 command APDU.
62#[derive(Debug, Clone)]
63pub struct ApduCommand {
64    pub cla: u8,
65    pub ins: u8,
66    pub p1: u8,
67    pub p2: u8,
68    /// Command data field (`Lc` bytes). Empty for case 1 and case 2 APDUs.
69    pub data: Vec<u8>,
70    /// Expected response length. `None` when the command carries no `Le`.
71    /// A wire value of `00` means the maximum (256 short / 65536 extended).
72    pub le: Option<u32>,
73}
74
75impl ApduCommand {
76    /// Parse a command APDU, rejecting every truncated or inconsistent form.
77    pub fn parse(bytes: &[u8]) -> Result<Self> {
78        if bytes.len() < 4 {
79            bail!("APDU too short: {} byte(s), need at least 4", bytes.len());
80        }
81
82        let cla = bytes[0];
83        let ins = bytes[1];
84        let p1 = bytes[2];
85        let p2 = bytes[3];
86        let body = &bytes[4..];
87
88        let (data, le) = match body.len() {
89            // Case 1: header only.
90            0 => (Vec::new(), None),
91            // Case 2S: a single Le byte.
92            1 => (Vec::new(), Some(expand_le_short(body[0]))),
93            _ if body[0] != 0 => {
94                // Short form with a data field.
95                let lc = body[0] as usize;
96                let data_end = 1 + lc;
97                if body.len() < data_end {
98                    bail!(
99                        "APDU truncated: Lc={} but only {} byte(s) follow",
100                        lc,
101                        body.len() - 1
102                    );
103                }
104                let data = body[1..data_end].to_vec();
105                match body.len() - data_end {
106                    0 => (data, None),
107                    1 => (data, Some(expand_le_short(body[data_end]))),
108                    extra => bail!("APDU has {} trailing byte(s) after Lc/Data/Le", extra),
109                }
110            }
111            // Extended form: leading 00 marker followed by a 2-byte length.
112            _ => {
113                if body.len() < 3 {
114                    bail!(
115                        "APDU truncated: extended-length marker needs 3 byte(s), got {}",
116                        body.len()
117                    );
118                }
119                let extended = u16::from_be_bytes([body[1], body[2]]);
120                if body.len() == 3 {
121                    // Case 2E: extended Le only.
122                    return Ok(Self {
123                        cla,
124                        ins,
125                        p1,
126                        p2,
127                        data: Vec::new(),
128                        le: Some(expand_le_extended(extended)),
129                    });
130                }
131                let lc = extended as usize;
132                if lc == 0 {
133                    bail!("APDU has an extended-length marker with Lc=0 and trailing bytes");
134                }
135                let data_end = 3 + lc;
136                if body.len() < data_end {
137                    bail!(
138                        "APDU truncated: extended Lc={} but only {} byte(s) follow",
139                        lc,
140                        body.len() - 3
141                    );
142                }
143                let data = body[3..data_end].to_vec();
144                match body.len() - data_end {
145                    0 => (data, None),
146                    2 => (
147                        data,
148                        Some(expand_le_extended(u16::from_be_bytes([
149                            body[data_end],
150                            body[data_end + 1],
151                        ]))),
152                    ),
153                    extra => bail!(
154                        "APDU has {} trailing byte(s) after extended Lc/Data/Le",
155                        extra
156                    ),
157                }
158            }
159        };
160
161        Ok(Self {
162            cla,
163            ins,
164            p1,
165            p2,
166            data,
167            le,
168        })
169    }
170
171    /// Human-readable instruction name, so the model is not asked to memorise instruction
172    /// bytes. SELECT by DF name is reported distinctly because that is how a reader picks an
173    /// application (PIV, OpenPGP, …) and a handler almost always wants to branch on it.
174    pub fn ins_name(&self) -> &'static str {
175        if self.is_select_by_aid() {
176            return "SELECT_BY_AID";
177        }
178        match self.ins {
179            ins::SELECT => "SELECT",
180            ins::READ_BINARY => "READ_BINARY",
181            ins::READ_RECORD => "READ_RECORD",
182            ins::UPDATE_BINARY => "UPDATE_BINARY",
183            ins::UPDATE_RECORD => "UPDATE_RECORD",
184            ins::ERASE_BINARY => "ERASE_BINARY",
185            ins::VERIFY => "VERIFY",
186            ins::CHANGE_REFERENCE_DATA => "CHANGE_REFERENCE_DATA",
187            ins::RESET_RETRY_COUNTER => "RESET_RETRY_COUNTER",
188            ins::GET_RESPONSE => "GET_RESPONSE",
189            ins::GET_DATA => "GET_DATA",
190            ins::PUT_DATA => "PUT_DATA",
191            ins::ENVELOPE => "ENVELOPE",
192            ins::INTERNAL_AUTHENTICATE => "INTERNAL_AUTHENTICATE",
193            ins::EXTERNAL_AUTHENTICATE => "EXTERNAL_AUTHENTICATE",
194            ins::GET_CHALLENGE => "GET_CHALLENGE",
195            ins::MANAGE_SECURITY_ENVIRONMENT => "MANAGE_SECURITY_ENVIRONMENT",
196            ins::PERFORM_SECURITY_OPERATION => "PERFORM_SECURITY_OPERATION",
197            ins::GENERATE_ASYMMETRIC_KEY_PAIR => "GENERATE_ASYMMETRIC_KEY_PAIR",
198            _ => "UNKNOWN",
199        }
200    }
201
202    /// True for `SELECT` by DF name (AID) — the command a reader uses to pick an application
203    /// on the card.
204    pub fn is_select_by_aid(&self) -> bool {
205        self.ins == ins::SELECT && self.p1 == SELECT_P1_BY_AID && !self.data.is_empty()
206    }
207}
208
209/// A short `Le` of `00` means 256 bytes, not zero.
210fn expand_le_short(value: u8) -> u32 {
211    if value == 0 {
212        256
213    } else {
214        value as u32
215    }
216}
217
218/// An extended `Le` of `0000` means 65536 bytes, not zero.
219fn expand_le_extended(value: u16) -> u32 {
220    if value == 0 {
221        65536
222    } else {
223        value as u32
224    }
225}
226
227/// A response APDU: optional data followed by the two status bytes.
228#[derive(Debug, Clone)]
229pub struct ApduResponse {
230    pub data: Vec<u8>,
231    pub sw1: u8,
232    pub sw2: u8,
233}
234
235impl ApduResponse {
236    pub fn new(data: Vec<u8>, sw1: u8, sw2: u8) -> Self {
237        Self { data, sw1, sw2 }
238    }
239
240    /// The fail-closed response used when no handler produced one.
241    pub fn card_error() -> Self {
242        Self::new(
243            Vec::new(),
244            SW_NO_PRECISE_DIAGNOSIS.0,
245            SW_NO_PRECISE_DIAGNOSIS.1,
246        )
247    }
248
249    /// The response to an APDU that could not be parsed.
250    pub fn wrong_length() -> Self {
251        Self::new(Vec::new(), SW_WRONG_LENGTH.0, SW_WRONG_LENGTH.1)
252    }
253
254    pub fn status_word(&self) -> String {
255        format!("{:02X}{:02X}", self.sw1, self.sw2)
256    }
257
258    pub fn into_bytes(self) -> Vec<u8> {
259        let mut bytes = self.data;
260        bytes.push(self.sw1);
261        bytes.push(self.sw2);
262        bytes
263    }
264}
265
266/// Render an APDU data field as text when every byte is printable ASCII, so the model gets
267/// something readable alongside the hex.
268pub fn printable_text(data: &[u8]) -> Option<String> {
269    if data.is_empty() {
270        return None;
271    }
272    if data
273        .iter()
274        .all(|&b| b.is_ascii_graphic() || b == b' ' || b == b'\t')
275    {
276        Some(String::from_utf8_lossy(data).to_string())
277    } else {
278        None
279    }
280}