feat: add hw sub commands
This commit is contained in:
102
Cargo.lock
generated
102
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
50
README.md
50
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
|
||||
|
||||
160
src/fthr.rs
Normal file
160
src/fthr.rs
Normal file
@@ -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<u8> {
|
||||
[1, 2, 3, 4, 5, 7, 8, 10, 11, 13, 16, 18, 19, COMPLETE_CODE]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn error_codes() -> HashSet<u8> {
|
||||
[6, 9, 12, 14, 15, 17].into_iter().collect()
|
||||
}
|
||||
|
||||
pub struct MAX78000FTHR {
|
||||
file: File,
|
||||
}
|
||||
|
||||
impl MAX78000FTHR {
|
||||
pub fn open(port: &str) -> Result<Self> {
|
||||
let file = open_serial(port, Some(5.0))?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
fn verify_resp(&mut self) -> Result<u8> {
|
||||
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<Sha256>;
|
||||
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<Vec<u8>> {
|
||||
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()
|
||||
}
|
||||
371
src/main.rs
371
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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<u16> {
|
||||
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<u16> {
|
||||
|
||||
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<Vec<(u8, u16, String)>> {
|
||||
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<Vec<(u8, u16, String)>> {
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Derive an image name from a file path (file stem, truncated to 8 bytes).
|
||||
fn infer_image_name(path: &PathBuf) -> Option<String> {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.chars().take(8).collect())
|
||||
}
|
||||
|
||||
fn hex_decode(s: &str) -> Result<Vec<u8>> {
|
||||
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<u8>| 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<u8>| {
|
||||
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<Vec<u8>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<Self> {
|
||||
// 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<Message> {
|
||||
// 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<Message> {
|
||||
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<Message> {
|
||||
self.send_msg(opcode, body)?;
|
||||
let resp = self.get_msg()?;
|
||||
|
||||
93
src/serial.rs
Normal file
93
src/serial.rs
Normal file
@@ -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<f32>) -> Result<File> {
|
||||
// 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)
|
||||
}
|
||||
466
src/ti.rs
Normal file
466
src/ti.rs
Normal file
@@ -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<Self> {
|
||||
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<Vec<u8>>,
|
||||
}
|
||||
|
||||
enum Response {
|
||||
None,
|
||||
Message,
|
||||
Identity(IdentityResponse),
|
||||
Digest(Vec<u8>),
|
||||
}
|
||||
|
||||
fn parse_response(body: &[u8]) -> Result<Response> {
|
||||
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<u8>,
|
||||
pub chunks: Vec<(u32, Vec<u8>)>, // (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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
let file = open_serial(port, Some(3.0))?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, n: usize) -> Result<Vec<u8>> {
|
||||
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<Option<u8>> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<IdentityResponse> {
|
||||
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<Vec<u8>> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user