From 805fabfb1b53d9fd141ec8f5e1cc9884047be3da Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Wed, 25 Feb 2026 19:32:44 -0500 Subject: [PATCH] feat: add hw sub commands --- Cargo.lock | 102 +++++++++++ Cargo.toml | 5 + README.md | 50 +++++- src/fthr.rs | 160 +++++++++++++++++ src/main.rs | 371 ++++++++++++++++++++++++++++++++------ src/protocol.rs | 83 +-------- src/serial.rs | 93 ++++++++++ src/ti.rs | 466 ++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1192 insertions(+), 138 deletions(-) create mode 100644 src/fthr.rs create mode 100644 src/serial.rs create mode 100644 src/ti.rs diff --git a/Cargo.lock b/Cargo.lock index ac1ef9a..fa299c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -122,13 +131,57 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "ectf-tools" version = "0.1.0" dependencies = [ "anyhow", "clap", + "crc32fast", + "hmac", "libc", + "serde", + "serde_json", + "sha2", "uuid", ] @@ -144,6 +197,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.1" @@ -178,6 +241,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -307,6 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -342,12 +415,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -359,6 +449,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -388,6 +484,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 3bd6642..58dcc03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,8 @@ clap = { version = "4", features = ["derive"] } uuid = { version = "1", features = ["v4"] } anyhow = "1" libc = "0.2" +crc32fast = "1" +hmac = "0.12" +sha2 = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/README.md b/README.md index 2399b4d..5f7eba7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rust-ectf-tools -Drop-in replacement for MITRE's `uvx ectf tools` host tools, rewritten in Rust with reliable serial I/O. Uses raw termios instead of pyserial to avoid macOS CDC-ACM data corruption bugs. +Drop-in replacement for MITRE's `uvx ectf` CLI, rewritten in Rust with reliable serial I/O. Uses raw termios instead of pyserial to avoid macOS CDC-ACM data corruption bugs. ## Usage @@ -8,24 +8,60 @@ Drop-in replacement for MITRE's `uvx ectf tools` host tools, rewritten in Rust w cargo build --release ``` +### HSM Host Tools + ```bash # List files on the HSM -./target/release/ectf-tools /dev/tty.usbmodemXXX list 1a2b3c +ectf-tools tools /dev/tty.usbmodemXXX list 1a2b3c # Write a file -./target/release/ectf-tools /dev/tty.usbmodemXXX write 1a2b3c 0 0x4321 myfile.bin +ectf-tools tools /dev/tty.usbmodemXXX write 1a2b3c 0 0x4321 myfile.bin # Read a file -./target/release/ectf-tools /dev/tty.usbmodemXXX read 1a2b3c 1 ./output/ +ectf-tools tools /dev/tty.usbmodemXXX read 1a2b3c 1 ./output/ # Interrogate a connected HSM -./target/release/ectf-tools /dev/tty.usbmodemXXX interrogate 1a2b3c +ectf-tools tools /dev/tty.usbmodemXXX interrogate 1a2b3c # Listen for another HSM -./target/release/ectf-tools /dev/tty.usbmodemXXX listen +ectf-tools tools /dev/tty.usbmodemXXX listen # Receive a file from another HSM -./target/release/ectf-tools /dev/tty.usbmodemXXX receive 1a2b3c 0 1 +ectf-tools tools /dev/tty.usbmodemXXX receive 1a2b3c 0 1 +``` + +### Hardware Bootloader Tools (MSPM0L2228) + +```bash +# Check bootloader version and status +ectf-tools hw /dev/tty.usbmodemXXX status + +# Erase the current design +ectf-tools hw /dev/tty.usbmodemXXX erase + +# Flash an image (name auto-derived from filename for unprotected images) +ectf-tools hw /dev/tty.usbmodemXXX flash design.bin +ectf-tools hw /dev/tty.usbmodemXXX flash design.bin --name mydesign + +# Start the flashed design +ectf-tools hw /dev/tty.usbmodemXXX start + +# Erase + flash + start in one step (file or directory with hsm.bin) +ectf-tools hw /dev/tty.usbmodemXXX reflash ./build/ +ectf-tools hw /dev/tty.usbmodemXXX reflash engineer.hsm/hsm.bin + +# Get a file digest from the secure bootloader +ectf-tools hw /dev/tty.usbmodemXXX digest 0 +``` + +### Hardware Bootloader Tools (MAX78000FTHR) + +```bash +# Flash a design +ectf-tools hw /dev/tty.usbmodemXXX flash-fthr /dev/tty.usbmodemYYY image.bin + +# Permanently unlock the secure bootloader (irreversible!) +ectf-tools hw /dev/tty.usbmodemXXX unlock-fthr /dev/tty.usbmodemYYY secrets.json --force --force ``` ### Verbosity diff --git a/src/fthr.rs b/src/fthr.rs new file mode 100644 index 0000000..783fb58 --- /dev/null +++ b/src/fthr.rs @@ -0,0 +1,160 @@ +use std::collections::HashSet; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Write}; + +use anyhow::{Result, bail}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::log; +use crate::serial::open_serial; + +const PAGE_SIZE: usize = 8192; +const APP_PAGES: usize = 28; +const TOTAL_SIZE: usize = APP_PAGES * PAGE_SIZE; +const BLOCK_SIZE: usize = 16; +const COMPLETE_CODE: u8 = 20; + +fn success_codes() -> HashSet { + [1, 2, 3, 4, 5, 7, 8, 10, 11, 13, 16, 18, 19, COMPLETE_CODE] + .into_iter() + .collect() +} + +fn error_codes() -> HashSet { + [6, 9, 12, 14, 15, 17].into_iter().collect() +} + +pub struct MAX78000FTHR { + file: File, +} + +impl MAX78000FTHR { + pub fn open(port: &str) -> Result { + let file = open_serial(port, Some(5.0))?; + Ok(Self { file }) + } + + fn verify_resp(&mut self) -> Result { + let success = success_codes(); + let errors = error_codes(); + + loop { + let mut buf = [0u8; 1]; + match self.file.read(&mut buf) { + Ok(0) => continue, // timeout, retry + Ok(_) => {} + Err(e) => return Err(e.into()), + } + let resp = buf[0]; + if errors.contains(&resp) { + bail!("Bootloader error response: {resp}"); + } + if !success.contains(&resp) { + bail!("Unexpected bootloader response: {resp}"); + } + return Ok(resp); + } + } + + pub fn flash(&mut self, image: &[u8]) -> Result<()> { + // Pad to TOTAL_SIZE + let mut padded = image.to_vec(); + if padded.len() < TOTAL_SIZE { + padded.resize(TOTAL_SIZE, 0xFF); + } + + // Send update command + log::info("Requesting update"); + self.file.write_all(&[0x00])?; + self.file.flush()?; + + self.verify_resp()?; + self.verify_resp()?; + + // Send image in BLOCK_SIZE chunks + log::info("Sending image data..."); + let total_blocks = padded.len() / BLOCK_SIZE; + for (i, chunk) in padded.chunks(BLOCK_SIZE).enumerate() { + self.file.write_all(chunk)?; + self.file.flush()?; + self.verify_resp()?; + + // Print progress every 10% + let pct = (i + 1) * 100 / total_blocks; + let prev_pct = i * 100 / total_blocks; + if pct / 10 > prev_pct / 10 { + log::info(&format!(" {pct}%")); + } + } + + log::info("Waiting for installation..."); + loop { + if self.verify_resp()? == COMPLETE_CODE { + break; + } + } + + log::success("Update complete"); + Ok(()) + } + + pub fn unlock(&mut self, secrets: &serde_json::Value) -> Result<()> { + let challenge_key = secrets + .get("challenge_key") + .and_then(|v| v.as_str()) + .context("Missing 'challenge_key' in secrets")?; + let challenge_key = hex_decode(challenge_key)?; + + // Send challenge request + self.file.write_all(b"GC\r\n")?; + self.file.flush()?; + + // Read two lines: echo + challenge hex + let mut reader = BufReader::new(&mut self.file); + let mut _echo = String::new(); + reader.read_line(&mut _echo)?; + let mut challenge_hex = String::new(); + reader.read_line(&mut challenge_hex)?; + let challenge_bytes = hex_decode(challenge_hex.trim())?; + + log::info(&format!("Challenge: {}", hex_encode(&challenge_bytes))); + + // Compute HMAC-SHA256 + type HmacSha256 = Hmac; + let mut mac = + HmacSha256::new_from_slice(&challenge_key).map_err(|e| anyhow::anyhow!("{e}"))?; + mac.update(&challenge_bytes); + let result = mac.finalize().into_bytes(); + let response_hex = hex_encode(&result).to_uppercase(); + + log::info(&format!("Response: {response_hex}")); + + // Send response + unlock + let resp_line = format!("SR {response_hex}\r\n"); + // Get the inner file back from BufReader + let file = reader.into_inner(); + file.write_all(resp_line.as_bytes())?; + file.write_all(b"UNLOCK\r\n")?; + file.flush()?; + + log::success("Unlock command sent"); + Ok(()) + } +} + +use anyhow::Context; + +fn hex_decode(s: &str) -> Result> { + if s.len() % 2 != 0 { + bail!("Hex string must have even length"); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(Into::into)) + .collect() +} + +fn hex_encode(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/main.rs b/src/main.rs index 69fcee3..a64e627 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,83 +1,177 @@ +mod fthr; mod log; mod protocol; +mod serial; +mod ti; use std::fs; use std::path::PathBuf; use anyhow::{Context, Result, bail}; +use clap::builder::styling::{AnsiColor, Effects, Styles}; use clap::{Parser, Subcommand}; use protocol::{HSMIntf, Opcode}; +const STYLES: Styles = Styles::styled() + .header(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .placeholder(AnsiColor::Cyan.on_default()) + .valid(AnsiColor::Green.on_default()) + .invalid(AnsiColor::Red.on_default()) + .error(AnsiColor::Red.on_default().effects(Effects::BOLD)); + const PIN_LEN: usize = 6; const MAX_NAME_LEN: usize = 32; const MAX_FILE_LEN: usize = 8192; const UUID_LEN: usize = 16; #[derive(Parser)] -#[command(name = "ectf-tools", about = "eCTF host tools")] +#[command( + name = "ectf-tools", + about = "eCTF host tools — Rust reimplementation", + long_about = "Drop-in replacement for MITRE's ectf CLI.\n\ + Reliable serial I/O using raw termios instead of pyserial.", + styles = STYLES, + arg_required_else_help = true, +)] struct Cli { - /// Serial port - port: String, - - /// Verbosity level (-v, -vv) + /// Verbosity (-v for debug, -vv for trace w/ hexdump) #[arg(short, long, action = clap::ArgAction::Count, global = true)] verbose: u8, #[command(subcommand)] - command: Command, + command: TopLevel, } #[derive(Subcommand)] -enum Command { +enum TopLevel { + /// Interact with the HSM filesystem + #[command(arg_required_else_help = true)] + Tools { + /// Serial port (e.g. /dev/tty.usbmodemXXX) + port: String, + #[command(subcommand)] + command: ToolsCmd, + }, + /// Manage the hardware bootloader + #[command(arg_required_else_help = true)] + Hw { + /// Serial port (e.g. /dev/tty.usbmodemXXX) + port: String, + #[command(subcommand)] + command: HwCmd, + }, +} + +// ─── Tools subcommands ─── + +#[derive(Subcommand)] +enum ToolsCmd { /// Write a file to the HSM + #[command(long_about = "Write a host file into an HSM slot, protected by PIN and group ID.")] Write { - /// 6-digit PIN + /// 6-char hex PIN pin: String, - /// Slot (0-7) + /// Slot number (0-7) slot: u8, - /// Group ID (decimal or 0x hex) + /// Group ID (decimal or 0xHEX) gid: String, - /// Path to file to write + /// File to write file: PathBuf, - /// UUID (hex, 32 chars). Random if omitted. + /// UUID hex string (random if omitted) #[arg(short, long)] uuid: Option, }, /// Read a file from the HSM Read { - /// 6-digit PIN + /// 6-char hex PIN pin: String, - /// Slot (0-7) + /// Slot number (0-7) slot: u8, - /// Output directory + /// Directory to write the file into output_dir: PathBuf, - /// Overwrite existing file + /// Overwrite if file exists #[arg(short, long)] force: bool, }, - /// List files on the HSM + /// List files stored on the HSM List { - /// 6-digit PIN + /// 6-char hex PIN pin: String, }, - /// Interrogate files on a connected HSM + /// Interrogate files on a connected remote HSM Interrogate { - /// 6-digit PIN + /// 6-char hex PIN pin: String, }, - /// Alert the HSM to listen for another HSM + /// Put the HSM into listen mode for file transfer Listen, /// Receive a file from another HSM Receive { - /// 6-digit PIN + /// 6-char hex PIN pin: String, - /// Read slot (0-7) + /// Source slot on the remote HSM (0-7) read_slot: u8, - /// Write slot (0-7) + /// Destination slot on this HSM (0-7) write_slot: u8, }, } +// ─── HW subcommands ─── + +#[derive(Subcommand)] +enum HwCmd { + /// Get bootloader status [MSPM0L2228] + Status, + /// Erase the current design [MSPM0L2228] + Erase, + /// Flash a design image [MSPM0L2228] + Flash { + /// Image file (.bin or .hsm) + infile: PathBuf, + /// Binary name (required for unprotected images) + #[arg(short, long)] + name: Option, + }, + /// Start the flashed design [MSPM0L2228] + Start, + /// Erase + flash + start in one step [MSPM0L2228] + Reflash { + /// Image file, or directory containing hsm.bin + infile: PathBuf, + /// Binary name (required for unprotected images) + #[arg(short, long)] + name: Option, + }, + /// Get a file digest from the secure bootloader [MSPM0L2228] + Digest { + /// File slot number + slot: u8, + }, + /// Flash a design image [MAX78000FTHR] + FlashFthr { + /// FTHR serial port + fthr_port: String, + /// Image file to flash + infile: PathBuf, + }, + /// Permanently unlock the secure bootloader [MAX78000FTHR] + #[command(long_about = "Permanently unlock the MAX78000FTHR secure bootloader.\n\ + This is irreversible! Requires --force --force to confirm.")] + UnlockFthr { + /// FTHR serial port + fthr_port: String, + /// Bootloader secrets JSON file + secrets: PathBuf, + /// Confirm (must pass twice: --force --force) + #[arg(short, long, action = clap::ArgAction::Count)] + force: u8, + }, +} + +// ─── Helpers ─── + fn parse_gid(s: &str) -> Result { let val = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { u16::from_str_radix(hex, 16)? @@ -89,7 +183,10 @@ fn parse_gid(s: &str) -> Result { fn validate_pin(pin: &str) -> Result<()> { if pin.len() != PIN_LEN { - bail!("PIN must be exactly {PIN_LEN} characters, got {}", pin.len()); + bail!( + "PIN must be exactly {PIN_LEN} characters, got {}", + pin.len() + ); } if !pin.chars().all(|c| c.is_ascii_hexdigit()) { bail!("PIN must contain only hex digits (0-9, a-f, A-F)"); @@ -126,7 +223,10 @@ fn unpack_files(body: &[u8]) -> Result> { let group_id = u16::from_le_bytes(entries[off + 1..off + 3].try_into().unwrap()); let name_bytes = &entries[off + 3..off + 3 + MAX_NAME_LEN]; let name = String::from_utf8_lossy( - &name_bytes[..name_bytes.iter().position(|&b| b == 0).unwrap_or(MAX_NAME_LEN)], + &name_bytes[..name_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(MAX_NAME_LEN)], ) .into_owned(); files.push((slot, group_id, name)); @@ -134,6 +234,25 @@ fn unpack_files(body: &[u8]) -> Result> { Ok(files) } +/// Derive an image name from a file path (file stem, truncated to 8 bytes). +fn infer_image_name(path: &PathBuf) -> Option { + path.file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.chars().take(8).collect()) +} + +fn hex_decode(s: &str) -> Result> { + if s.len() % 2 != 0 { + bail!("Hex string must have even length"); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(Into::into)) + .collect() +} + +// ─── Main ─── + fn main() { let cli = Cli::parse(); log::set_verbosity(cli.verbose); @@ -149,10 +268,19 @@ fn main() { } fn run(cli: Cli) -> Result<()> { - let mut hsm = HSMIntf::open(&cli.port).context("Failed to open serial port")?; - match cli.command { - Command::Write { + TopLevel::Tools { port, command } => run_tools(&port, command), + TopLevel::Hw { port, command } => run_hw(&port, command), + } +} + +// ─── Tools commands ─── + +fn run_tools(port: &str, cmd: ToolsCmd) -> Result<()> { + let mut hsm = HSMIntf::open(port).context("Failed to open serial port")?; + + match cmd { + ToolsCmd::Write { pin, slot, gid, @@ -166,14 +294,11 @@ fn run(cli: Cli) -> Result<()> { let uuid_bytes: [u8; UUID_LEN] = match uuid { Some(hex) => { let bytes = hex_decode(&hex).context("Invalid UUID hex")?; - bytes - .try_into() - .map_err(|v: Vec| anyhow::anyhow!("UUID must be 16 bytes, got {}", v.len()))? - } - None => { - let id = uuid::Uuid::new_v4(); - *id.as_bytes() + bytes.try_into().map_err(|v: Vec| { + anyhow::anyhow!("UUID must be 16 bytes, got {}", v.len()) + })? } + None => *uuid::Uuid::new_v4().as_bytes(), }; let contents = fs::read(&file).context("Failed to read input file")?; @@ -196,7 +321,6 @@ fn run(cli: Cli) -> Result<()> { } name_buf[..name_bytes.len()].copy_from_slice(name_bytes); - // Pack frame: pin(6) + slot(1) + gid(2) + name(32) + uuid(16) + contents_len(2) + contents let mut frame = Vec::with_capacity(59 + contents.len()); frame.extend_from_slice(pin.as_bytes()); frame.push(slot); @@ -210,7 +334,7 @@ fn run(cli: Cli) -> Result<()> { log::success("Write successful"); } - Command::Read { + ToolsCmd::Read { pin, slot, output_dir, @@ -219,7 +343,6 @@ fn run(cli: Cli) -> Result<()> { validate_pin(&pin)?; validate_slot(slot)?; - // Pack frame: pin(6) + slot(1) let mut frame = Vec::with_capacity(7); frame.extend_from_slice(pin.as_bytes()); frame.push(slot); @@ -240,7 +363,10 @@ fn run(cli: Cli) -> Result<()> { let full_path = output_dir.join(name.as_ref()); if !force && full_path.exists() { - bail!("File {} already exists (use --force to overwrite)", full_path.display()); + bail!( + "File {} already exists (use --force to overwrite)", + full_path.display() + ); } fs::create_dir_all(&output_dir)?; fs::write(&full_path, contents)?; @@ -250,32 +376,36 @@ fn run(cli: Cli) -> Result<()> { )); } - Command::List { pin } => { + ToolsCmd::List { pin } => { validate_pin(&pin)?; let resp = hsm.send_respond(Opcode::List, pin.as_bytes())?; let files = unpack_files(&resp.body)?; for (slot, group_id, name) in &files { - log::info(&format!("Found file: Slot {slot:x}, Group {group_id:x}, {name}")); + log::info(&format!( + "Found file: Slot {slot:x}, Group {group_id:x}, {name}" + )); } log::success("List successful"); } - Command::Interrogate { pin } => { + ToolsCmd::Interrogate { pin } => { validate_pin(&pin)?; let resp = hsm.send_respond(Opcode::Interrogate, pin.as_bytes())?; let files = unpack_files(&resp.body)?; for (slot, group_id, name) in &files { - log::info(&format!("Found remote file: Slot {slot:x}, Group {group_id:x}, {name}")); + log::info(&format!( + "Found remote file: Slot {slot:x}, Group {group_id:x}, {name}" + )); } log::success("Interrogate successful"); } - Command::Listen => { + ToolsCmd::Listen => { hsm.send_respond(Opcode::Listen, &[])?; log::success("Listen successful"); } - Command::Receive { + ToolsCmd::Receive { pin, read_slot, write_slot, @@ -284,26 +414,157 @@ fn run(cli: Cli) -> Result<()> { validate_slot(read_slot)?; validate_slot(write_slot)?; - // Pack frame: pin(6) + read_slot(1) + write_slot(1) let mut frame = Vec::with_capacity(8); frame.extend_from_slice(pin.as_bytes()); frame.push(read_slot); frame.push(write_slot); hsm.send_respond(Opcode::Receive, &frame)?; - log::success(&format!("Receive successful. Wrote file to local slot {write_slot}")); + log::success(&format!( + "Receive successful. Wrote file to local slot {write_slot}" + )); } } Ok(()) } -fn hex_decode(s: &str) -> Result> { - if s.len() % 2 != 0 { - bail!("Hex string must have even length"); +// ─── HW commands ─── + +fn run_hw(port: &str, cmd: HwCmd) -> Result<()> { + match cmd { + HwCmd::Status => { + let mut board = + ti::MSPM0L2228::open(port).context("Failed to open serial port")?; + board.connect()?; + let status = board.status()?; + + log::success("Successfully got bootloader status:"); + log::success(&format!( + " - Version: {}.{}.{}", + status.year, status.major_version, status.minor_version + )); + log::success(&format!( + " - Secure bootloader: {}", + status.secure != 0 + )); + match &status.installed { + Some(name) => log::success(&format!( + " - Installed design: {}", + String::from_utf8_lossy(name) + )), + None => log::success(" - No design installed"), + } + if status.app_clear == 0 && status.app_ready == 0 { + log::warning("Bootloader in unstable state and needs to be erased!"); + } + } + + HwCmd::Erase => { + let mut board = + ti::MSPM0L2228::open(port).context("Failed to open serial port")?; + board.connect()?; + board.erase()?; + log::success("Bootloader erased successfully. The LED should be flashing now"); + } + + HwCmd::Flash { infile, name } => { + if infile.extension().is_some_and(|e| e == "elf") { + bail!("Do not flash the .elf file. It's likely you are looking for the .bin file"); + } + let raw = fs::read(&infile).context("Failed to read image file")?; + let name = name.or_else(|| infer_image_name(&infile)); + let image = ti::Image::deserialize(&raw, name.as_deref())?; + log::info(&format!("Flashing design {}", image.name)); + + let mut board = + ti::MSPM0L2228::open(port).context("Failed to open serial port")?; + board.flash(&image)?; + log::success( + "Design was successfully flashed. Send start command or reboot to launch new design", + ); + } + + HwCmd::Start => { + log::info("Starting design"); + let mut board = + ti::MSPM0L2228::open(port).context("Failed to open serial port")?; + board.connect()?; + board.start()?; + log::success("Loaded image should be running now."); + log::success("Reset while holding S2/PB21 to return to bootloader mode."); + } + + HwCmd::Reflash { infile, name } => { + let path = if infile.is_dir() { + infile.join("hsm.bin") + } else { + infile + }; + let raw = fs::read(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let name = name.or_else(|| infer_image_name(&path)); + let image = ti::Image::deserialize(&raw, name.as_deref())?; + + log::info("Reflashing design"); + let mut board = + ti::MSPM0L2228::open(port).context("Failed to open serial port")?; + board.connect()?; + log::info("Erasing old design"); + board.erase()?; + log::info("Flashing new design"); + board.flash(&image)?; + log::info("Starting new design"); + board.start()?; + log::success("Loaded image should be running now."); + log::success("Reset while holding S2/PB21 to return to bootloader mode."); + } + + HwCmd::Digest { slot } => { + log::info("Requesting digest"); + let mut board = + ti::MSPM0L2228::open(port).context("Failed to open serial port")?; + board.connect()?; + let digest = board.digest(slot)?; + log::success(&format!("Successfully retrieved digest for slot {slot}.")); + log::success("Submit the following to the API:"); + let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + log::success(&format!(" {hex}")); + } + + HwCmd::FlashFthr { fthr_port, infile } => { + let raw = fs::read(&infile).context("Failed to read image file")?; + let mut fthr = + fthr::MAX78000FTHR::open(&fthr_port).context("Failed to open FTHR serial port")?; + fthr.flash(&raw)?; + } + + HwCmd::UnlockFthr { + fthr_port, + secrets, + force, + } => { + if force < 2 { + let msg = if force == 0 { + "Unlocking the board is permanent. You will no longer be able to use \ + the board to load protected binaries.\n\n\ + Run again with --force to continue" + } else { + "Unlocking the board is permanent. You will no longer be able to use \ + the board to load protected binaries.\n\n\ + THIS IS YOUR LAST CHANCE TO TURN BACK!\n\n\ + Run again with --force --force to continue" + }; + bail!("{msg}"); + } + let raw = fs::read_to_string(&secrets).context("Failed to read secrets file")?; + let secrets: serde_json::Value = + serde_json::from_str(&raw).context("Invalid JSON in secrets file")?; + let mut fthr = + fthr::MAX78000FTHR::open(&fthr_port).context("Failed to open FTHR serial port")?; + fthr.unlock(&secrets)?; + } } - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(Into::into)) - .collect() + + Ok(()) } diff --git a/src/protocol.rs b/src/protocol.rs index 04f871a..d009ab4 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,10 +1,10 @@ use std::fs::File; -use std::io::{self, Read, Write}; -use std::os::fd::FromRawFd; +use std::io::{Read, Write}; use anyhow::{Result, bail}; use crate::log; +use crate::serial::open_serial; const MAGIC: u8 = 0x25; // '%' const BLOCK_LEN: usize = 256; @@ -57,70 +57,7 @@ pub struct HSMIntf { impl HSMIntf { pub fn open(port: &str) -> Result { - // Open with O_NONBLOCK to avoid blocking on carrier detect (macOS CDC-ACM). - // Then clear O_NONBLOCK so subsequent reads are blocking. - let c_port = std::ffi::CString::new(port)?; - let fd = unsafe { libc::open(c_port.as_ptr(), libc::O_RDWR | libc::O_NOCTTY | libc::O_NONBLOCK) }; - if fd < 0 { - bail!("Failed to open {port}: {}", io::Error::last_os_error()); - } - - // Clear O_NONBLOCK now that we're past the open - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) < 0 { - libc::close(fd); - bail!("Failed to clear O_NONBLOCK: {}", io::Error::last_os_error()); - } - } - - let file = unsafe { File::from_raw_fd(fd) }; - - // Configure termios for raw serial at 115200 - unsafe { - let mut termios: libc::termios = std::mem::zeroed(); - if libc::tcgetattr(fd, &mut termios) != 0 { - bail!("tcgetattr failed: {}", io::Error::last_os_error()); - } - - // Input flags: disable all processing - termios.c_iflag &= !(libc::IGNBRK - | libc::BRKINT - | libc::PARMRK - | libc::ISTRIP - | libc::INLCR - | libc::IGNCR - | libc::ICRNL - | libc::IXON - | libc::IXOFF - | libc::IXANY); - - // Output flags: disable all processing - termios.c_oflag &= !libc::OPOST; - - // Control flags: 8N1, no flow control - termios.c_cflag &= !(libc::CSIZE | libc::PARENB | libc::CSTOPB | libc::CRTSCTS); - termios.c_cflag |= libc::CS8 | libc::CLOCAL | libc::CREAD; - - // Local flags: raw mode - termios.c_lflag &= !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN); - - // Blocking read: VMIN=1, VTIME=0 - termios.c_cc[libc::VMIN] = 1; - termios.c_cc[libc::VTIME] = 0; - - // Set baud rate to 115200 - libc::cfsetispeed(&mut termios, libc::B115200); - libc::cfsetospeed(&mut termios, libc::B115200); - - if libc::tcsetattr(fd, libc::TCSAFLUSH, &termios) != 0 { - bail!("tcsetattr failed: {}", io::Error::last_os_error()); - } - - // Flush input buffer (the MITRE tool doesn't do this — we should) - libc::tcflush(fd, libc::TCIFLUSH); - } - + let file = open_serial(port, None)?; Ok(Self { file, stream: Vec::new(), @@ -167,11 +104,8 @@ impl HSMIntf { Ok(()) } - /// Try to parse a header from the internal stream buffer. - /// Returns Some((opcode, size)) if found, consuming up through the header. fn try_parse_header(&mut self) -> Option<(Opcode, u16)> { if let Some(pos) = self.stream.iter().position(|&b| b == MAGIC) { - // Need at least 3 more bytes after magic if pos + 4 <= self.stream.len() { let opc = self.stream[pos + 1]; let size = u16::from_le_bytes([self.stream[pos + 2], self.stream[pos + 3]]); @@ -184,9 +118,7 @@ impl HSMIntf { None } - /// Read a raw message from the device (may be DEBUG, ACK, ERROR, or data). fn get_raw_msg(&mut self) -> Result { - // Read bytes until we can parse a header let (opcode, size) = loop { if let Some(hdr) = self.try_parse_header() { break hdr; @@ -201,7 +133,6 @@ impl HSMIntf { self.send_ack()?; } - // Read body in BLOCK_LEN chunks let mut body = Vec::with_capacity(size as usize); let mut remaining = size as usize; while remaining > 0 { @@ -218,7 +149,6 @@ impl HSMIntf { Ok(Message { opcode, body }) } - /// Read a message, filtering out DEBUG and raising on ERROR. fn get_msg(&mut self) -> Result { loop { let msg = self.get_raw_msg()?; @@ -239,10 +169,12 @@ impl HSMIntf { } } - /// Send a message with ACK flow control (header + chunked body). fn send_msg(&mut self, opcode: Opcode, body: &[u8]) -> Result<()> { let hdr = Self::pack_header(opcode, body.len() as u16); - log::debug(&format!("Sending header: opcode={opcode:?}, size={}", body.len())); + log::debug(&format!( + "Sending header: opcode={opcode:?}, size={}", + body.len() + )); self.write_all(&hdr)?; self.get_ack()?; @@ -256,7 +188,6 @@ impl HSMIntf { Ok(()) } - /// Send a command and wait for the matching response. pub fn send_respond(&mut self, opcode: Opcode, body: &[u8]) -> Result { self.send_msg(opcode, body)?; let resp = self.get_msg()?; diff --git a/src/serial.rs b/src/serial.rs new file mode 100644 index 0000000..191e47c --- /dev/null +++ b/src/serial.rs @@ -0,0 +1,93 @@ +use std::fs::File; +use std::io; +use std::os::fd::FromRawFd; + +use anyhow::{Result, bail}; + +/// Open and configure a serial port with raw termios at 115200 8N1. +/// +/// - `timeout`: read timeout in seconds. `None` = blocking (VMIN=1, VTIME=0). +/// `Some(t)` = VMIN=0, VTIME in deciseconds. +pub fn open_serial(port: &str, timeout: Option) -> Result { + // Open with O_NONBLOCK to avoid blocking on carrier detect (macOS CDC-ACM). + let c_port = std::ffi::CString::new(port)?; + let fd = unsafe { + libc::open( + c_port.as_ptr(), + libc::O_RDWR | libc::O_NOCTTY | libc::O_NONBLOCK, + ) + }; + if fd < 0 { + bail!("Failed to open {port}: {}", io::Error::last_os_error()); + } + + // Clear O_NONBLOCK so reads are blocking + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) < 0 { + libc::close(fd); + bail!( + "Failed to clear O_NONBLOCK: {}", + io::Error::last_os_error() + ); + } + } + + let file = unsafe { File::from_raw_fd(fd) }; + + unsafe { + let mut termios: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut termios) != 0 { + bail!("tcgetattr failed: {}", io::Error::last_os_error()); + } + + // Input flags: disable all processing + termios.c_iflag &= !(libc::IGNBRK + | libc::BRKINT + | libc::PARMRK + | libc::ISTRIP + | libc::INLCR + | libc::IGNCR + | libc::ICRNL + | libc::IXON + | libc::IXOFF + | libc::IXANY); + + // Output flags: disable all processing + termios.c_oflag &= !libc::OPOST; + + // Control flags: 8N1, no flow control + termios.c_cflag &= !(libc::CSIZE | libc::PARENB | libc::CSTOPB | libc::CRTSCTS); + termios.c_cflag |= libc::CS8 | libc::CLOCAL | libc::CREAD; + + // Local flags: raw mode + termios.c_lflag &= + !(libc::ECHO | libc::ECHONL | libc::ICANON | libc::ISIG | libc::IEXTEN); + + // Timeout configuration + match timeout { + None => { + termios.c_cc[libc::VMIN] = 1; + termios.c_cc[libc::VTIME] = 0; + } + Some(secs) => { + termios.c_cc[libc::VMIN] = 0; + // VTIME is in deciseconds (1/10 sec), minimum 1 + termios.c_cc[libc::VTIME] = ((secs * 10.0) as u8).max(1); + } + } + + // Baud rate 115200 + libc::cfsetispeed(&mut termios, libc::B115200); + libc::cfsetospeed(&mut termios, libc::B115200); + + if libc::tcsetattr(fd, libc::TCSAFLUSH, &termios) != 0 { + bail!("tcsetattr failed: {}", io::Error::last_os_error()); + } + + // Flush stale input + libc::tcflush(fd, libc::TCIFLUSH); + } + + Ok(file) +} diff --git a/src/ti.rs b/src/ti.rs new file mode 100644 index 0000000..4889e79 --- /dev/null +++ b/src/ti.rs @@ -0,0 +1,466 @@ +use std::fs::File; +use std::io::{Read, Write}; +use std::time::Instant; + +use anyhow::{Context, Result, bail}; + +use crate::log; +use crate::serial::open_serial; + +const CRC_SIZE: usize = 4; +const NAME_SIZE: usize = 8; +const PKT_HDR: u8 = 0x80; +const RESP_HDR: u8 = 0x08; + +pub const SECTOR_SIZE: usize = 1024; +pub const CHUNK_SIZE: usize = 0x2C00; // 11264 + +/// CRC-32/JAMCRC = bitwise NOT of standard CRC-32 +fn crc32_jamcrc(data: &[u8]) -> u32 { + !crc32fast::hash(data) +} + +// --- Command types --- + +#[derive(Debug, Clone, Copy)] +#[repr(u8)] +enum Cmd { + Connection = b'C', + Identity = b'I', + Erase = b'E', + Update = b'U', + Program = b'P', + Verify = b'V', + Digest = b'D', + Start = b'S', +} + +// --- ACK values --- + +fn check_ack(val: u8) -> Result<()> { + match val { + 0x00 => Ok(()), + 0x51 => bail!("NACK: header incorrect"), + 0x52 => bail!("NACK: checksum incorrect"), + 0x53 => bail!("NACK: packet size zero"), + 0x54 => bail!("NACK: packet size too big"), + 0x55 => bail!("NACK: unknown error"), + other => bail!("Bad ACK value: 0x{other:02x}"), + } +} + +// --- Response types --- + +#[derive(Debug, Clone, Copy)] +#[repr(u8)] +enum RespType { + Message = b'M', + Identity = b'I', + Digest = b'D', +} + +impl RespType { + fn from_u8(b: u8) -> Result { + match b { + b'M' => Ok(Self::Message), + b'I' => Ok(Self::Identity), + b'D' => Ok(Self::Digest), + _ => bail!("Unknown response type: 0x{b:02x}"), + } + } +} + +fn check_message(val: u8) -> Result<()> { + match val { + 0x00 => Ok(()), + 0x04 => bail!("Unknown command"), + 0x05 => bail!("Invalid memory range"), + 0x06 => bail!("Invalid command"), + 0x0A => bail!("Invalid address"), + 0xF0 => bail!("Command rejected"), + 0xF1 => bail!("Program failed"), + 0xF2 => bail!("Erase failed"), + 0xF3 => bail!("Verify failed"), + other => bail!("Unknown message code: 0x{other:02x}"), + } +} + +// --- Parsed responses --- + +#[derive(Debug)] +#[allow(dead_code)] +pub struct IdentityResponse { + pub year: u8, + pub major_version: u8, + pub minor_version: u8, + pub secure: u8, + pub buf_size: u32, + pub app_start: u32, + pub sector_size: u32, + pub app_clear: u16, + pub app_ready: u16, + pub installed: Option>, +} + +enum Response { + None, + Message, + Identity(IdentityResponse), + Digest(Vec), +} + +fn parse_response(body: &[u8]) -> Result { + if body.is_empty() { + bail!("Empty response body"); + } + let resp_type = RespType::from_u8(body[0])?; + let data = &body[1..]; + + match resp_type { + RespType::Message => { + if data.len() != 1 { + bail!("Bad MESSAGE data length: {}", data.len()); + } + check_message(data[0])?; + Ok(Response::Message) + } + RespType::Identity => { + // BBBBIIIHH = 1+1+1+1+4+4+4+2+2 = 20 bytes, then NAME_SIZE + if data.len() < 20 + NAME_SIZE { + bail!("Identity response too short: {} bytes", data.len()); + } + let installed_raw = &data[20..20 + NAME_SIZE]; + let installed = if installed_raw == [0xFF; NAME_SIZE] { + None + } else { + Some(installed_raw.to_vec()) + }; + Ok(Response::Identity(IdentityResponse { + year: data[0], + major_version: data[1], + minor_version: data[2], + secure: data[3], + buf_size: u32::from_le_bytes(data[4..8].try_into().unwrap()), + app_start: u32::from_le_bytes(data[8..12].try_into().unwrap()), + sector_size: u32::from_le_bytes(data[12..16].try_into().unwrap()), + app_clear: u16::from_le_bytes(data[16..18].try_into().unwrap()), + app_ready: u16::from_le_bytes(data[18..20].try_into().unwrap()), + installed, + })) + } + RespType::Digest => Ok(Response::Digest(data.to_vec())), + } +} + +// --- Image --- + +pub struct Image { + pub name: String, + pub size: u32, + pub protected: bool, + pub verification_data: Vec, + pub chunks: Vec<(u32, Vec)>, // (offset, data) +} + +impl Image { + const PROTECTED_MAGIC: &[u8] = b"SECURE!!"; + // Protected header: 8s magic + 8s name + I size + 32s verification_data = 52 bytes + const PROT_HDR_SIZE: usize = 8 + NAME_SIZE + 4 + 32; + const CHUNK_META_SIZE: usize = 32; + + pub fn deserialize(raw: &[u8], name: Option<&str>) -> Result { + if raw.starts_with(Self::PROTECTED_MAGIC) { + Self::deserialize_protected(raw) + } else { + let name = name.context("Must provide --name for unprotected image")?; + Self::deserialize_unprotected(raw, name) + } + } + + fn deserialize_protected(raw: &[u8]) -> Result { + if raw.len() < Self::PROT_HDR_SIZE { + bail!("Protected image too short"); + } + let name_bytes = &raw[8..8 + NAME_SIZE]; + let name = String::from_utf8_lossy( + &name_bytes[..name_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(NAME_SIZE)], + ) + .into_owned(); + let size = u32::from_le_bytes(raw[16..20].try_into().unwrap()); + let ver_data = raw[20..52].to_vec(); + let body = &raw[Self::PROT_HDR_SIZE..]; + + // Chunk protected image: each chunk is prefixed with a 4-byte LE size + let mut chunks = Vec::new(); + let mut offset: u32 = 0; + let mut remaining = body; + while !remaining.is_empty() { + if remaining.len() < 4 { + bail!("Truncated protected chunk header"); + } + let chunk_size = u32::from_le_bytes(remaining[..4].try_into().unwrap()) as usize; + remaining = &remaining[4..]; + if remaining.len() < chunk_size { + bail!("Truncated protected chunk data"); + } + chunks.push((offset, remaining[..chunk_size].to_vec())); + offset += (chunk_size - Self::CHUNK_META_SIZE) as u32; + remaining = &remaining[chunk_size..]; + } + + Ok(Self { + name, + size, + protected: true, + verification_data: ver_data, + chunks, + }) + } + + fn deserialize_unprotected(raw: &[u8], name: &str) -> Result { + let ver_data = crc32_jamcrc(raw).to_le_bytes().to_vec(); + let step = ((CHUNK_SIZE / SECTOR_SIZE) - 1) * SECTOR_SIZE; + let mut chunks = Vec::new(); + let mut offset: u32 = 0; + for chunk_start in (0..raw.len()).step_by(step) { + let end = (chunk_start + step).min(raw.len()); + let mut block = raw[chunk_start..end].to_vec(); + // Pad to sector boundary + let pad = (SECTOR_SIZE - (block.len() % SECTOR_SIZE)) % SECTOR_SIZE; + if pad > 0 { + block.extend(std::iter::repeat_n(0xFF, pad)); + } + chunks.push((offset, block)); + offset += step as u32; + } + + Ok(Self { + name: name.to_string(), + size: raw.len() as u32, + protected: false, + verification_data: ver_data, + chunks, + }) + } +} + +// --- Board interface --- + +pub struct MSPM0L2228 { + file: File, +} + +impl MSPM0L2228 { + pub fn open(port: &str) -> Result { + let file = open_serial(port, Some(3.0))?; + Ok(Self { file }) + } + + fn read_bytes(&mut self, n: usize) -> Result> { + let mut buf = vec![0u8; n]; + self.file.read_exact(&mut buf)?; + log::trace_hex("RX", &buf); + Ok(buf) + } + + fn read_byte(&mut self) -> Result> { + let mut buf = [0u8; 1]; + match self.file.read(&mut buf) { + Ok(0) => Ok(None), // timeout + Ok(_) => { + log::trace(&format!("RX byte: {:02x}", buf[0])); + Ok(Some(buf[0])) + } + Err(e) => Err(e.into()), + } + } + + fn write_bytes(&mut self, data: &[u8]) -> Result<()> { + log::trace_hex("TX", data); + self.file.write_all(data)?; + self.file.flush()?; + Ok(()) + } + + /// Build and send a command packet: [0x80][len:2 LE][cmd:1][data][crc:4 LE] + fn send_packet(&mut self, cmd: Cmd, data: &[u8]) -> Result<()> { + let length = (1 + data.len()) as u16; // cmd byte + data + let mut cmd_data = vec![cmd as u8]; + cmd_data.extend_from_slice(data); + let crc = crc32_jamcrc(&cmd_data); + + let mut pkt = Vec::with_capacity(3 + cmd_data.len() + CRC_SIZE); + pkt.push(PKT_HDR); + pkt.extend_from_slice(&length.to_le_bytes()); + pkt.extend_from_slice(&cmd_data); + pkt.extend_from_slice(&crc.to_le_bytes()); + + log::debug(&format!("Sending {:?} ({} bytes)", cmd, pkt.len())); + self.write_bytes(&pkt) + } + + /// Read ACK byte from bootloader + fn read_ack(&mut self) -> Result<()> { + log::trace("Waiting for ACK"); + let ack = self + .read_byte()? + .context("Timeout waiting for ACK")?; + check_ack(ack) + } + + /// Read a core response: [0x08][len:2 LE][body][crc:4 LE] + fn read_response(&mut self) -> Result { + let hdr_bytes = self.read_bytes(3)?; + if hdr_bytes[0] != RESP_HDR { + bail!("Bad response header: 0x{:02x}", hdr_bytes[0]); + } + let length = u16::from_le_bytes([hdr_bytes[1], hdr_bytes[2]]) as usize; + if length == 0 { + bail!("Zero-length response"); + } + + let body = self.read_bytes(length)?; + let crc_bytes = self.read_bytes(CRC_SIZE)?; + let sent_crc = u32::from_le_bytes(crc_bytes[..4].try_into().unwrap()); + let calc_crc = crc32_jamcrc(&body); + if sent_crc != calc_crc { + bail!("CRC mismatch: sent={sent_crc:08x} calc={calc_crc:08x}"); + } + + parse_response(&body) + } + + /// Send command, read ACK, optionally read core response + fn execute(&mut self, cmd: Cmd, data: &[u8], expect_response: bool) -> Result { + let start = Instant::now(); + self.send_packet(cmd, data)?; + self.read_ack()?; + let resp = if expect_response { + self.read_response()? + } else { + Response::None + }; + log::debug(&format!( + "Executed {:?} in {:.2}s", + cmd, + start.elapsed().as_secs_f64() + )); + + Ok(resp) + } + + pub fn connect(&mut self) -> Result<()> { + self.execute(Cmd::Connection, &[], false) + .context("Could not connect to the bootloader")?; + Ok(()) + } + + pub fn status(&mut self) -> Result { + let resp = self + .execute(Cmd::Identity, &[], true) + .context("Could not get identity")?; + match resp { + Response::Identity(id) => { + log::debug(&format!( + "Identity: v{}.{}.{}, secure={}, clear={}, ready={}", + id.year, id.major_version, id.minor_version, + id.secure, id.app_clear, id.app_ready + )); + Ok(id) + } + _ => bail!("Expected Identity response"), + } + } + + pub fn erase(&mut self) -> Result<()> { + log::info("Erasing old image"); + self.execute(Cmd::Erase, &[], true)?; + + let identity = self.status()?; + if identity.app_clear == 0 { + bail!("Erase failed"); + } + Ok(()) + } + + pub fn flash(&mut self, image: &Image) -> Result<()> { + self.connect()?; + let identity = self.status()?; + + if image.protected && identity.secure == 0 { + bail!("Tried to load protected image onto design bootloader"); + } + if !image.protected && identity.secure != 0 { + bail!("Tried to load unprotected image onto attack bootloader"); + } + if identity.app_clear == 0 { + bail!("Board must be erased before flashing"); + } + + // Send UPDATE + log::info("Requesting update"); + let mut update_data = Vec::with_capacity(NAME_SIZE + 4 + image.verification_data.len()); + let mut name_buf = [0u8; NAME_SIZE]; + let name_bytes = image.name.as_bytes(); + let copy_len = name_bytes.len().min(NAME_SIZE); + name_buf[..copy_len].copy_from_slice(&name_bytes[..copy_len]); + update_data.extend_from_slice(&name_buf); + update_data.extend_from_slice(&image.size.to_le_bytes()); + update_data.extend_from_slice(&image.verification_data); + self.execute(Cmd::Update, &update_data, true)?; + + if identity.sector_size != SECTOR_SIZE as u32 { + bail!("Bad sector size reported: {}", identity.sector_size); + } + if identity.buf_size < CHUNK_SIZE as u32 { + bail!("Bad chunk size reported: {}", identity.buf_size); + } + + // Send PROGRAM chunks + log::debug(&format!("Sending {} chunks", image.chunks.len())); + let start = Instant::now(); + for (offset, chunk) in &image.chunks { + let mut prog_data = Vec::with_capacity(4 + chunk.len()); + prog_data.extend_from_slice(&offset.to_le_bytes()); + prog_data.extend_from_slice(chunk); + self.execute(Cmd::Program, &prog_data, true)?; + } + log::debug(&format!( + "Sent image in {:.2}s", + start.elapsed().as_secs_f64() + )); + + // Verify + self.execute(Cmd::Verify, &[], true)?; + + let identity = self.status()?; + if identity.app_ready == 0 { + bail!("Image not ready after verification! Reboot board and try again"); + } + + Ok(()) + } + + pub fn start(&mut self) -> Result<()> { + self.execute(Cmd::Start, &[], true)?; + Ok(()) + } + + pub fn digest(&mut self, slot: u8) -> Result> { + let identity = self.status()?; + if identity.secure == 0 { + bail!("Digest only possible on secure bootloader"); + } + if identity.app_ready == 0 { + bail!("Design must be flashed before requesting digest"); + } + let resp = self.execute(Cmd::Digest, &[slot], true)?; + match resp { + Response::Digest(d) => Ok(d), + _ => bail!("Expected Digest response"), + } + } +}