Skip to main content

hsm_usb_protocol/
commands.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//! Commands sent from the host to the token.
17//!
18//! See [`crate::frame::Frame`] for the wire layout. The opcode byte is
19//! [`CommandOpcode`]; the payload layout is documented per-opcode below
20//! (in the variant doc-comments) and parsed via the helpers in this module.
21//!
22//! # Opcode ranges
23//!
24//! - `0x01..=0x0F`: runtime operations. Read-only inspection, signing,
25//!   PIN/PUK session management, recovery. Safe to call at any point
26//!   after provisioning is complete.
27//! - `0x10..=0x1F`: provisioning operations. Only effective while the
28//!   relevant zone is unlocked. Sequenced once at the chip's first boot
29//!   and never again in normal operation.
30//! - `0xF0..=0xFF`: destructive / irreversible operations (zone and slot
31//!   locks). Protected by per-opcode magic words and, on the host side,
32//!   by interactive double-confirmation prompts.
33
34/// Opcode byte for each command.
35#[repr(u8)]
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[cfg_attr(feature = "defmt", derive(defmt::Format))]
38pub enum CommandOpcode
39{
40    /// `0x01` - `Info`: read firmware version, chip serial, provisioning state.
41    /// Payload: empty.
42    Info               = 0x01,
43    /// `0x02` - `GetPubkey(slot)`: read a slot's public key (64 bytes).
44    /// Payload: `[slot: u8]`.
45    GetPubkey          = 0x02,
46    /// `0x03` - `Sign(slot, digest)`: produce an ECDSA P-256 signature.
47    /// Requires an active PIN session and a touch.
48    /// Payload: `[slot: u8, digest: [u8; 32]]`.
49    Sign               = 0x03,
50    /// `0x04` - `GenKey(slot)`: regenerate a P-256 key pair.
51    /// Requires an active PIN session.
52    /// Payload: `[slot: u8]`.
53    GenKey             = 0x04,
54    /// `0x05` - Read the 128 bytes of the chip's config zone.
55    /// Payload: `[block: u8]` where `block` is in `0..=3`. The 128-byte
56    /// config zone is returned one 32-byte block at a time so the response
57    /// fits in a single report. The host issues this command four times,
58    /// once per block, to assemble the full image.
59    ReadConfigZone     = 0x05,
60    /// `0x06` - Read the 4-byte `SlotConfig` + `KeyConfig` for one slot.
61    /// Payload: `[slot: u8]`.
62    ReadConfigSlot     = 0x06,
63    /// `0x07` - `VerifyPin(pin)`: open a PIN session (30 s window).
64    /// Payload: `[pin: [u8; 4]]` (each byte holds an ASCII digit `'0'..'9'`).
65    VerifyPin          = 0x07,
66    /// `0x08` - `SetPin(old, new)`: change PIN within an active session.
67    /// Payload: `[old: [u8; 4], new: [u8; 4]]`.
68    SetPin             = 0x08,
69    /// `0x09` - `UnblockPin(puk, new_pin)`: reset PIN counter via PUK.
70    /// Payload: `[puk: [u8; 8], new_pin: [u8; 4]]`.
71    UnblockPin         = 0x09,
72    /// `0x0A` - Read current PIN / PUK retry counters.
73    /// Payload: empty.
74    GetPinStatus       = 0x0A,
75    /// `0x0B` - `SetPuk(old_puk, new_puk, io_key)`: change the PUK.
76    /// Requires an active PIN session (proves the caller knows the
77    /// current PIN). Rewrites slot 6 via the encrypted-write protocol.
78    /// Payload: `[old_puk: [u8; 8], new_puk: [u8; 8], io_key: [u8; 32]]`.
79    SetPuk             = 0x0B,
80    /// `0x0C` - `CloseSession`: terminate the current PIN session
81    /// immediately, without waiting for the 30 s inactivity timeout.
82    ///
83    /// Idempotent: closing an already-closed session is a no-op and
84    /// still returns `Ok`. No payload from the host. No payload in the
85    /// response.
86    CloseSession       = 0x0C,
87    /// `0x0D` - `EmergencyReset(magic, io_key)`: last-chance reset.
88    /// Requires that **both** PIN and PUK batches are exhausted. The
89    /// firmware refuses to run otherwise with the
90    /// `EmergencyResetNotPermitted` status (which carries the actual
91    /// tries-remaining figures in its payload).
92    ///
93    /// Regenerates the ECC private keys in slots 0..=4 and 7 (the
94    /// user's secrets are lost), resets PIN to `"0000"`, generates and
95    /// stores a fresh random PUK (returned in the response payload).
96    /// The user is granted one fresh batch of PIN attempts and one
97    /// fresh batch of PUK attempts.
98    ///
99    /// Protected against accidental invocation by a magic word
100    /// (`0xBADC0FFE`, little-endian on the wire). The CLI also
101    /// requires an interactive double-confirm before sending.
102    ///
103    /// Payload: `[magic: [u8; 4], io_key: [u8; 32]]` (36 bytes).
104    /// Response payload on success: `[new_puk: [u8; 8]]`.
105    EmergencyReset     = 0x0D,
106    /// `0x0E` - `ReadSlotBlock(slot, block)`: read one 32-byte block from
107    /// a data slot. Useful for bring-up diagnostics (verify what
108    /// `ProvisionSlot` wrote) and for inspecting the IO key in slot 8
109    /// before locking the data zone.
110    ///
111    /// The chip applies the slot's `IsSecret` / `EncryptRead` policy and
112    /// rejects reads of private ECC keys. No host-side filter is added
113    /// here: the chip is the authority.
114    ///
115    /// Payload: `[slot: u8, block: u8]`. Response: `[data: [u8; 32]]`.
116    ReadSlotBlock      = 0x0E,
117    /// `0x0F` - `ReadSlotWord(slot, block, offset_words)`: read one 4-byte
118    /// word from a data slot. Same policy as [`Self::ReadSlotBlock`].
119    /// Payload: `[slot: u8, block: u8, offset_words: u8]`.
120    /// Response: `[data: [u8; 4]]`.
121    ReadSlotWord       = 0x0F,
122
123    // Provisioning (reversible while zones are unlocked).
124    /// `0x10` - `WriteConfigZone(blob)`: replace the writable part of the
125    /// config zone. Payload: `[block: u8, blob: [u8; 32]]`. The host issues
126    /// this command four times, once per block index 0..=3.
127    ///
128    /// Two blocks of the config zone have a special wire-level shape
129    /// because the chip's `Write` command refuses some words inside them:
130    ///
131    /// - **Block 0** : words 0..=3 (chip-side bytes 0..16) are the
132    ///   read-only factory area. The firmware writes only words 4..=7
133    ///   (bytes 16..32) as four 4-byte transfers. Payload bytes 0..16 are
134    ///   **ignored**; callers may set them to any placeholder value (the
135    ///   canonical `config-generator` emits zeros).
136    /// - **Block 2** : word 5 (chip-side bytes 84..88) covers
137    ///   `UserExtra`, `Selector`, `LockValue`, and `LockConfig`. Those
138    ///   are modified only via the dedicated `UpdateExtra` and `Lock`
139    ///   commands; the `Write` command rejects a 32-byte transfer that
140    ///   includes them. The firmware writes block 2 as seven 4-byte
141    ///   transfers at word offsets 0..=4 and 6..=7. Payload bytes 20..24
142    ///   are **ignored**.
143    ///
144    /// Blocks 1 and 3 are written wholesale as a single 32-byte transfer.
145    ///
146    /// This mirrors the strategy of `CryptoAuthLib`'s
147    /// `calib_write_bytes_zone` (`lib/calib/calib_basic.c`).
148    WriteConfigZone    = 0x10,
149    /// `0x11` - `ProvisionSlot(slot, value)`: write a 32-byte cleartext
150    /// value into one of the data slots. Only accepted by the firmware
151    /// for the three policy-allowed slots (5, 6, 8). Used at
152    /// provisioning to install the initial PIN hash, PUK hash, and IO
153    /// key, before `LockDataZone`. Returns `InvalidSlot` for other
154    /// slots and chip-error after data lock.
155    /// Payload: `[slot: u8, value: [u8; 32]]`.
156    ProvisionSlot      = 0x11,
157    /// `0x12` - `ProvisionInitialPin`: write `SHA256("0000" || pin_salt)`
158    /// into slot 5 in cleartext, where `pin_salt` is derived from the
159    /// chip's serial. No payload. Used at provisioning instead of
160    /// `ProvisionSlot --slot 5` so that the host does not need to
161    /// reimplement the PIN-hash derivation. Returns `Ok` (empty
162    /// payload) on success.
163    ProvisionInitialPin = 0x12,
164    /// `0x13` - `ProvisionInitialPuk`: generate a fresh random 8-digit
165    /// PUK from the chip's RNG, compute its hash with the per-chip
166    /// salt, write the hash into slot 6 in cleartext, and return the
167    /// PUK in the response payload so the operator can record it.
168    /// **This is the only opportunity to learn the PUK.** No payload
169    /// from the host. Response: `[puk: [u8; 8]]`.
170    ProvisionInitialPuk = 0x13,
171    /// `0x14` - `ProvisionIoKey`: generate a fresh random 32-byte I/O
172    /// Protection Key from the chip's RNG, write it into slot 8 in
173    /// cleartext, and return it in the response payload so the host
174    /// can store it for later encrypted writes. **This is the only
175    /// opportunity to learn the IO key.** No payload from the host.
176    /// Response: `[io_key: [u8; 32]]`.
177    ProvisionIoKey      = 0x14,
178    /// `0x15` - `ReadCounter(counter_id)`: read the raw value of an
179    /// ATECC608B monotonic counter without modifying it. Diagnostic
180    /// tool used for bring-up and debugging. Payload: `[counter_id: u8]`
181    /// where `counter_id` is 0 (Counter0, backs PIN slot) or 1
182    /// (Counter1, backs PUK slot). Response: `[value: [u8; 4]]` in
183    /// little-endian (the same `u32` the chip returns from
184    /// `Counter(mode=Read)`).
185    ///
186    /// Unlike [`GetPinStatus`](Self::GetPinStatus) which converts the
187    /// raw count into "tries remaining" via the service's batch logic,
188    /// this command returns the chip's binary count unmodified. Useful
189    /// to verify the batch-arithmetic against the actual hardware
190    /// state during development.
191    ReadCounter         = 0x15,
192
193    // Lock - isolated, protected by a magic word. Never called from
194    // automated flows: see crates/atecc608b/src/command/lock.rs and the
195    // project's design decisions. The configuration-zone lock also
196    // carries a CRC; the data-zone and per-slot locks do not (the
197    // secret-bearing slots cannot be read back to compute one).
198    /// `0xF0` - Lock the config zone permanently.
199    /// Payload: `[magic: [u8; 4], crc: [u8; 2]]`. The CRC is computed
200    /// by the host CLI over the full 128 bytes of the current
201    /// configuration zone and is verified one last time by the chip
202    /// before commit.
203    LockConfigZone     = 0xF0,
204    /// `0xF1` - Lock the data zone permanently.
205    /// Payload: `[magic: [u8; 4]]`. No CRC: secret-bearing slots cannot
206    /// be read back to compute one.
207    LockDataZone       = 0xF1,
208    /// `0xF2` - Lock a single slot permanently.
209    /// Payload: `[magic: [u8; 4], slot: u8]`.
210    LockSlot           = 0xF2,
211}
212
213impl CommandOpcode
214{
215    /// Map a raw byte to a [`CommandOpcode`], if it is a recognized value.
216    ///
217    /// Returns `None` for any opcode the firmware does not implement,
218    /// including reserved-for-future-use values.
219    #[must_use]
220    pub(crate) const fn from_byte(byte: u8) -> Option<Self>
221    {
222        match byte
223        {
224            0x01 => Some(Self::Info),
225            0x02 => Some(Self::GetPubkey),
226            0x03 => Some(Self::Sign),
227            0x04 => Some(Self::GenKey),
228            0x05 => Some(Self::ReadConfigZone),
229            0x06 => Some(Self::ReadConfigSlot),
230            0x07 => Some(Self::VerifyPin),
231            0x08 => Some(Self::SetPin),
232            0x09 => Some(Self::UnblockPin),
233            0x0A => Some(Self::GetPinStatus),
234            0x0B => Some(Self::SetPuk),
235            0x0C => Some(Self::CloseSession),
236            0x0D => Some(Self::EmergencyReset),
237            0x0E => Some(Self::ReadSlotBlock),
238            0x0F => Some(Self::ReadSlotWord),
239            0x10 => Some(Self::WriteConfigZone),
240            0x11 => Some(Self::ProvisionSlot),
241            0x12 => Some(Self::ProvisionInitialPin),
242            0x13 => Some(Self::ProvisionInitialPuk),
243            0x14 => Some(Self::ProvisionIoKey),
244            0x15 => Some(Self::ReadCounter),
245            0xF0 => Some(Self::LockConfigZone),
246            0xF1 => Some(Self::LockDataZone),
247            0xF2 => Some(Self::LockSlot),
248            _    => None,
249        }
250    }
251
252    /// The raw opcode byte that goes on the wire.
253    #[must_use]
254    pub const fn as_u8(self) -> u8
255    {
256        self as u8
257    }
258}
259
260impl TryFrom<u8> for CommandOpcode
261{
262    type Error = UnknownOpcode;
263
264    fn try_from(byte: u8) -> Result<Self, Self::Error>
265    {
266        Self::from_byte(byte).ok_or(UnknownOpcode { byte })
267    }
268}
269
270/// Returned when a byte cannot be mapped to a known [`CommandOpcode`].
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272#[cfg_attr(feature = "defmt", derive(defmt::Format))]
273pub struct UnknownOpcode
274{
275    /// The raw byte that was not recognized.
276    pub(crate) byte: u8,
277}
278
279// ---------------------------------------------------------------------------
280// Payload shape helpers.
281//
282// These are tiny stateless functions that parse / build the payload bytes
283// for each command. They keep the wire layout in one well-tested place and
284// avoid scattering "byte 0 is the slot, bytes 1..33 are the digest" comments
285// across the firmware code.
286// ---------------------------------------------------------------------------
287
288/// PIN length in bytes (4 digits).
289pub const PIN_LEN: usize = 4;
290
291/// PUK length in bytes (8 digits).
292pub const PUK_LEN: usize = 8;
293
294/// Digest length used for [`CommandOpcode::Sign`] (SHA-256 output).
295pub const DIGEST_LEN: usize = 32;
296
297/// Length of the magic word protecting [`CommandOpcode::LockConfigZone`],
298/// [`CommandOpcode::LockDataZone`], and [`CommandOpcode::LockSlot`].
299pub(crate) const LOCK_MAGIC_LEN: usize = 4;
300
301/// Length of the I/O Protection Key (slot 8 content), in bytes.
302pub(crate) const IO_KEY_LEN: usize = 32;
303
304/// Result of [`parse_set_pin`]: `(old_pin, new_pin, io_key)`.
305pub(crate) type SetPinParts = ([u8; PIN_LEN], [u8; PIN_LEN], [u8; IO_KEY_LEN]);
306
307/// Result of [`parse_unblock_pin`]: `(puk, new_pin, io_key)`.
308pub(crate) type UnblockPinParts = ([u8; PUK_LEN], [u8; PIN_LEN], [u8; IO_KEY_LEN]);
309
310/// Result of [`parse_set_puk`]: `(old_puk, new_puk, io_key)`.
311pub(crate) type SetPukParts = ([u8; PUK_LEN], [u8; PUK_LEN], [u8; IO_KEY_LEN]);
312
313/// Errors returned when a payload does not match the expected shape.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315#[cfg_attr(feature = "defmt", derive(defmt::Format))]
316pub enum PayloadError
317{
318    /// Payload was the wrong size for the command.
319    WrongLen
320    {
321        /// Number of bytes that were expected.
322        expected: usize,
323        /// Number of bytes the caller actually provided.
324        actual:   usize,
325    },
326    /// A magic-word check failed. Used by commands that require an
327    /// explicit confirmation byte sequence in their payload to guard
328    /// against accidental invocation.
329    MagicMismatch,
330}
331
332/// Parse the payload of [`CommandOpcode::GetPubkey`] / `GenKey` / `ReadConfigSlot`.
333///
334/// # Errors
335/// See [`PayloadError`].
336pub fn parse_slot_only(payload: &[u8]) -> Result<u8, PayloadError>
337{
338    require_len(payload, 1)?;
339    Ok(payload[0])
340}
341
342/// Parse the payload of [`CommandOpcode::Sign`].
343///
344/// Returns `(slot, digest)`.
345///
346/// # Errors
347/// See [`PayloadError`].
348pub fn parse_sign(payload: &[u8]) -> Result<(u8, [u8; DIGEST_LEN]), PayloadError>
349{
350    require_len(payload, 1 + DIGEST_LEN)?;
351    let mut digest = [0u8; DIGEST_LEN];
352    digest.copy_from_slice(&payload[1..=DIGEST_LEN]);
353    Ok((payload[0], digest))
354}
355
356/// Parse the payload of [`CommandOpcode::VerifyPin`].
357///
358/// # Errors
359/// See [`PayloadError`].
360pub fn parse_verify_pin(payload: &[u8]) -> Result<[u8; PIN_LEN], PayloadError>
361{
362    require_len(payload, PIN_LEN)?;
363    let mut pin = [0u8; PIN_LEN];
364    pin.copy_from_slice(payload);
365    Ok(pin)
366}
367
368/// Parse the payload of [`CommandOpcode::SetPin`].
369///
370/// Layout: `old_pin (4) || new_pin (4) || io_key (32)`. The IO key is
371/// the 32-byte I/O Protection Key stored in slot 8, provided by the host
372/// from its local provisioning config. The firmware uses it to perform
373/// the encrypted write of the new PIN hash into slot 5; it is not stored.
374///
375/// Returns `(old_pin, new_pin, io_key)`.
376///
377/// # Errors
378/// See [`PayloadError`].
379pub fn parse_set_pin(payload: &[u8]) -> Result<SetPinParts, PayloadError>
380{
381    require_len(payload, PIN_LEN * 2 + 32)?;
382    let mut old = [0u8; PIN_LEN];
383    let mut new = [0u8; PIN_LEN];
384    let mut io_key = [0u8; 32];
385    old.copy_from_slice(&payload[..PIN_LEN]);
386    new.copy_from_slice(&payload[PIN_LEN..PIN_LEN * 2]);
387    io_key.copy_from_slice(&payload[PIN_LEN * 2..PIN_LEN * 2 + 32]);
388    Ok((old, new, io_key))
389}
390
391/// Parse the payload of [`CommandOpcode::UnblockPin`].
392///
393/// Layout: `puk (8) || new_pin (4) || io_key (32)`. The IO key is
394/// the 32-byte I/O Protection Key stored in slot 8, provided by the host
395/// from its local provisioning config. The firmware uses it to perform
396/// the encrypted write of the new PIN hash into slot 5; it is not stored.
397///
398/// Returns `(puk, new_pin, io_key)`.
399///
400/// # Errors
401/// See [`PayloadError`].
402pub fn parse_unblock_pin(payload: &[u8]) -> Result<UnblockPinParts, PayloadError>
403{
404    require_len(payload, PUK_LEN + PIN_LEN + 32)?;
405    let mut puk = [0u8; PUK_LEN];
406    let mut new = [0u8; PIN_LEN];
407    let mut io_key = [0u8; 32];
408    puk.copy_from_slice(&payload[..PUK_LEN]);
409    new.copy_from_slice(&payload[PUK_LEN..PUK_LEN + PIN_LEN]);
410    io_key.copy_from_slice(&payload[PUK_LEN + PIN_LEN..PUK_LEN + PIN_LEN + 32]);
411    Ok((puk, new, io_key))
412}
413
414/// Parse the payload of [`CommandOpcode::SetPuk`].
415///
416/// Layout: `old_puk (8) || new_puk (8) || io_key (32)`. Authentication
417/// is via the PIN session (the caller proved knowledge of the current
418/// PIN earlier); `old_puk` is kept in the payload for forward
419/// compatibility with a defence-in-depth pass that would re-verify the
420/// old PUK on the chip before accepting the new one.
421///
422/// Returns `(old_puk, new_puk, io_key)`.
423///
424/// # Errors
425/// See [`PayloadError`].
426pub fn parse_set_puk(payload: &[u8]) -> Result<SetPukParts, PayloadError>
427{
428    require_len(payload, PUK_LEN * 2 + 32)?;
429    let mut old = [0u8; PUK_LEN];
430    let mut new = [0u8; PUK_LEN];
431    let mut io_key = [0u8; 32];
432    old.copy_from_slice(&payload[..PUK_LEN]);
433    new.copy_from_slice(&payload[PUK_LEN..PUK_LEN * 2]);
434    io_key.copy_from_slice(&payload[PUK_LEN * 2..PUK_LEN * 2 + 32]);
435    Ok((old, new, io_key))
436}
437
438/// Magic word required in the payload of
439/// [`CommandOpcode::EmergencyReset`] to confirm the caller's intent.
440/// Picked to be improbable for any byte sequence arising from a typo
441/// or a buggy host. Same role as [`LOCK_CONFIG_MAGIC`] in the lock
442/// commands.
443pub const EMERGENCY_RESET_MAGIC: [u8; 4] = [0xBA, 0xDC, 0x0F, 0xFE];
444
445/// Parse the payload of [`CommandOpcode::EmergencyReset`].
446///
447/// Layout: `magic (4) || io_key (32)`. The magic must match
448/// [`EMERGENCY_RESET_MAGIC`]. No PIN is required since the use case
449/// is "PIN and PUK both forgotten / exhausted".
450///
451/// Returns `io_key` once the magic is validated.
452///
453/// # Errors
454/// - [`PayloadError::WrongLen`] if the payload is not exactly 36 bytes.
455/// - [`PayloadError::MagicMismatch`] if the first 4 bytes are not
456///   `EMERGENCY_RESET_MAGIC`.
457pub fn parse_emergency_reset(payload: &[u8]) -> Result<[u8; 32], PayloadError>
458{
459    require_len(payload, 4 + 32)?;
460    if payload[0..4] != EMERGENCY_RESET_MAGIC
461    {
462        return Err(PayloadError::MagicMismatch);
463    }
464    let mut io_key = [0u8; 32];
465    io_key.copy_from_slice(&payload[4..4 + 32]);
466    Ok(io_key)
467}
468
469/// Magic word for [`CommandOpcode::LockConfigZone`]. Picked to be a
470/// distinctive 32-bit value (`DE AD BE EF`).
471///
472/// Exposed `pub` so the host CLI in `tools/hsm-host` can build the same
473/// payload byte sequence without re-declaring the constant. The crate is
474/// the single source of truth for the wire format; the CLI is a consumer.
475pub const LOCK_CONFIG_MAGIC: [u8; 4] = [0xDE, 0xAD, 0xBE, 0xEF];
476
477/// Magic word for [`CommandOpcode::LockDataZone`] (`CA FE BA BE`).
478///
479/// See [`LOCK_CONFIG_MAGIC`] for the rationale behind the `pub` exposure.
480pub const LOCK_DATA_MAGIC: [u8; 4] = [0xCA, 0xFE, 0xBA, 0xBE];
481
482/// Magic word for [`CommandOpcode::LockSlot`] (`F0 0D CA FE`).
483///
484/// See [`LOCK_CONFIG_MAGIC`] for the rationale behind the `pub` exposure.
485pub const LOCK_SLOT_MAGIC: [u8; 4] = [0xF0, 0x0D, 0xCA, 0xFE];
486
487/// Parse the payload of [`CommandOpcode::LockConfigZone`].
488///
489/// Layout: `magic (4) || crc (2 LE)`. Returns the CRC if the magic
490/// matches. The CRC is the CRC-16 of the chip's current 128-byte
491/// configuration zone, computed by the host CLI just before sending.
492///
493/// # Errors
494/// - [`PayloadError::WrongLen`] if the payload is not exactly 6 bytes.
495/// - [`PayloadError::MagicMismatch`] if the first 4 bytes are not
496///   `LOCK_CONFIG_MAGIC`.
497pub fn parse_lock_config_zone(payload: &[u8]) -> Result<u16, PayloadError>
498{
499    require_len(payload, LOCK_MAGIC_LEN + 2)?;
500    if payload[0..LOCK_MAGIC_LEN] != LOCK_CONFIG_MAGIC
501    {
502        return Err(PayloadError::MagicMismatch);
503    }
504    Ok(u16::from_le_bytes([payload[LOCK_MAGIC_LEN], payload[LOCK_MAGIC_LEN + 1]]))
505}
506
507/// Parse the payload of [`CommandOpcode::LockDataZone`].
508///
509/// Layout: `magic (4)`. No CRC accompanies a data-zone lock because
510/// secret-bearing slots cannot be read back to compute one. The
511/// double-confirmation in the host CLI is the only safety beyond the
512/// magic word.
513///
514/// # Errors
515/// - [`PayloadError::WrongLen`] if the payload is not exactly 4 bytes.
516/// - [`PayloadError::MagicMismatch`] if the first 4 bytes are not
517///   `LOCK_DATA_MAGIC`.
518pub fn parse_lock_data_zone(payload: &[u8]) -> Result<(), PayloadError>
519{
520    require_len(payload, LOCK_MAGIC_LEN)?;
521    if payload[0..LOCK_MAGIC_LEN] != LOCK_DATA_MAGIC
522    {
523        return Err(PayloadError::MagicMismatch);
524    }
525    Ok(())
526}
527
528/// Parse the payload of [`CommandOpcode::LockSlot`].
529///
530/// Layout: `magic (4) || slot (1)`.
531///
532/// # Errors
533/// - [`PayloadError::WrongLen`] if the payload is not exactly 5 bytes.
534/// - [`PayloadError::MagicMismatch`] if the first 4 bytes are not
535///   `LOCK_SLOT_MAGIC`.
536pub fn parse_lock_slot(payload: &[u8]) -> Result<u8, PayloadError>
537{
538    require_len(payload, LOCK_MAGIC_LEN + 1)?;
539    if payload[0..LOCK_MAGIC_LEN] != LOCK_SLOT_MAGIC
540    {
541        return Err(PayloadError::MagicMismatch);
542    }
543    Ok(payload[LOCK_MAGIC_LEN])
544}
545
546/// Parse the payload of [`CommandOpcode::WriteConfigZone`].
547///
548/// Returns `(block_index, block_data)`.
549///
550/// # Errors
551/// See [`PayloadError`].
552pub fn parse_write_config_zone(payload: &[u8]) 
553-> Result<(u8, [u8; 32]), PayloadError>
554{
555    require_len(payload, 1 + 32)?;
556    let mut block = [0u8; 32];
557    block.copy_from_slice(&payload[1..=32]);
558    Ok((payload[0], block))
559}
560
561/// Parse the payload of [`CommandOpcode::ProvisionSlot`].
562///
563/// Layout: `slot (1) || value (32)`. Returns the pair.
564///
565/// # Errors
566/// - [`PayloadError::WrongLen`] if the payload is not exactly 33 bytes.
567pub fn parse_provision_slot(payload: &[u8]) 
568-> Result<(u8, [u8; 32]), PayloadError>
569{
570    require_len(payload, 1 + 32)?;
571    let mut value = [0u8; 32];
572    value.copy_from_slice(&payload[1..=32]);
573    Ok((payload[0], value))
574}
575
576/// Parse the payload of [`CommandOpcode::ReadSlotBlock`].
577///
578/// Layout: `slot (1) || block (1)`. Returns the pair.
579///
580/// # Errors
581/// - [`PayloadError::WrongLen`] if the payload is not exactly 2 bytes.
582pub fn parse_read_slot_block(payload: &[u8]) -> Result<(u8, u8), PayloadError>
583{
584    require_len(payload, 2)?;
585    Ok((payload[0], payload[1]))
586}
587
588/// Parse the payload of [`CommandOpcode::ReadSlotWord`].
589///
590/// Layout: `slot (1) || block (1) || offset_words (1)`. Returns the
591/// triple.
592///
593/// # Errors
594/// - [`PayloadError::WrongLen`] if the payload is not exactly 3 bytes.
595pub fn parse_read_slot_word(payload: &[u8]) -> Result<(u8, u8, u8), PayloadError>
596{
597    require_len(payload, 3)?;
598    Ok((payload[0], payload[1], payload[2]))
599}
600
601fn require_len(payload: &[u8], expected: usize) -> Result<(), PayloadError>
602{
603    if payload.len() == expected
604    {
605        Ok(())
606    }
607    else
608    {
609        Err(PayloadError::WrongLen
610        {
611            expected,
612            actual: payload.len(),
613        })
614    }
615}
616
617#[cfg(test)]
618mod tests
619{
620    use super::*;
621
622    #[test]
623    fn from_byte_round_trips()
624    {
625        for op in [
626            CommandOpcode::Info,
627            CommandOpcode::GetPubkey,
628            CommandOpcode::Sign,
629            CommandOpcode::GenKey,
630            CommandOpcode::ReadConfigZone,
631            CommandOpcode::ReadConfigSlot,
632            CommandOpcode::VerifyPin,
633            CommandOpcode::SetPin,
634            CommandOpcode::UnblockPin,
635            CommandOpcode::GetPinStatus,
636            CommandOpcode::SetPuk,
637            CommandOpcode::CloseSession,
638            CommandOpcode::EmergencyReset,
639            CommandOpcode::ReadSlotBlock,
640            CommandOpcode::ReadSlotWord,
641            CommandOpcode::WriteConfigZone,
642            CommandOpcode::ProvisionSlot,
643            CommandOpcode::ProvisionInitialPin,
644            CommandOpcode::ProvisionInitialPuk,
645            CommandOpcode::ProvisionIoKey,
646            CommandOpcode::ReadCounter,
647            CommandOpcode::LockConfigZone,
648            CommandOpcode::LockDataZone,
649            CommandOpcode::LockSlot,
650        ]
651        {
652            assert_eq!(CommandOpcode::from_byte(op.as_u8()), Some(op));
653        }
654    }
655
656    #[test]
657    fn from_byte_returns_none_for_unknown()
658    {
659        assert!(CommandOpcode::from_byte(0x00).is_none());
660        assert!(CommandOpcode::from_byte(0xFF).is_none());
661        assert!(CommandOpcode::from_byte(0x42).is_none());
662    }
663
664    #[test]
665    fn try_from_returns_error_with_byte()
666    {
667        let err = CommandOpcode::try_from(0x42u8).unwrap_err();
668        assert_eq!(err.byte, 0x42);
669    }
670
671    #[test]
672    fn parse_slot_only_accepts_one_byte()
673    {
674        assert_eq!(parse_slot_only(&[5]).unwrap(), 5);
675        assert!(parse_slot_only(&[]).is_err());
676        assert!(parse_slot_only(&[1, 2]).is_err());
677    }
678
679    #[test]
680    fn parse_sign_extracts_slot_and_digest()
681    {
682        let expected_digest: [u8; DIGEST_LEN] = core::array::from_fn(|i| u8::try_from(i).unwrap());
683        let mut payload = [0u8; 1 + DIGEST_LEN];
684        payload[0] = 7;
685        payload[1..].copy_from_slice(&expected_digest);
686        let (slot, digest) = parse_sign(&payload).unwrap();
687        assert_eq!(slot, 7);
688        assert_eq!(digest, expected_digest);
689    }
690
691    #[test]
692    fn parse_sign_rejects_short_payload()
693    {
694        let err = parse_sign(&[0u8; 10]).unwrap_err();
695        assert_eq!(err, PayloadError::WrongLen { expected: 33, actual: 10 });
696    }
697
698    #[test]
699    fn parse_verify_pin_extracts_4_bytes()
700    {
701        let pin = parse_verify_pin(b"1234").unwrap();
702        assert_eq!(&pin, b"1234");
703    }
704
705    #[test]
706    fn parse_set_pin_extracts_old_new_and_io_key()
707    {
708        let mut payload = [0u8; PIN_LEN * 2 + 32];
709        payload[..4].copy_from_slice(b"0000");
710        payload[4..8].copy_from_slice(b"1234");
711        for i in 0u8..32
712        {
713            payload[8 + usize::from(i)] = 0xA0 + i;
714        }
715        let (old, new, io_key) = parse_set_pin(&payload).unwrap();
716        assert_eq!(&old, b"0000");
717        assert_eq!(&new, b"1234");
718        for i in 0u8..32
719        {
720            assert_eq!(io_key[usize::from(i)], 0xA0 + i); 
721        }
722    }
723
724    #[test]
725    fn parse_set_pin_rejects_short_payload()
726    {
727        let err = parse_set_pin(b"0000123").unwrap_err();
728        assert_eq!(err, PayloadError::WrongLen { expected: 40, actual: 7 });
729    }
730
731    #[test]
732    fn parse_unblock_pin_extracts_puk_new_pin_and_io_key()
733    {
734        let mut payload = [0u8; PUK_LEN + PIN_LEN + 32];
735        payload[..8].copy_from_slice(b"01234567");
736        payload[8..12].copy_from_slice(b"1234");
737        for i in 0u8..32
738        {
739            payload[12 + usize::from(i)] = 0xB0 + i;
740        }
741        let (puk, new, io_key) = parse_unblock_pin(&payload).unwrap();
742        assert_eq!(&puk, b"01234567");
743        assert_eq!(&new, b"1234");
744        for i in 0u8..32
745        {
746            assert_eq!(io_key[usize::from(i)], 0xB0 + i);
747        }
748    }
749
750    #[test]
751    fn parse_unblock_pin_rejects_short_payload()
752    {
753        let err = parse_unblock_pin(b"01234567").unwrap_err();
754        assert_eq!(err, PayloadError::WrongLen { expected: 44, actual: 8 });
755    }
756
757    #[test]
758    fn parse_set_puk_extracts_old_new_and_io_key()
759    {
760        let mut payload = [0u8; PUK_LEN * 2 + 32];
761        payload[..8].copy_from_slice(b"00000000");
762        payload[8..16].copy_from_slice(b"99999999");
763        for i in 0u8..32
764        {
765            payload[16 + usize::from(i)] = 0xC0 + i;
766        }
767        let (old, new, io_key) = parse_set_puk(&payload).unwrap();
768        assert_eq!(&old, b"00000000");
769        assert_eq!(&new, b"99999999");
770        for i in 0u8..32
771        {
772            assert_eq!(io_key[usize::from(i)], 0xC0 + i);
773        }
774    }
775
776    #[test]
777    fn parse_set_puk_rejects_short_payload()
778    {
779        let err = parse_set_puk(b"012345670000").unwrap_err();
780        assert_eq!(err, PayloadError::WrongLen { expected: 48, actual: 12 });
781    }
782
783    #[test]
784    fn parse_emergency_reset_accepts_valid_magic_and_extracts_io_key()
785    {
786        let mut payload = [0u8; 4 + 32];
787        payload[..4].copy_from_slice(&EMERGENCY_RESET_MAGIC);
788        for i in 0u8..32
789        {
790            payload[4 + usize::from(i)] = 0xE0u8.wrapping_add(i);
791        }
792        let io_key = parse_emergency_reset(&payload).unwrap();
793        for i in 0u8..32
794        {
795            assert_eq!(io_key[usize::from(i)], 0xE0u8.wrapping_add(i));
796        }
797    }
798
799    #[test]
800    fn parse_emergency_reset_rejects_wrong_magic()
801    {
802        let mut payload = [0u8; 4 + 32];
803        payload[..4].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
804        let err = parse_emergency_reset(&payload).unwrap_err();
805        assert_eq!(err, PayloadError::MagicMismatch);
806    }
807
808    #[test]
809    fn parse_emergency_reset_rejects_short_payload()
810    {
811        let err = parse_emergency_reset(&[0u8; 8]).unwrap_err();
812        assert_eq!(err, PayloadError::WrongLen { expected: 36, actual: 8 });
813    }
814
815    #[test]
816    fn parse_write_config_zone_extracts_block_index_and_data()
817    {
818        let mut payload = [0u8; 1 + 32];
819        payload[0] = 2;
820        for i in 0u8..32
821        {
822            payload[1 + usize::from(i)] = 0x10 + i;
823        }
824        let (block, data) = parse_write_config_zone(&payload).unwrap();
825        assert_eq!(block, 2);
826        for i in 0u8..32
827        {
828            assert_eq!(data[usize::from(i)], 0x10 + i);
829        }
830    }
831
832    #[test]
833    fn parse_read_slot_block_extracts_slot_and_block()
834    {
835        let (slot, block) = parse_read_slot_block(&[8, 2]).unwrap();
836        assert_eq!(slot, 8);
837        assert_eq!(block, 2);
838    }
839
840    #[test]
841    fn parse_read_slot_block_rejects_wrong_len()
842    {
843        assert_eq!
844        (
845            parse_read_slot_block(&[5]).unwrap_err(),
846            PayloadError::WrongLen { expected: 2, actual: 1 },
847        );
848        assert_eq!
849        (
850            parse_read_slot_block(&[5, 1, 0]).unwrap_err(),
851            PayloadError::WrongLen { expected: 2, actual: 3 },
852        );
853    }
854
855    #[test]
856    fn parse_read_slot_word_extracts_slot_block_offset()
857    {
858        let (slot, block, offset) = parse_read_slot_word(&[8, 1, 3]).unwrap();
859        assert_eq!(slot, 8);
860        assert_eq!(block, 1);
861        assert_eq!(offset, 3);
862    }
863
864    #[test]
865    fn parse_read_slot_word_rejects_wrong_len()
866    {
867        assert_eq!
868        (
869            parse_read_slot_word(&[5, 1]).unwrap_err(),
870            PayloadError::WrongLen { expected: 3, actual: 2 },
871        );
872    }
873}