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}