Skip to main content

hsm_host/
main.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//! CLI used to operate the mini-HSM dongle from a development host.
17//!
18//! Most subcommands map one-to-one to a USB-HID opcode. The two
19//! exceptions are `enumerate` (no chip command involved) and the lock
20//! commands (interactive double-confirm before the opcode is sent).
21
22mod device;
23
24use std::fs;
25use std::io::{self, BufRead, Write};
26
27use anyhow::{bail, Context, Result};
28use clap::{Parser, Subcommand};
29
30use hsm_usb_protocol::commands::
31{
32    EMERGENCY_RESET_MAGIC,
33    LOCK_CONFIG_MAGIC,
34    LOCK_DATA_MAGIC,
35    LOCK_SLOT_MAGIC,
36};
37use hsm_usb_protocol::CommandOpcode;
38
39#[derive(Parser, Debug)]
40#[command(author, version, about, long_about = None)]
41struct Cli
42{
43    #[command(subcommand)]
44    command: Command,
45}
46
47#[derive(Subcommand, Debug)]
48enum Command
49{
50    /// Enumerate all USB HID devices and print those matching the
51    /// mini-HSM vendor / product IDs.
52    Enumerate,
53
54    /// Send an `Info` request and pretty-print the response.
55    Info,
56
57    /// Read the chip's 128-byte config zone and dump it as hex.
58    ReadConfig,
59
60    /// Read the per-slot configuration (SlotConfig + KeyConfig).
61    /// Returns 4 bytes : [SlotConfig lo/hi, KeyConfig lo/hi].
62    ReadConfigSlot
63    {
64        #[arg(long)]
65        slot: u8,
66    },
67
68    /// Read one 32-byte block of a data slot. The chip enforces the
69    /// slot's read policy (private ECC slots refuse reads). Mostly
70    /// useful for bring-up diagnostics and to verify what
71    /// `ProvisionSlot` wrote before locking the data zone.
72    ReadSlotBlock
73    {
74        #[arg(long)]
75        slot:  u8,
76        /// Block index inside the slot (slot-size dependent).
77        #[arg(long, default_value_t = 0)]
78        block: u8,
79    },
80
81    /// Read one 4-byte word of a data slot.
82    ReadSlotWord
83    {
84        #[arg(long)]
85        slot:   u8,
86        /// Block index inside the slot.
87        #[arg(long, default_value_t = 0)]
88        block:  u8,
89        /// Word offset inside the block (`0..=7`).
90        #[arg(long, default_value_t = 0)]
91        offset: u8,
92    },
93
94    /// Write the writable bytes of the config zone (provisioning).
95    /// Reversible while the zone is unlocked.
96    WriteConfig
97    {
98        /// Path to the 128-byte config blob produced by
99        /// `tools/config-generator`.
100        #[arg(long)]
101        path: String,
102    },
103
104    /// Write a 32-byte value in cleartext into one of the data slots
105    /// 5, 6, or 8. Only legal before the data zone is locked. Used for
106    /// the initial provisioning of the PIN hash, PUK hash, and IO key.
107    ProvisionSlot
108    {
109        #[arg(long)]
110        slot:  u8,
111        /// 32-byte value, hex-encoded (64 chars).
112        #[arg(long)]
113        value: String,
114    },
115
116    /// Orchestrate the full data-zone provisioning of a fresh,
117    /// config-locked token in one shot: generate IO key (slot 8),
118    /// initial PIN hash (slot 5, PIN = "0000"), initial PUK (slot 6,
119    /// random 8 digits), and the primary identity key (slot 0). The
120    /// IO key and PUK are written to `secrets_file` (JSON) and also
121    /// printed on stdout. Both are required for later operations and
122    /// cannot be retrieved later.
123    ProvisionToken
124    {
125        /// Path to the JSON file that will receive the chip's
126        /// serial, the IO key, and the initial PUK. Refuses to
127        /// overwrite an existing file.
128        #[arg(long)]
129        secrets_file: String,
130    },
131
132    /// Read the public key of a slot.
133    GetPubkey
134    {
135        #[arg(long)]
136        slot: u8,
137    },
138
139    /// Regenerate the private key in a slot (P-256, on-chip).
140    Genkey
141    {
142        #[arg(long)]
143        slot: u8,
144    },
145
146    /// Sign a 32-byte challenge after PIN + touch.
147    Sign
148    {
149        #[arg(long)]
150        slot:      u8,
151        /// 32-byte challenge as a hex string (64 chars).
152        #[arg(long)]
153        challenge: String,
154    },
155
156    /// Open a PIN session.
157    VerifyPin
158    {
159        #[arg(long)]
160        pin: String,
161    },
162
163    /// Change the PIN. Requires an active PIN session.
164    SetPin
165    {
166        #[arg(long)]
167        old:    String,
168        #[arg(long)]
169        new:    String,
170        /// 32-byte IO Protection Key as a hex string (64 chars).
171        #[arg(long)]
172        io_key: String,
173    },
174
175    /// Reset the PIN using the PUK.
176    UnblockPin
177    {
178        #[arg(long)]
179        puk:     String,
180        #[arg(long)]
181        new_pin: String,
182        /// 32-byte IO Protection Key as a hex string (64 chars).
183        #[arg(long)]
184        io_key:  String,
185    },
186
187    /// Change the PUK. Requires an active PIN session (call `verify-pin`
188    /// first) AND the current PUK. The current PUK is re-verified
189    /// against slot 6, which consumes one Counter1 attempt internally
190    /// (refreshed on success).
191    SetPuk
192    {
193        #[arg(long)]
194        old:    String,
195        #[arg(long)]
196        new:    String,
197        /// 32-byte IO Protection Key as a hex string (64 chars).
198        #[arg(long)]
199        io_key: String,
200    },
201
202    /// Close the active PIN session immediately. Idempotent: succeeds
203    /// even if no session is open. Use this to lock the dongle
204    /// proactively after a signing burst instead of waiting for the
205    /// 30 s inactivity timeout.
206    CloseSession,
207
208    /// **LAST-CHANCE RECOVERY.** Only usable when both the PIN and the
209    /// PUK batches are exhausted (i.e. the user has forgotten both and
210    /// tried until they hit zero attempts on both). Destroys every
211    /// user secret in the chip and rebuilds a clean baseline with PIN
212    /// "0000" and a fresh random PUK. ECC private keys in slots 0..=4
213    /// and 7 are lost. The chip survives.
214    #[command(name = "emergency-reset-DANGEROUS")]
215    EmergencyResetDangerous
216    {
217        /// 32-byte IO Protection Key as a hex string (64 chars).
218        #[arg(long)]
219        io_key: String,
220    },
221
222    /// Read current PIN / PUK retry counters and session state.
223    PinStatus,
224
225    /// Read the raw value of one of the chip's monotonic counters.
226    ///
227    /// Diagnostic command. Returns the binary count the chip's `Counter`
228    /// command sees, without the batch-arithmetic conversion that
229    /// `pin-status` applies. Use during bring-up to verify what the
230    /// chip actually stores.
231    #[command(name = "read-counter")]
232    ReadCounter
233    {
234        /// 0 for Counter0 (PIN slot, slot 5), 1 for Counter1 (PUK
235        /// slot, slot 6).
236        #[arg(long)]
237        id: u8,
238    },
239
240    /// Lock the config zone. **Irreversible.** Reads the chip's
241    /// configuration zone, computes the CRC-16 over the full 128 bytes,
242    /// and shows it in the double-confirmation prompt. The same CRC is
243    /// passed to the chip, which verifies one last time before
244    /// committing.
245    #[command(name = "lock-config-DANGEROUS")]
246    LockConfigDangerous,
247
248    /// Lock the data zone. **Irreversible.** No CRC is checked at lock
249    /// time: every secret-bearing slot has `IsSecret=1` and cannot be
250    /// read back. The double-confirmation prompt is the only safety
251    /// beyond the magic-word check.
252    #[command(name = "lock-data-DANGEROUS")]
253    LockDataDangerous,
254
255    /// Lock an individual slot. **Irreversible.** Requires the slot
256    /// index and an interactive confirmation.
257    #[command(name = "lock-slot-DANGEROUS")]
258    LockSlotDangerous
259    {
260        #[arg(long)]
261        slot: u8,
262    },
263}
264
265fn main() -> Result<()>
266{
267    let cli = Cli::parse();
268
269    match cli.command
270    {
271        Command::Enumerate => device::enumerate(),
272        Command::Info => cmd_info(),
273        Command::ReadConfig => cmd_read_config(),
274        Command::ReadConfigSlot { slot } => cmd_read_config_slot(slot),
275        Command::ReadSlotBlock { slot, block } => cmd_read_slot_block(slot, block),
276        Command::ReadSlotWord { slot, block, offset } => cmd_read_slot_word(slot, block, offset),
277        Command::WriteConfig { path } => cmd_write_config(&path),
278        Command::ProvisionSlot { slot, value } => cmd_provision_slot(slot, &value),
279        Command::ProvisionToken { secrets_file } => cmd_provision_token(&secrets_file),
280        Command::GetPubkey { slot } => cmd_get_pubkey(slot),
281        Command::Genkey { slot } => cmd_genkey(slot),
282        Command::Sign { slot, challenge } => cmd_sign(slot, &challenge),
283        Command::VerifyPin { pin } => cmd_verify_pin(&pin),
284        Command::SetPin { old, new, io_key } => cmd_set_pin(&old, &new, &io_key),
285        Command::UnblockPin { puk, new_pin, io_key } =>
286        {
287            cmd_unblock_pin(&puk, &new_pin, &io_key)
288        }
289        Command::SetPuk { old, new, io_key } => cmd_set_puk(&old, &new, &io_key),
290        Command::CloseSession => cmd_close_session(),
291        Command::EmergencyResetDangerous { io_key } =>
292        {
293            cmd_emergency_reset_dangerous(&io_key)
294        }
295        Command::PinStatus => cmd_pin_status(),
296        Command::ReadCounter { id } => cmd_read_counter(id),
297        Command::LockConfigDangerous => cmd_lock_config_dangerous(),
298        Command::LockDataDangerous => cmd_lock_data_dangerous(),
299        Command::LockSlotDangerous { slot } => cmd_lock_slot_dangerous(slot),
300    }
301}
302
303fn cmd_info() -> Result<()>
304{
305    let device = device::open()?;
306    let payload = device::send_command(&device, CommandOpcode::Info.as_u8(), &[])?;
307    if payload.len() != 14
308    {
309        bail!("unexpected Info payload length: {}", payload.len());
310    }
311    let revision: [u8; 4] = payload[0..4].try_into().unwrap();
312    let serial: [u8; 9] = payload[4..13].try_into().unwrap();
313    let is_provisioned = payload[13] != 0;
314    println!("Chip revision    : {:02X} {:02X} {:02X} {:02X}",
315        revision[0], revision[1], revision[2], revision[3]);
316    println!("Serial number    : {}", hex::encode_upper(serial));
317    println!("Provisioned      : {}", if is_provisioned { "yes" } else { "no (zones unlocked)" });
318    Ok(())
319}
320
321/// Read the chip's full 128-byte configuration zone via the device.
322///
323/// The firmware exposes a `ReadConfigZone(block)` opcode that returns
324/// 32 bytes at a time. This helper drives the four calls in order and
325/// assembles the result.
326fn read_full_config_zone(device: &hidapi::HidDevice) -> Result<[u8; 128]>
327{
328    let mut full = [0u8; 128];
329    for block in 0u8..=3
330    {
331        let payload = device::send_command
332        (
333            device,
334            CommandOpcode::ReadConfigZone.as_u8(),
335            &[block],
336        )?;
337        if payload.len() != 32
338        {
339            bail!("ReadConfigZone block {block}: expected 32 bytes, got {}", payload.len());
340        }
341        let start = (block as usize) * 32;
342        full[start..start + 32].copy_from_slice(&payload);
343    }
344    Ok(full)
345}
346
347fn cmd_read_config() -> Result<()>
348{
349    let device = device::open()?;
350    let full = read_full_config_zone(&device)?;
351    for (i, b) in full.iter().enumerate()
352    {
353        if i % 16 == 0
354        {
355            if i != 0 { println!(); }
356            print!("{:03}:  ", i);
357        }
358        print!("{:02x} ", b);
359    }
360    println!();
361    Ok(())
362}
363
364fn cmd_write_config(path: &str) -> Result<()>
365{
366    let blob = fs::read(path)
367        .with_context(|| format!("failed to read config blob from {path}"))?;
368    if blob.len() != 128
369    {
370        bail!("config blob must be exactly 128 bytes, got {}", blob.len());
371    }
372    let device = device::open()?;
373    // Send 4 writes, one per 32-byte block.
374    for block in 0u8..=3
375    {
376        let mut payload = [0u8; 33];
377        payload[0] = block;
378        let start = (block as usize) * 32;
379        payload[1..33].copy_from_slice(&blob[start..start + 32]);
380        device::send_command
381        (
382            &device,
383            CommandOpcode::WriteConfigZone.as_u8(),
384            &payload,
385        )?;
386        println!("wrote block {block}");
387    }
388    Ok(())
389}
390
391fn cmd_read_config_slot(slot: u8) -> Result<()>
392{
393    let device = device::open()?;
394    let payload = device::send_command
395    (
396        &device,
397        CommandOpcode::ReadConfigSlot.as_u8(),
398        &[slot],
399    )?;
400    if payload.len() != 4
401    {
402        bail!("unexpected ReadConfigSlot payload length: {}", payload.len());
403    }
404    let slot_config = u16::from_le_bytes([payload[0], payload[1]]);
405    let key_config  = u16::from_le_bytes([payload[2], payload[3]]);
406    println!("Slot {slot} configuration:");
407    println!("  SlotConfig: 0x{:04X}  ({} {})",
408        slot_config,
409        format_args!("{:08b}", payload[1]),
410        format_args!("{:08b}", payload[0]),
411    );
412    println!("  KeyConfig : 0x{:04X}  ({} {})",
413        key_config,
414        format_args!("{:08b}", payload[3]),
415        format_args!("{:08b}", payload[2]),
416    );
417    Ok(())
418}
419
420fn cmd_read_slot_block(slot: u8, block: u8) -> Result<()>
421{
422    let device = device::open()?;
423    let payload = device::send_command
424    (
425        &device,
426        CommandOpcode::ReadSlotBlock.as_u8(),
427        &[slot, block],
428    )?;
429    if payload.len() != 32
430    {
431        bail!("unexpected ReadSlotBlock payload length: {}", payload.len());
432    }
433    println!("Slot {slot} block {block} (32 bytes):");
434    for (i, chunk) in payload.chunks(16).enumerate()
435    {
436        print!("  {:02}:  ", i * 16);
437        for b in chunk
438        {
439            print!("{b:02X} ");
440        }
441        println!();
442    }
443    Ok(())
444}
445
446fn cmd_read_slot_word(slot: u8, block: u8, offset: u8) -> Result<()>
447{
448    let device = device::open()?;
449    let payload = device::send_command
450    (
451        &device,
452        CommandOpcode::ReadSlotWord.as_u8(),
453        &[slot, block, offset],
454    )?;
455    if payload.len() != 4
456    {
457        bail!("unexpected ReadSlotWord payload length: {}", payload.len());
458    }
459    println!
460    (
461        "Slot {slot} block {block} word {offset}: {:02X} {:02X} {:02X} {:02X}",
462        payload[0], payload[1], payload[2], payload[3],
463    );
464    Ok(())
465}
466
467fn cmd_close_session() -> Result<()>
468{
469    let device = device::open()?;
470    device::send_command
471    (
472        &device,
473        CommandOpcode::CloseSession.as_u8(),
474        &[],
475    )?;
476    println!("Session closed.");
477    Ok(())
478}
479
480fn cmd_provision_slot(slot: u8, value_hex: &str) -> Result<()>
481{
482    let value = parse_hex_array::<32>(value_hex, "value")?;
483    let mut payload = [0u8; 1 + 32];
484    payload[0] = slot;
485    payload[1..].copy_from_slice(&value);
486
487    let device = device::open()?;
488    device::send_command
489    (
490        &device,
491        CommandOpcode::ProvisionSlot.as_u8(),
492        &payload,
493    )?;
494    println!("Slot {slot} written (cleartext, {} bytes).", value.len());
495    Ok(())
496}
497
498/// Provision a fresh chip in one orchestrated pass.
499///
500/// Sequence:
501/// 1. `Info` to capture the chip serial.
502/// 2. `ProvisionIoKey` -> chip generates random 32 bytes, writes
503///    slot 8, returns the key.
504/// 3. `ProvisionInitialPin` -> chip writes SHA-256("0000" || salt)
505///    to slot 5.
506/// 4. `ProvisionInitialPuk` -> chip generates 8-digit PUK, writes
507///    hash to slot 6, returns the PUK.
508/// 5. `GenKey --slot 0` -> chip generates primary ECC key on chip.
509/// 6. Write the IO key + PUK + serial to `secrets_file` (JSON) and
510///    print on stdout.
511///
512/// Refuses to overwrite an existing `secrets_file`: the caller must
513/// move or delete an existing one to re-provision.
514fn cmd_provision_token(secrets_file_path: &str) -> Result<()>
515{
516    use std::path::Path;
517    let secrets_path = Path::new(secrets_file_path);
518    if secrets_path.exists()
519    {
520        bail!(
521            "secrets file `{secrets_file_path}` already exists, refusing to overwrite. \
522             Move it aside or pick a different path."
523        );
524    }
525
526    let device = device::open()?;
527
528    // 1. Info: capture serial.
529    let info = device::send_command(&device, CommandOpcode::Info.as_u8(), &[])?;
530    if info.len() != 14
531    {
532        bail!("unexpected Info payload: {} bytes", info.len());
533    }
534    let serial_hex = hex::encode_upper(&info[4..13]);
535    let already_provisioned = info[13] != 0;
536    if already_provisioned
537    {
538        bail!("chip reports it is already provisioned (both zones locked). Refusing.");
539    }
540    println!("Chip serial : {serial_hex}");
541
542    // 2. IO key.
543    println!("Generating IO key...");
544    let io_key_bytes = device::send_command(&device, CommandOpcode::ProvisionIoKey.as_u8(), &[])?;
545    if io_key_bytes.len() != 32
546    {
547        bail!("unexpected IO key length: {}", io_key_bytes.len());
548    }
549    let io_key_hex = hex::encode_upper(&io_key_bytes);
550    println!("IO key written to slot 8.");
551
552    // 3. Initial PIN.
553    println!("Writing default PIN hash to slot 5...");
554    device::send_command(&device, CommandOpcode::ProvisionInitialPin.as_u8(), &[])?;
555
556    // 4. Initial PUK.
557    println!("Generating PUK...");
558    let puk_bytes = device::send_command(
559        &device,
560        CommandOpcode::ProvisionInitialPuk.as_u8(),
561        &[],
562    )?;
563    if puk_bytes.len() != 8
564    {
565        bail!("unexpected PUK length: {}", puk_bytes.len());
566    }
567    let puk_str = core::str::from_utf8(&puk_bytes)
568        .context("PUK is not valid UTF-8")?
569        .to_string();
570
571    // 5. Primary identity key.
572    println!("Generating ECC P-256 key in slot 0...");
573    let pubkey = device::send_command(&device, CommandOpcode::GenKey.as_u8(), &[0u8])?;
574    if pubkey.len() != 64
575    {
576        bail!("unexpected pubkey length: {}", pubkey.len());
577    }
578    let pubkey_x = hex::encode_upper(&pubkey[..32]);
579    let pubkey_y = hex::encode_upper(&pubkey[32..]);
580
581    // 6. Persist to JSON.
582    let json = format!
583    (
584        "{{\n  \"chip_serial\": \"{serial_hex}\",\n  \
585         \"io_key\": \"{io_key_hex}\",\n  \
586         \"initial_puk\": \"{puk_str}\",\n  \
587         \"primary_pubkey_x\": \"{pubkey_x}\",\n  \
588         \"primary_pubkey_y\": \"{pubkey_y}\"\n}}\n"
589    );
590    fs::write(secrets_path, &json)
591        .with_context(|| format!("failed to write secrets to {secrets_file_path}"))?;
592
593    println!();
594    println!("=== PROVISIONING DONE ===");
595    println!("Chip serial      : {serial_hex}");
596    println!("IO key           : {io_key_hex}");
597    println!("Initial PIN      : 0000");
598    println!("Initial PUK      : {puk_str}");
599    println!("Primary pubkey X : {pubkey_x}");
600    println!("Primary pubkey Y : {pubkey_y}");
601    println!();
602    println!("Secrets written to: {secrets_file_path}");
603    println!();
604    println!("WRITE THE PUK AND IO KEY DOWN NOW. There is no way to recover");
605    println!("them after this command exits. The secrets file is your");
606    println!("only durable copy.");
607    println!();
608    println!("Next step: lock the data zone with");
609    println!("  hsm-host lock-data-DANGEROUS");
610    println!("once you have verified the slot contents via `read-config`.");
611    Ok(())
612}
613
614fn cmd_get_pubkey(slot: u8) -> Result<()>
615{
616    let device = device::open()?;
617    let payload = device::send_command
618    (
619        &device,
620        CommandOpcode::GetPubkey.as_u8(),
621        &[slot],
622    )?;
623    if payload.len() != 64
624    {
625        bail!("unexpected GetPubkey payload length: {}", payload.len());
626    }
627    println!("X: {}", hex::encode_upper(&payload[..32]));
628    println!("Y: {}", hex::encode_upper(&payload[32..]));
629    Ok(())
630}
631
632fn cmd_genkey(slot: u8) -> Result<()>
633{
634    let device = device::open()?;
635    let payload = device::send_command
636    (
637        &device,
638        CommandOpcode::GenKey.as_u8(),
639        &[slot],
640    )?;
641    if payload.len() != 64
642    {
643        bail!("unexpected GenKey payload length: {}", payload.len());
644    }
645    println!("New public key for slot {slot}:");
646    println!("X: {}", hex::encode_upper(&payload[..32]));
647    println!("Y: {}", hex::encode_upper(&payload[32..]));
648    Ok(())
649}
650
651fn cmd_sign(slot: u8, challenge: &str) -> Result<()>
652{
653    let digest = parse_hex_array::<32>(challenge, "challenge")?;
654    let mut payload = [0u8; 33];
655    payload[0] = slot;
656    payload[1..].copy_from_slice(&digest);
657
658    let device = device::open()?;
659    println!("touch the dongle within 30s...");
660    let response = device::send_command
661    (
662        &device,
663        CommandOpcode::Sign.as_u8(),
664        &payload,
665    )?;
666    if response.len() != 64
667    {
668        bail!("unexpected Sign response length: {}", response.len());
669    }
670    println!("R: {}", hex::encode_upper(&response[..32]));
671    println!("S: {}", hex::encode_upper(&response[32..]));
672    Ok(())
673}
674
675fn cmd_verify_pin(pin: &str) -> Result<()>
676{
677    let pin_bytes = pin.as_bytes();
678    if pin_bytes.len() != 4
679    {
680        bail!("PIN must be exactly 4 digits, got {}", pin_bytes.len());
681    }
682    let device = device::open()?;
683    device::send_command
684    (
685        &device,
686        CommandOpcode::VerifyPin.as_u8(),
687        pin_bytes,
688    )?;
689    println!("PIN accepted, session opened (30s window).");
690    Ok(())
691}
692
693fn cmd_set_pin(old: &str, new: &str, io_key_hex: &str) -> Result<()>
694{
695    let old_bytes = check_pin(old, "old")?;
696    let new_bytes = check_pin(new, "new")?;
697    let io_key = parse_hex_array::<32>(io_key_hex, "io-key")?;
698
699    // set-pin re-verifies `old` against the chip via CheckMac. No
700    // separate verify-pin is needed before this call. The verify
701    // consumes one Counter0 attempt internally (refreshed on success).
702    let mut payload = [0u8; 4 + 4 + 32];
703    payload[..4].copy_from_slice(&old_bytes);
704    payload[4..8].copy_from_slice(&new_bytes);
705    payload[8..].copy_from_slice(&io_key);
706
707    let device = device::open()?;
708    device::send_command(&device, CommandOpcode::SetPin.as_u8(), &payload)?;
709    println!("PIN changed.");
710    Ok(())
711}
712
713fn cmd_unblock_pin(puk: &str, new_pin: &str, io_key_hex: &str) -> Result<()>
714{
715    let puk_bytes = check_puk(puk)?;
716    let new_pin_bytes = check_pin(new_pin, "new-pin")?;
717    let io_key = parse_hex_array::<32>(io_key_hex, "io-key")?;
718
719    let mut payload = [0u8; 8 + 4 + 32];
720    payload[..8].copy_from_slice(&puk_bytes);
721    payload[8..12].copy_from_slice(&new_pin_bytes);
722    payload[12..].copy_from_slice(&io_key);
723
724    let device = device::open()?;
725    device::send_command(&device, CommandOpcode::UnblockPin.as_u8(), &payload)?;
726    println!("PIN reset via PUK, fresh tries window granted.");
727    Ok(())
728}
729
730fn cmd_set_puk(old: &str, new: &str, io_key_hex: &str) -> Result<()>
731{
732    let old_bytes = check_puk(old)?;
733    let new_bytes = check_puk(new)?;
734    let io_key = parse_hex_array::<32>(io_key_hex, "io-key")?;
735
736    let mut payload = [0u8; 8 + 8 + 32];
737    payload[..8].copy_from_slice(&old_bytes);
738    payload[8..16].copy_from_slice(&new_bytes);
739    payload[16..].copy_from_slice(&io_key);
740
741    let device = device::open()?;
742    device::send_command(&device, CommandOpcode::SetPuk.as_u8(), &payload)?;
743    println!("PUK changed.");
744    Ok(())
745}
746
747fn cmd_emergency_reset_dangerous(io_key_hex: &str) -> Result<()>
748{
749    let io_key = parse_hex_array::<32>(io_key_hex, "io-key")?;
750
751    println!();
752    println!("=== EMERGENCY RESET -- LAST-CHANCE RECOVERY ===");
753    println!();
754    println!("This command is only intended for the case where you have");
755    println!("forgotten BOTH the PIN and the PUK, AND have tried enough times");
756    println!("on each to exhaust both retry batches. The token will refuse");
757    println!("this operation otherwise.");
758    println!();
759    println!("If you go through with it:");
760    println!(" - ALL identity ECC private keys (slots 0..=4 and 7) are");
761    println!("   destroyed. Any signature made under those keys cannot be");
762    println!("   reproduced. Public keys you published become useless.");
763    println!(" - PIN is reset to '0000'.");
764    println!(" - A fresh random PUK is generated and printed ONCE.");
765    println!(" - You get one fresh batch of PIN attempts and one fresh");
766    println!("   batch of PUK attempts. The chip's hardware counters are");
767    println!("   still consumed; you cannot do this indefinitely.");
768    println!();
769    println!("If you remember either the PIN or the PUK, STOP HERE and use");
770    println!("the appropriate command instead:");
771    println!("   PUK known  -> `hsm-host unblock-pin`");
772    println!();
773    confirm_interactive("EMERGENCY-RESET")?;
774
775    let mut payload = [0u8; 4 + 32];
776    payload[..4].copy_from_slice(&EMERGENCY_RESET_MAGIC);
777    payload[4..].copy_from_slice(&io_key);
778
779    let device = device::open()?;
780    let response = device::send_command(
781        &device,
782        CommandOpcode::EmergencyReset.as_u8(),
783        &payload,
784    )?;
785    if response.len() != 8
786    {
787        bail!("unexpected response length: {} (expected 8 for the new PUK)", response.len());
788    }
789    let new_puk = core::str::from_utf8(&response)
790        .context("new PUK is not valid UTF-8")?;
791    println!();
792    println!("Emergency reset complete.");
793    println!(" - All identity keys regenerated (slots 0..=4 and 7).");
794    println!(" - PIN reset to: 0000");
795    println!(" - NEW PUK     : {new_puk}");
796    println!();
797    println!("WRITE THE PUK DOWN NOW. It cannot be retrieved later.");
798    println!("Change the default PIN immediately.");
799    Ok(())
800}
801
802fn cmd_pin_status() -> Result<()>
803{
804    let device = device::open()?;
805    let payload = device::send_command
806    (
807        &device,
808        CommandOpcode::GetPinStatus.as_u8(),
809        &[],
810    )?;
811    if payload.len() != 3
812    {
813        bail!("unexpected PinStatus payload: {} bytes", payload.len());
814    }
815    println!("PIN tries remaining: {}", payload[0]);
816    println!("PUK tries remaining: {}", payload[1]);
817    println!("Session active     : {}", payload[2] != 0);
818    Ok(())
819}
820
821fn cmd_read_counter(id: u8) -> Result<()>
822{
823    if id > 1
824    {
825        bail!("invalid counter id `{id}`: must be 0 (PIN) or 1 (PUK)");
826    }
827    let device = device::open()?;
828    let payload = device::send_command
829    (
830        &device,
831        CommandOpcode::ReadCounter.as_u8(),
832        &[id],
833    )?;
834    if payload.len() != 4
835    {
836        bail!("unexpected ReadCounter payload: {} bytes", payload.len());
837    }
838    // Length was checked above; copy into a fixed array to keep the
839    // call site free of any unreachable `expect` / `unwrap`.
840    let mut bytes = [0u8; 4];
841    bytes.copy_from_slice(&payload[..4]);
842    let value = u32::from_le_bytes(bytes);
843    let name = if id == 0 { "Counter0 (PIN)" } else { "Counter1 (PUK)" };
844    println!("{name} : {value} (0x{value:08X})");
845    println!("Raw bytes (LE)  : {:02X} {:02X} {:02X} {:02X}", bytes[0], bytes[1], bytes[2], bytes[3]);
846    Ok(())
847}
848
849fn cmd_lock_config_dangerous() -> Result<()>
850{
851    let device = device::open()?;
852
853    // Read the full 128-byte configuration zone and compute the CRC the
854    // chip will check at lock time. The chip uses the same algorithm as
855    // every other ATECC command (poly 0x8005, init 0x0000, MSB-first,
856    // no reflect, no XOR-out). Reuse the driver's implementation so the
857    // host and the firmware cannot disagree on what "the CRC" means.
858    let zone = read_full_config_zone(&device)?;
859    let crc = atecc608b::crc::crc16(&zone);
860
861    println!();
862    println!("=== LOCK CONFIG ZONE : IRREVERSIBLE ===");
863    println!();
864    println!("Current configuration zone CRC-16 (full 128 bytes): 0x{crc:04X}");
865    println!();
866    println!("This permanently freezes the configuration zone of the ATECC chip.");
867    println!("Slot policies, key configs, and counters can never be changed again.");
868    println!("The chip will recompute this CRC just before committing and refuse");
869    println!("the lock if it has drifted between this read and the lock command.");
870    println!();
871    confirm_interactive("LOCK-CONFIG")?;
872
873    let mut payload = [0u8; 6];
874    payload[..4].copy_from_slice(&LOCK_CONFIG_MAGIC);
875    payload[4..].copy_from_slice(&crc.to_le_bytes());
876
877    device::send_command(&device, CommandOpcode::LockConfigZone.as_u8(), &payload)?;
878    println!("Config zone locked.");
879    Ok(())
880}
881
882fn cmd_lock_data_dangerous() -> Result<()>
883{
884    println!();
885    println!("=== LOCK DATA ZONE : IRREVERSIBLE ===");
886    println!();
887    println!("This permanently freezes the data zone. Slots can no longer be");
888    println!("written in cleartext. Only the encrypted-write protocol against");
889    println!("the IO key (slot 8) remains, and only for slots whose WriteConfig");
890    println!("allows it. Make sure provisioning is complete (PIN hash, PUK hash,");
891    println!("IO key, identity keys) before doing this.");
892    println!();
893    println!("No CRC is checked at lock time: secret-bearing slots (IsSecret=1)");
894    println!("cannot be read back to compute one. The confirmation below is the");
895    println!("only safety beyond the magic-word check in the firmware.");
896    println!();
897    confirm_interactive("LOCK-DATA")?;
898
899    let mut payload = [0u8; 4];
900    payload.copy_from_slice(&LOCK_DATA_MAGIC);
901
902    let device = device::open()?;
903    device::send_command
904    (
905        &device,
906        CommandOpcode::LockDataZone.as_u8(),
907        &payload,
908    )?;
909    println!("Data zone locked.");
910    Ok(())
911}
912
913fn cmd_lock_slot_dangerous(slot: u8) -> Result<()>
914{
915    println!();
916    println!("=== LOCK SLOT {slot} : IRREVERSIBLE ===");
917    println!();
918    println!("Slot {slot} will no longer accept writes, even via the encrypted");
919    println!("write protocol. The current contents are frozen for life.");
920    println!();
921    confirm_interactive(&format!("LOCK-SLOT-{slot}"))?;
922
923    let mut payload = [0u8; 5];
924    payload[..4].copy_from_slice(&LOCK_SLOT_MAGIC);
925    payload[4] = slot;
926
927    let device = device::open()?;
928    device::send_command(&device, CommandOpcode::LockSlot.as_u8(), &payload)?;
929    println!("Slot {slot} locked.");
930    Ok(())
931}
932
933fn check_pin(pin: &str, name: &str) -> Result<[u8; 4]>
934{
935    if pin.len() != 4
936    {
937        bail!("{name} PIN must be 4 digits, got {}", pin.len());
938    }
939    let mut out = [0u8; 4];
940    out.copy_from_slice(pin.as_bytes());
941    Ok(out)
942}
943
944fn check_puk(puk: &str) -> Result<[u8; 8]>
945{
946    if puk.len() != 8
947    {
948        bail!("PUK must be 8 digits, got {}", puk.len());
949    }
950    let mut out = [0u8; 8];
951    out.copy_from_slice(puk.as_bytes());
952    Ok(out)
953}
954
955fn parse_hex_array<const N: usize>(s: &str, name: &str) -> Result<[u8; N]>
956{
957    let s = s.strip_prefix("0x").unwrap_or(s);
958    let bytes = hex::decode(s)
959        .with_context(|| format!("{name} is not valid hex"))?;
960    if bytes.len() != N
961    {
962        bail!("{name} must be {N} bytes ({} hex chars), got {} bytes", N * 2, bytes.len());
963    }
964    let mut out = [0u8; N];
965    out.copy_from_slice(&bytes);
966    Ok(out)
967}
968
969/// Print a confirmation prompt and read a line from stdin. Returns OK
970/// only if the line matches `expected` exactly. Anything else aborts
971/// with an error.
972fn confirm_interactive(expected: &str) -> Result<()>
973{
974    print!("Type '{expected}' to confirm, anything else to abort: ");
975    io::stdout().flush().ok();
976    let mut buf = String::new();
977    io::stdin().lock().read_line(&mut buf).context("failed to read stdin")?;
978    let trimmed = buf.trim();
979    if trimmed != expected
980    {
981        bail!("confirmation did not match (got {trimmed:?}), aborting");
982    }
983    Ok(())
984}