feat: init

This commit is contained in:
Kieran Klukas
2026-02-25 19:06:05 -05:00
commit 1f94f6964f
6 changed files with 1318 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

595
Cargo.lock generated Normal file
View File

@@ -0,0 +1,595 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "ectf-tools"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"libc",
"uuid",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.90"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"getrandom",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

10
Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "ectf-tools"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }
anyhow = "1"
libc = "0.2"

131
src/log.rs Normal file
View File

@@ -0,0 +1,131 @@
#![allow(dead_code)]
use std::sync::atomic::{AtomicU8, Ordering};
static VERBOSITY: AtomicU8 = AtomicU8::new(0);
pub fn set_verbosity(level: u8) {
VERBOSITY.store(level, Ordering::Relaxed);
}
fn verbosity() -> u8 {
VERBOSITY.load(Ordering::Relaxed)
}
// ANSI codes
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
// 256-color: \x1b[38;5;Nm
const GRAY: &str = "\x1b[38;5;242m";
const BLUE: &str = "\x1b[38;5;75m";
const GREEN: &str = "\x1b[38;5;114m";
const YELLOW: &str = "\x1b[38;5;221m";
const RED: &str = "\x1b[38;5;203m";
const MAGENTA: &str = "\x1b[38;5;183m";
const WHITE: &str = "\x1b[38;5;252m";
// Padding so messages align. Longest tag is [error] = 7 chars.
// Each tag pads to 8 total (tag + spaces) before the message.
const PAD: usize = 8; // "[error] " = 8
macro_rules! tag {
($label:expr) => {
// pad_len = PAD - len("[") - len($label) - len("]") - len(" ")
// but easier: total visible = 2 + label.len(), pad to PAD
concat!("[", $label, "]")
};
}
/// Trace (verbosity >= 2) — gray
pub fn trace(msg: &str) {
if verbosity() >= 2 {
eprintln!("{RED}{:<PAD$}{RESET}{msg}", tag!("trace"));
}
}
/// Debug (verbosity >= 1) — blue
pub fn debug(msg: &str) {
if verbosity() >= 1 {
eprintln!("{BLUE}{:<PAD$}{RESET}{msg}", tag!("debug"));
}
}
/// Info — white
pub fn info(msg: &str) {
println!("{WHITE}{:<PAD$}{RESET}{msg}", tag!("info"));
}
/// Success — green
pub fn success(msg: &str) {
println!("{GREEN}{:<PAD$}{RESET}{msg}", tag!("ok"));
}
/// Warning — yellow
pub fn warning(msg: &str) {
eprintln!("{YELLOW}{:<PAD$}{RESET}{msg}", tag!("warn"));
}
/// Error — bold red
pub fn error(msg: &str) {
eprintln!("{BOLD}{RED}{:<PAD$}{RESET}{msg}", tag!("error"));
}
/// Error cause — red, indented to match message column
pub fn error_cause(msg: &str) {
eprintln!("{:PAD$}{RED}{msg}{RESET}", "");
}
/// HSM debug messages from firmware — magenta
pub fn hsm_debug(msg: &str) {
eprintln!("{MAGENTA}{:<PAD$}{RESET}{msg}", tag!("hsm"));
}
/// HSM debug messages that aren't valid UTF-8 — show as hexdump
pub fn hsm_debug_hex(data: &[u8]) {
eprintln!("{MAGENTA}{:<PAD$}{RESET}({} bytes)", tag!("hsm"), data.len());
for (i, chunk) in data.chunks(16).enumerate() {
let offset = i * 16;
let mut hex_part = String::with_capacity(40);
let mut ascii_part = String::with_capacity(16);
for (j, &b) in chunk.iter().enumerate() {
if j > 0 && j % 2 == 0 {
hex_part.push(' ');
}
hex_part.push_str(&format!("{b:02x}"));
ascii_part.push(if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'.'
});
}
eprintln!("{:PAD$}{GRAY}{offset:08x}:{RESET} {hex_part:<39} {GREEN}{ascii_part}{RESET}", "");
}
}
/// Trace-level hex+ASCII dump of a byte buffer (verbosity >= 2)
/// Format matches xxd: paired hex bytes, 16 per line.
pub fn trace_hex(label: &str, data: &[u8]) {
if verbosity() < 2 {
return;
}
eprintln!("{RED}{:<PAD$}{RESET}{label} ({} bytes)", tag!("trace"), data.len());
for (i, chunk) in data.chunks(16).enumerate() {
let offset = i * 16;
let mut hex_part = String::with_capacity(40);
let mut ascii_part = String::with_capacity(16);
for (j, &b) in chunk.iter().enumerate() {
if j > 0 && j % 2 == 0 {
hex_part.push(' ');
}
hex_part.push_str(&format!("{b:02x}"));
ascii_part.push(if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'.'
});
}
eprintln!("{:PAD$}{GRAY}{offset:08x}:{RESET} {hex_part:<39} {GREEN}{ascii_part}{RESET}", "");
}
}

309
src/main.rs Normal file
View File

@@ -0,0 +1,309 @@
mod log;
mod protocol;
use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use protocol::{HSMIntf, Opcode};
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")]
struct Cli {
/// Serial port
port: String,
/// Verbosity level (-v, -vv)
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Write a file to the HSM
Write {
/// 6-digit PIN
pin: String,
/// Slot (0-7)
slot: u8,
/// Group ID (decimal or 0x hex)
gid: String,
/// Path to file to write
file: PathBuf,
/// UUID (hex, 32 chars). Random if omitted.
#[arg(short, long)]
uuid: Option<String>,
},
/// Read a file from the HSM
Read {
/// 6-digit PIN
pin: String,
/// Slot (0-7)
slot: u8,
/// Output directory
output_dir: PathBuf,
/// Overwrite existing file
#[arg(short, long)]
force: bool,
},
/// List files on the HSM
List {
/// 6-digit PIN
pin: String,
},
/// Interrogate files on a connected HSM
Interrogate {
/// 6-digit PIN
pin: String,
},
/// Alert the HSM to listen for another HSM
Listen,
/// Receive a file from another HSM
Receive {
/// 6-digit PIN
pin: String,
/// Read slot (0-7)
read_slot: u8,
/// Write slot (0-7)
write_slot: u8,
},
}
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)?
} else {
s.parse()?
};
Ok(val)
}
fn validate_pin(pin: &str) -> Result<()> {
if pin.len() != 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)");
}
Ok(())
}
fn validate_slot(slot: u8) -> Result<()> {
if slot > 7 {
bail!("Slot must be 0-7, got {slot}");
}
Ok(())
}
fn unpack_files(body: &[u8]) -> Result<Vec<(u8, u16, String)>> {
if body.len() < 4 {
bail!("File list response too short");
}
let n_files = u32::from_le_bytes(body[0..4].try_into().unwrap()) as usize;
log::debug(&format!("Reported {n_files} files"));
let entries = &body[4..];
let entry_size = 1 + 2 + MAX_NAME_LEN; // 35 bytes
if entries.len() != n_files * entry_size {
bail!(
"Expected {} bytes for {n_files} files, got {}",
n_files * entry_size,
entries.len()
);
}
let mut files = Vec::with_capacity(n_files);
for i in 0..n_files {
let off = i * entry_size;
let slot = entries[off];
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)],
)
.into_owned();
files.push((slot, group_id, name));
}
Ok(files)
}
fn main() {
let cli = Cli::parse();
log::set_verbosity(cli.verbose);
if let Err(e) = run(cli) {
let chain: Vec<String> = e.chain().map(|c| c.to_string()).collect();
log::error(&chain[0]);
for cause in &chain[1..] {
log::error_cause(cause);
}
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<()> {
let mut hsm = HSMIntf::open(&cli.port).context("Failed to open serial port")?;
match cli.command {
Command::Write {
pin,
slot,
gid,
file,
uuid,
} => {
validate_pin(&pin)?;
validate_slot(slot)?;
let gid = parse_gid(&gid)?;
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()
}
};
let contents = fs::read(&file).context("Failed to read input file")?;
if contents.len() > MAX_FILE_LEN {
bail!(
"File too large: {} bytes (max {MAX_FILE_LEN})",
contents.len()
);
}
let filename = file
.file_name()
.context("No filename")?
.to_str()
.context("Filename not UTF-8")?;
let mut name_buf = [0u8; MAX_NAME_LEN];
let name_bytes = filename.as_bytes();
if name_bytes.len() > MAX_NAME_LEN {
bail!("Filename too long (max {MAX_NAME_LEN} bytes)");
}
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);
frame.extend_from_slice(&gid.to_le_bytes());
frame.extend_from_slice(&name_buf);
frame.extend_from_slice(&uuid_bytes);
frame.extend_from_slice(&(contents.len() as u16).to_le_bytes());
frame.extend_from_slice(&contents);
hsm.send_respond(Opcode::Write, &frame)?;
log::success("Write successful");
}
Command::Read {
pin,
slot,
output_dir,
force,
} => {
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);
let resp = hsm.send_respond(Opcode::Read, &frame)?;
let body = &resp.body;
if body.len() < MAX_NAME_LEN {
bail!("Read response too short");
}
let name_bytes = &body[..MAX_NAME_LEN];
let name = String::from_utf8_lossy(
&name_bytes[..name_bytes
.iter()
.position(|&b| b == 0)
.unwrap_or(MAX_NAME_LEN)],
);
let contents = &body[MAX_NAME_LEN..];
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());
}
fs::create_dir_all(&output_dir)?;
fs::write(&full_path, contents)?;
log::success(&format!(
"Read successful. Wrote file to {}",
full_path.canonicalize().unwrap_or(full_path).display()
));
}
Command::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::success("List successful");
}
Command::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::success("Interrogate successful");
}
Command::Listen => {
hsm.send_respond(Opcode::Listen, &[])?;
log::success("Listen successful");
}
Command::Receive {
pin,
read_slot,
write_slot,
} => {
validate_pin(&pin)?;
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}"));
}
}
Ok(())
}
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()
}

272
src/protocol.rs Normal file
View File

@@ -0,0 +1,272 @@
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::FromRawFd;
use anyhow::{Result, bail};
use crate::log;
const MAGIC: u8 = 0x25; // '%'
const BLOCK_LEN: usize = 256;
const HDR_LEN: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Opcode {
List = 0x4C,
Read = 0x52,
Write = 0x57,
Receive = 0x43,
Interrogate = 0x49,
Listen = 0x4E,
Ack = 0x41,
Debug = 0x44,
Error = 0x45,
}
impl Opcode {
fn from_u8(b: u8) -> Result<Self> {
match b {
0x4C => Ok(Self::List),
0x52 => Ok(Self::Read),
0x57 => Ok(Self::Write),
0x43 => Ok(Self::Receive),
0x49 => Ok(Self::Interrogate),
0x4E => Ok(Self::Listen),
0x41 => Ok(Self::Ack),
0x44 => Ok(Self::Debug),
0x45 => Ok(Self::Error),
_ => bail!("Unknown opcode: 0x{b:02X}"),
}
}
fn needs_ack(self) -> bool {
!matches!(self, Self::Ack | Self::Debug)
}
}
pub struct Message {
pub opcode: Opcode,
pub body: Vec<u8>,
}
pub struct HSMIntf {
file: File,
stream: Vec<u8>,
}
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);
}
Ok(Self {
file,
stream: Vec::new(),
})
}
fn read_byte(&mut self) -> Result<u8> {
let mut buf = [0u8; 1];
self.file.read_exact(&mut buf)?;
log::trace(&format!("RX byte: {:02x}", buf[0]));
Ok(buf[0])
}
fn read_exact(&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 write_all(&mut self, data: &[u8]) -> Result<()> {
log::trace_hex("TX", data);
self.file.write_all(data)?;
self.file.flush()?;
Ok(())
}
fn pack_header(opcode: Opcode, size: u16) -> [u8; HDR_LEN] {
let size_bytes = size.to_le_bytes();
[MAGIC, opcode as u8, size_bytes[0], size_bytes[1]]
}
fn send_ack(&mut self) -> Result<()> {
log::trace("TX ACK");
let hdr = Self::pack_header(Opcode::Ack, 0);
self.write_all(&hdr)
}
fn get_ack(&mut self) -> Result<()> {
let msg = self.get_msg()?;
if msg.opcode != Opcode::Ack {
bail!("Expected ACK, got {:?}", msg.opcode);
}
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]]);
self.stream.drain(..pos + 4);
if let Ok(opcode) = Opcode::from_u8(opc) {
return Some((opcode, size));
}
}
}
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;
}
let b = self.read_byte()?;
self.stream.push(b);
};
log::debug(&format!("Found header: opcode={opcode:?}, size={size}"));
if opcode.needs_ack() {
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 {
let chunk_size = remaining.min(BLOCK_LEN);
let chunk = self.read_exact(chunk_size)?;
body.extend_from_slice(&chunk);
remaining -= chunk_size;
if opcode.needs_ack() {
self.send_ack()?;
}
log::debug(&format!("Read block ({chunk_size} bytes)"));
}
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()?;
match msg.opcode {
Opcode::Error => {
let text = String::from_utf8_lossy(&msg.body);
bail!("HSM error: {text}");
}
Opcode::Debug => {
match std::str::from_utf8(&msg.body) {
Ok(text) => log::hsm_debug(text.trim()),
Err(_) => log::hsm_debug_hex(&msg.body),
}
continue;
}
_ => return Ok(msg),
}
}
}
/// 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()));
self.write_all(&hdr)?;
self.get_ack()?;
for chunk in body.chunks(BLOCK_LEN) {
log::debug(&format!("Sending chunk ({} bytes)", chunk.len()));
self.write_all(chunk)?;
self.get_ack()?;
}
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()?;
if resp.opcode != opcode {
bail!(
"Response opcode mismatch: expected {:?}, got {:?}",
opcode,
resp.opcode
);
}
Ok(resp)
}
}