Skip to main content

hsm_host/
device.rs

1// Copyright (c) 2026 Tuloup Simon
2//
3// This program is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// any later version.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! HID device abstraction.
17//!
18//! Wraps `hidapi` to find the mini-HSM dongle by VID/PID and exchange
19//! single 128-byte HID reports. The protocol is request/response. 
20//! The host sends one report and reads exactly one back.
21
22use anyhow::{anyhow, bail, Context, Result};
23use hidapi::{HidApi, HidDevice};
24
25use atecc608b::{AteccErrorKind, ChipError};
26use hsm_usb_protocol::{Frame, HID_REPORT_SIZE, USB_PID, USB_VID};
27use hsm_usb_protocol::responses::ResponseStatus;
28
29/// Open the first HID device matching the mini-HSM VID/PID.
30pub(crate) fn open() -> Result<HidDevice>
31{
32    let api = HidApi::new().context("failed to initialise hidapi")?;
33    let device = api
34        .open(USB_VID, USB_PID)
35        .with_context(|| format!
36        (
37            "failed to open HID device {:04X}:{:04X}. Is the dongle plugged in?",
38            USB_VID, USB_PID
39        ))?;
40    Ok(device)
41}
42
43/// Print one line per HID device on the bus, marking matches.
44pub(crate) fn enumerate() -> Result<()>
45{
46    let api = HidApi::new().context("failed to initialise hidapi")?;
47    let mut matches = 0;
48    for info in api.device_list()
49    {
50        let is_mini_hsm = info.vendor_id() == USB_VID && info.product_id() == USB_PID;
51        if is_mini_hsm
52        {
53            matches += 1;
54        }
55        let marker = if is_mini_hsm { "<-- mini-HSM" } else { "" };
56        println!
57        (
58            "{:04x}:{:04x} {} / {} {}",
59            info.vendor_id(),
60            info.product_id(),
61            info.manufacturer_string().unwrap_or("(no manufacturer)"),
62            info.product_string().unwrap_or("(no product)"),
63            marker,
64        );
65    }
66    println!();
67    println!("{matches} mini-HSM device(s) found.");
68    Ok(())
69}
70
71/// Send a command and return the response payload 
72/// (without the 3-byte frame header).
73///
74/// Returns `Err` if the chip responded with a non-`Ok` status. The error
75/// message includes the status code and any payload bytes the firmware
76/// included alongside (e.g. tries_remaining on WrongPin).
77pub(crate) fn send_command
78(
79    device: &HidDevice,
80    opcode: u8,
81    payload: &[u8],
82) -> Result<Vec<u8>>
83{
84    let request = Frame::to_report(opcode, payload).map_err
85    (|e| 
86        anyhow!("failed to encode request frame: {:?}", e)
87    )?;
88
89    // hidapi requires a leading report-ID byte of 0 on platforms that do
90    // not use numbered reports.
91    let mut wire = [0u8; HID_REPORT_SIZE + 1];
92    wire[0] = 0;
93    wire[1..].copy_from_slice(&request);
94    device.write(&wire).context("HID write failed")?;
95
96    let mut response = [0u8; HID_REPORT_SIZE];
97    let n = device
98        .read_timeout(&mut response, 60_000)
99        .context("HID read failed")?;
100    if n != HID_REPORT_SIZE
101    {
102        bail!("short HID read: got {n} bytes, expected {HID_REPORT_SIZE}");
103    }
104
105    let frame = Frame::parse(&response)
106        .map_err(|e| anyhow!("malformed response frame: {:?}", e))?;
107
108    if frame.opcode != ResponseStatus::Ok.as_u8()
109    {
110        let status_name = describe_status(frame.opcode);
111        
112        if frame.opcode == ResponseStatus::WrongPin.as_u8() && frame.payload.len() == 1
113        {
114            bail!(
115                "chip returned WrongPin: {} attempt(s) remaining before block",
116                frame.payload[0],
117            );
118        }
119        if frame.opcode == ResponseStatus::WrongPuk.as_u8() && frame.payload.len() == 1
120        {
121            bail!(
122                "chip returned WrongPuk: {} attempt(s) remaining before brick",
123                frame.payload[0],
124            );
125        }
126        if frame.opcode == ResponseStatus::EmergencyResetNotPermitted.as_u8()
127            && frame.payload.len() == 2
128        {
129            bail!(
130                "chip refused EmergencyReset: {} PIN attempt(s) and {} PUK attempt(s) \
131                 still remain. Use `verify-pin` / `unblock-pin` to recover instead.",
132                frame.payload[0],
133                frame.payload[1],
134            );
135        }
136        if frame.opcode == ResponseStatus::AteccChipError.as_u8() && frame.payload.len() == 1
137        {
138            let raw = frame.payload[0];
139            let chip = ChipError::from_status_byte(raw);
140            bail!(
141                "chip returned chip-level error: {} (raw status byte 0x{:02x})",
142                describe_chip_error(chip),
143                raw,
144            );
145        }
146        if frame.opcode == ResponseStatus::AteccCommunicationError.as_u8()
147            && frame.payload.len() == 1
148        {
149            let sub = frame.payload[0];
150            let kind = AteccErrorKind::from_sub_code(sub);
151            bail!(
152                "chip returned communication error: {} (sub-code 0x{:02x})",
153                describe_atecc_kind(kind),
154                sub,
155            );
156        }
157        if frame.payload.is_empty()
158        {
159            bail!("chip returned status 0x{:02x} ({status_name})", frame.opcode);
160        }
161        else
162        {
163            bail!
164            (
165                "chip returned status 0x{:02x} ({status_name}), data: {}",
166                frame.opcode,
167                hex::encode(frame.payload),
168            );
169        }
170    }
171
172    Ok(frame.payload.to_vec())
173}
174
175fn describe_status(byte: u8) -> &'static str
176{
177    // Delegate to the protocol crate so this CLI stays in sync if new
178    // status variants are added there. Returning a static str keeps the
179    // helper allocation-free.
180    match ResponseStatus::from_byte(byte)
181    {
182        Some(ResponseStatus::Ok)                      => "Ok",
183        Some(ResponseStatus::InvalidCommand)          => "InvalidCommand",
184        Some(ResponseStatus::InvalidPayload)          => "InvalidPayload",
185        Some(ResponseStatus::InvalidSlot)             => "InvalidSlot",
186        Some(ResponseStatus::AteccCommunicationError) => "AteccCommunicationError",
187        Some(ResponseStatus::AteccChipError)          => "AteccChipError",
188        Some(ResponseStatus::TouchTimeout)            => "TouchTimeout",
189        Some(ResponseStatus::NotProvisioned)          => "NotProvisioned",
190        Some(ResponseStatus::LockMagicMismatch)       => "LockMagicMismatch",
191        Some(ResponseStatus::LockCrcMismatch)         => "LockCrcMismatch",
192        Some(ResponseStatus::Busy)                    => "Busy",
193        Some(ResponseStatus::WrongPin)                => "WrongPin",
194        Some(ResponseStatus::PinRequired)             => "PinRequired",
195        Some(ResponseStatus::PinBlocked)              => "PinBlocked",
196        Some(ResponseStatus::WrongPuk)                => "WrongPuk",
197        Some(ResponseStatus::Bricked)                 => "Bricked",
198        Some(ResponseStatus::EmergencyResetNotPermitted) => "EmergencyResetNotPermitted",
199        None => "Unknown",
200    }
201}
202
203/// Symbolic name for a chip-side error decoded from
204/// `ResponseStatus::AteccChipError` payload.
205fn describe_chip_error(err: Option<ChipError>) -> &'static str
206{
207    match err
208    {
209        Some(ChipError::CheckMacOrVerifyFailed) => "CheckMacOrVerifyFailed",
210        Some(ChipError::ParseError)             => "ParseError",
211        Some(ChipError::EccFault)               => "EccFault",
212        Some(ChipError::SelfTestFailed)         => "SelfTestFailed",
213        Some(ChipError::HealthTestFailed)       => "HealthTestFailed",
214        Some(ChipError::ExecutionError)         => "ExecutionError",
215        Some(ChipError::WatchdogAboutToExpire)  => "WatchdogAboutToExpire",
216        Some(ChipError::CommandCrcError)        => "CommandCrcError",
217        Some(ChipError::Unknown(_))             => "Unknown(...)",
218        None                                    => "Success(0x00, unexpected here)",
219    }
220}
221
222/// Symbolic name for a non-chip driver error decoded from
223/// `ResponseStatus::AteccCommunicationError` payload.
224fn describe_atecc_kind(kind: Option<AteccErrorKind>) -> &'static str
225{
226    match kind
227    {
228        Some(AteccErrorKind::Hal)               => "Hal (I2C nack / GPIO failure)",
229        Some(AteccErrorKind::WakeFailed)        => "WakeFailed (wrong wake pattern)",
230        Some(AteccErrorKind::SelfTestFailure)   => "SelfTestFailure (chip self-test failed at wake)",
231        Some(AteccErrorKind::BadCrc)            => "BadCrc (response CRC mismatch)",
232        Some(AteccErrorKind::Timeout)           => "Timeout (chip polling timed out)",
233        Some(AteccErrorKind::MalformedResponse) => "MalformedResponse (inconsistent length byte)",
234        Some(AteccErrorKind::BufferTooSmall)    => "BufferTooSmall (driver buffer too small)",
235        Some(AteccErrorKind::Chip(_))           => "Chip(_) (unexpected here, see AteccChipError)",
236        None                                    => "Unknown sub-code",
237    }
238}