commit 1f94f6964fa2bd29b8e19eaa179cd83342ad9cf3 Author: Kieran Klukas Date: Wed Feb 25 19:06:05 2026 -0500 feat: init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ac1ef9a --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3bd6642 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..abe9d6a --- /dev/null +++ b/src/log.rs @@ -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}{:= 1) — blue +pub fn debug(msg: &str) { + if verbosity() >= 1 { + eprintln!("{BLUE}{: 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}{: 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}", ""); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..69fcee3 --- /dev/null +++ b/src/main.rs @@ -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, + }, + /// 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 { + 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> { + 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 = 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| 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> { + 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() +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..04f871a --- /dev/null +++ b/src/protocol.rs @@ -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 { + 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, +} + +pub struct HSMIntf { + file: File, + stream: Vec, +} + +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); + } + + Ok(Self { + file, + stream: Vec::new(), + }) + } + + fn read_byte(&mut self) -> Result { + 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> { + 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 { + // 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 { + 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 { + 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) + } +}