From 307a01f93ee3eabb1b40155a139d1534758a1aa6 Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Mon, 23 Feb 2026 15:06:26 +0300 Subject: [PATCH 1/3] website: gitignore ts build info files --- website/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/website/.gitignore b/website/.gitignore index a547bf3..b6cdf57 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.tsbuildinfo # Editor directories and files .vscode/* From ea9b12b25f1dc98c27cd24daf19337cdd11b0afd Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Mon, 23 Feb 2026 15:06:42 +0300 Subject: [PATCH 2/3] bhwi-wasm: set env vars for when glibc wasn't build with multilib support On Guix System, using guix shell, the C_INCLUDE_PATH remains set but is missing the 32 bit headers. Unsetting it somehow allows cargo/cc to resolve things. --- bhwi-wasm/.cargo/config.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bhwi-wasm/.cargo/config.toml b/bhwi-wasm/.cargo/config.toml index 8467175..6c79fe8 100644 --- a/bhwi-wasm/.cargo/config.toml +++ b/bhwi-wasm/.cargo/config.toml @@ -1,2 +1,7 @@ [build] rustflags = ["--cfg=web_sys_unstable_apis"] + +[env] +CC_wasm32_unknown_unknown = "clang" +CFLAGS = "-target wasm32-unknown-unknown -nostdlibinc" +C_INCLUDE_PATH = { value = "", force = true } From 00f5856ad5d4c8abfd22d88dc4be249ec48b7d0c Mon Sep 17 00:00:00 2001 From: Trevor Arjeski Date: Mon, 9 Mar 2026 20:03:58 +0000 Subject: [PATCH 3/3] bhwi-cli: command to enumerate devices The main goal of this change is to add a command to bhwi-cli that enumerates all supported devices and displays information (ex. fingerprint) about them so that the user can interact with a device. Lots of tangential changes have also been made as I was trying to build out a decent foundation for adding device enumeration. Large refactors were done in order to make it possible to have heterogeneous vectors of devices. It's not a major benefit but it makes the cli code nicer. This includes the creation of the HWIDevice trait and making error types use thiserror to implement std::error::Error. This also required the bhwi-wasm errors to be changed from JsValue to a wrapper error, WasmError, which converts the errors to strings. Not ideal, but could not find a better solution right now. Lots of the Transports from the e2e crates were moved around and changed for re-usability between the cli crate e2e tests. The rest of the changes were simply writing the Transports (HID and serial backends) used for the currently supported devices. --- Cargo.lock | 485 +++++++++++++++++++- Cargo.toml | 1 + bhwi-async/Cargo.toml | 1 + bhwi-async/src/lib.rs | 91 +++- bhwi-async/src/transport/coldcard/hid.rs | 14 +- bhwi-async/src/transport/coldcard/mod.rs | 1 + bhwi-async/src/transport/jade/mod.rs | 2 + bhwi-async/src/transport/jade/tcp.rs | 12 +- bhwi-async/src/transport/ledger/hid.rs | 18 +- bhwi-async/src/transport/ledger/speculos.rs | 114 ++--- bhwi-async/src/transport/mod.rs | 2 + bhwi-cli/Cargo.toml | 14 +- bhwi-cli/src/bin/bhwi.rs | 76 ++- bhwi-cli/src/coldcard.rs | 140 ++++++ bhwi-cli/src/config.rs | 8 + bhwi-cli/src/hid.rs | 40 ++ bhwi-cli/src/jade.rs | 184 ++++++++ bhwi-cli/src/ledger.rs | 119 +++++ bhwi-cli/src/lib.rs | 129 +++++- bhwi-wasm/Cargo.toml | 1 + bhwi-wasm/src/lib.rs | 31 +- bhwi-wasm/src/pinserver.rs | 6 +- bhwi-wasm/src/webserial.rs | 4 +- bhwi/Cargo.toml | 1 + bhwi/src/coldcard/mod.rs | 17 +- bhwi/src/common.rs | 20 +- bhwi/src/device.rs | 33 ++ bhwi/src/jade/mod.rs | 12 +- bhwi/src/ledger/apdu.rs | 5 +- bhwi/src/ledger/mod.rs | 36 +- bhwi/src/ledger/store.rs | 15 +- bhwi/src/lib.rs | 1 + docs/COLDCARD.md | 3 +- docs/LEDGER.md | 2 +- e2e/coldcard/Cargo.toml | 2 + e2e/coldcard/src/lib.rs | 55 +-- e2e/jade/Cargo.toml | 2 + e2e/jade/src/lib.rs | 66 +-- e2e/ledger/Cargo.toml | 2 + e2e/ledger/src/lib.rs | 159 ++++--- 40 files changed, 1544 insertions(+), 380 deletions(-) create mode 100644 bhwi-cli/src/coldcard.rs create mode 100644 bhwi-cli/src/config.rs create mode 100644 bhwi-cli/src/hid.rs create mode 100644 bhwi-cli/src/jade.rs create mode 100644 bhwi-cli/src/ledger.rs create mode 100644 bhwi/src/device.rs diff --git a/Cargo.lock b/Cargo.lock index 4c4898b..4eea7a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,44 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "async-hid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62cf94c2e8850d66d416a1551ab73cda04f21bd11ce95e1bbe088c77e109e170" +dependencies = [ + "async-io", + "atomic-waker", + "block2", + "crossbeam-queue", + "dispatch2", + "futures-lite", + "log", + "nix 0.29.0", + "objc2-core-foundation", + "objc2-io-kit", + "static_assertions", + "windows", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-trait" version = "0.1.83" @@ -198,6 +236,7 @@ dependencies = [ "serde_bytes", "serde_cbor", "serde_json", + "thiserror 1.0.69", ] [[package]] @@ -212,17 +251,28 @@ dependencies = [ "serde", "serde_cbor", "serde_json", + "thiserror 1.0.69", ] [[package]] name = "bhwi-cli" version = "0.0.1" dependencies = [ + "anyhow", + "async-hid", + "async-trait", "bhwi-async", "bitcoin", "clap", + "futures", "hex", + "rand_core 0.6.4", + "reqwest", + "serde", + "serde_json", + "strum", "tokio", + "tokio-serial", ] [[package]] @@ -233,6 +283,7 @@ dependencies = [ "async-trait", "base64ct", "bhwi-async", + "bhwi-cli", "bitcoin", "rand_core 0.6.4", "tokio", @@ -246,6 +297,7 @@ dependencies = [ "async-trait", "base64ct", "bhwi-async", + "bhwi-cli", "bitcoin", "reqwest", "serde_cbor", @@ -260,6 +312,7 @@ dependencies = [ "async-trait", "base64ct", "bhwi-async", + "bhwi-cli", "hex", "reqwest", "serde", @@ -282,6 +335,7 @@ dependencies = [ "js-sys", "log", "rand_core 0.6.4", + "thiserror 1.0.69", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -335,6 +389,12 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -350,6 +410,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -473,6 +542,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -534,6 +612,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -587,6 +680,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -652,6 +757,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "ff" version = "0.13.1" @@ -737,6 +858,19 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.30" @@ -870,11 +1004,20 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-conservative" @@ -1122,13 +1265,23 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "io-uring" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "libc", ] @@ -1219,9 +1372,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.174" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1241,12 +1400,30 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1269,10 +1446,90 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.59.0", ] +[[package]] +name = "mio-serial" +version = "5.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3" +dependencies = [ + "log", + "mio", + "nix 0.29.0", + "serialport", + "winapi", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -1300,6 +1557,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1328,6 +1591,20 @@ dependencies = [ "spki", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1540,6 +1817,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1639,6 +1929,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -1678,7 +1974,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1757,6 +2053,24 @@ dependencies = [ "zmij", ] +[[package]] +name = "serialport" +version = "4.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "winapi", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1825,12 +2139,39 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1874,7 +2215,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1993,6 +2334,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-serial" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" +dependencies = [ + "cfg-if", + "futures", + "log", + "mio-serial", + "serialport", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2027,7 +2382,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -2082,6 +2437,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicode-ident" version = "1.0.12" @@ -2257,6 +2621,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2266,6 +2646,80 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -2278,6 +2732,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -2374,6 +2838,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 0b3b286..df95033 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,4 +21,5 @@ reqwest = "0.13.2" serde = "1.0.228" serde_json = "1.0.149" serde_cbor = "0.11.2" +thiserror = "1" tokio = "1.0" diff --git a/bhwi-async/Cargo.toml b/bhwi-async/Cargo.toml index fd6ea36..b02d247 100644 --- a/bhwi-async/Cargo.toml +++ b/bhwi-async/Cargo.toml @@ -23,5 +23,6 @@ hex = { workspace = true, optional = true} serde = { workspace = true, optional = true} serde_cbor = { workspace = true, optional = true} serde_json = { workspace = true, optional = true} +thiserror.workspace = true byteorder = "1.5" diff --git a/bhwi-async/src/lib.rs b/bhwi-async/src/lib.rs index 0e96ddb..96cc472 100644 --- a/bhwi-async/src/lib.rs +++ b/bhwi-async/src/lib.rs @@ -3,7 +3,7 @@ pub mod jade; pub mod ledger; pub mod transport; -use std::fmt::Debug; +use std::{error::Error as StdError, fmt::Debug}; use async_trait::async_trait; use bhwi::{ @@ -47,17 +47,45 @@ pub trait HWI { ) -> Result<(u8, Signature), Self::Error>; } -#[derive(Debug)] +// TODO: this will become a pain to maintain, but we can have a proc-macro +// generate this trait by putting it over HWI's definition and then also +// generate the blanket impl which will map the errors to HWIDeviceError +#[async_trait(?Send)] +pub trait HWIDevice { + async fn unlock(&mut self, network: Network) -> Result<(), HWIDeviceError>; + async fn get_master_fingerprint(&mut self) -> Result; + async fn get_extended_pubkey( + &mut self, + path: DerivationPath, + display: bool, + ) -> Result; + async fn sign_message( + &mut self, + message: &[u8], + path: DerivationPath, + ) -> Result<(u8, Signature), HWIDeviceError>; +} + +#[derive(Debug, thiserror::Error)] +#[error("hwi device error: {0}")] +pub struct HWIDeviceError(#[from] Box); + +impl HWIDeviceError { + pub fn new(error: impl StdError + Send + Sync + 'static) -> Self { + Self(Box::new(error)) + } +} + +#[derive(Debug, thiserror::Error)] pub enum Error { + #[error("transport error: {0}")] Transport(E), + + #[error("http client error: {0}")] HttpClient(F), - Interpreter(common::Error), -} -impl From for Error { - fn from(value: common::Error) -> Self { - Self::Interpreter(value) - } + #[error("interpreter error: {0}")] + Interpreter(#[from] common::Error), } #[async_trait(?Send)] @@ -126,6 +154,45 @@ where } } +#[async_trait(?Send)] +impl HWIDevice for T +where + T: HWI, + T::Error: StdError + Send + Sync + 'static, +{ + async fn unlock(&mut self, network: Network) -> Result<(), HWIDeviceError> { + HWI::unlock(self, network) + .await + .map_err(HWIDeviceError::new) + } + + async fn get_master_fingerprint(&mut self) -> Result { + HWI::get_master_fingerprint(self) + .await + .map_err(HWIDeviceError::new) + } + + async fn get_extended_pubkey( + &mut self, + path: DerivationPath, + display: bool, + ) -> Result { + HWI::get_extended_pubkey(self, path, display) + .await + .map_err(HWIDeviceError::new) + } + + async fn sign_message( + &mut self, + message: &[u8], + path: DerivationPath, + ) -> Result<(u8, Signature), HWIDeviceError> { + HWI::sign_message(self, message, path) + .await + .map_err(HWIDeviceError::new) + } +} + pub trait OnUnlock { fn on_unlock(&mut self, _response: common::Response) -> Result<(), common::Error>; } @@ -144,13 +211,13 @@ pub trait CommonInterface { ); } -async fn run_command<'a, D, C, E, F>( - device: &'a mut D, +async fn run_command( + device: &mut D, command: C, ) -> Result> where - E: std::fmt::Debug + 'a, - F: std::fmt::Debug + 'a, + E: Debug, + F: Debug, D: CommonInterface< common::Command, common::Transmit, diff --git a/bhwi-async/src/transport/coldcard/hid.rs b/bhwi-async/src/transport/coldcard/hid.rs index 7d225aa..8ffc653 100644 --- a/bhwi-async/src/transport/coldcard/hid.rs +++ b/bhwi-async/src/transport/coldcard/hid.rs @@ -1,20 +1,18 @@ use crate::{Transport, transport::Channel}; use async_trait::async_trait; -pub const COLDCARD_VID: u16 = 0xd13e; +pub use bhwi::coldcard::COLDCARD_DEVICE_ID; + const COLDCARD_PACKET_WRITE_SIZE: usize = 63; const COLDCARD_PACKET_READ_SIZE: usize = 64; -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ColdcardHIDError { + #[error("communication error: {0}")] Comm(&'static str), - Hid(std::io::Error), -} -impl From for ColdcardHIDError { - fn from(value: std::io::Error) -> Self { - ColdcardHIDError::Hid(value) - } + #[error("HID IO error")] + Hid(#[from] std::io::Error), } pub struct ColdcardTransportHID { diff --git a/bhwi-async/src/transport/coldcard/mod.rs b/bhwi-async/src/transport/coldcard/mod.rs index 27460f1..f49085c 100644 --- a/bhwi-async/src/transport/coldcard/mod.rs +++ b/bhwi-async/src/transport/coldcard/mod.rs @@ -1 +1,2 @@ +pub use bhwi::coldcard::DEFAULT_CKCC_SOCKET; pub mod hid; diff --git a/bhwi-async/src/transport/jade/mod.rs b/bhwi-async/src/transport/jade/mod.rs index f585df1..e748cf5 100644 --- a/bhwi-async/src/transport/jade/mod.rs +++ b/bhwi-async/src/transport/jade/mod.rs @@ -1,2 +1,4 @@ +pub use bhwi::jade::JADE_DEVICE_IDS; + #[cfg(feature = "emulators")] pub mod tcp; diff --git a/bhwi-async/src/transport/jade/tcp.rs b/bhwi-async/src/transport/jade/tcp.rs index 1955f4c..d0aac42 100644 --- a/bhwi-async/src/transport/jade/tcp.rs +++ b/bhwi-async/src/transport/jade/tcp.rs @@ -1,5 +1,3 @@ -use std::fmt::Debug; - use async_trait::async_trait; use serde_cbor::Value; @@ -17,15 +15,13 @@ impl TcpTransport { #[async_trait(?Send)] pub trait TcpClient { - type Error: Debug + From; - - async fn write_all(&mut self, command: &[u8]) -> Result<(), Self::Error>; - async fn read(&mut self, buf: &mut [u8]) -> Result; + async fn write_all(&mut self, command: &[u8]) -> Result<(), std::io::Error>; + async fn read(&mut self, buf: &mut [u8]) -> Result; } #[async_trait(?Send)] impl Transport for TcpTransport { - type Error = ::Error; + type Error = std::io::Error; async fn exchange(&mut self, command: &[u8], _encrypted: bool) -> Result, Self::Error> { self.client.write_all(command).await?; @@ -47,7 +43,7 @@ impl Transport for TcpTransport { continue; // read more bytes } Err(e) => { - return Err(e.into()); + return Err(std::io::Error::other(e)); } } } diff --git a/bhwi-async/src/transport/ledger/hid.rs b/bhwi-async/src/transport/ledger/hid.rs index 26f3277..dbfc9b1 100644 --- a/bhwi-async/src/transport/ledger/hid.rs +++ b/bhwi-async/src/transport/ledger/hid.rs @@ -14,36 +14,32 @@ * limitations under the License. ********************************************************************************/ use async_trait::async_trait; +pub use bhwi::ledger::LEDGER_DEVICE_ID; use byteorder::{BigEndian, ReadBytesExt}; use std::io::Cursor; use crate::{Transport, transport::Channel}; -pub const LEDGER_VID: u16 = 0x2c97; -pub const LEDGER_USAGE_PAGE: u16 = 0xFFA0; pub const LEDGER_CHANNEL: u16 = 0x0101; // for Windows compatability, we prepend the buffer with a 0x00 // so the actual buffer is 64 bytes const LEDGER_PACKET_WRITE_SIZE: u8 = 64; const LEDGER_PACKET_READ_SIZE: u8 = 64; -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum LedgerHIDError { + #[error("communication error: {0}")] Comm(&'static str), - Hid(std::io::Error), -} -impl From for LedgerHIDError { - fn from(value: std::io::Error) -> Self { - LedgerHIDError::Hid(value) - } + #[error("HID IO error")] + Hid(#[from] std::io::Error), } -pub struct LedgerTransportHID { +pub struct LedgerTransportHID { channel: C, } -impl LedgerTransportHID { +impl LedgerTransportHID { pub fn new(channel: C) -> Self { Self { channel } } diff --git a/bhwi-async/src/transport/ledger/speculos.rs b/bhwi-async/src/transport/ledger/speculos.rs index a1eb586..e60fe85 100644 --- a/bhwi-async/src/transport/ledger/speculos.rs +++ b/bhwi-async/src/transport/ledger/speculos.rs @@ -1,109 +1,51 @@ -use std::fmt::{Debug, Display}; +use std::fmt::Debug; use async_trait::async_trait; -use hex::FromHexError; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use crate::Transport; +use crate::{Transport, transport::Channel}; -pub struct SpeculosTransport { - pub client: C, +pub struct LedgerTransportTcp { + channel: C, } -impl SpeculosTransport { - pub fn new(client: C) -> Self { - Self { client } - } -} +#[derive(Debug, thiserror::Error)] +pub enum LedgerTcpError { + #[error("ledger io error: {0}")] + Io(#[from] std::io::Error), -#[derive(Serialize, Deserialize)] -/// Apdu response from speculos emulator -pub struct Apdu { - /// hex encoded apdu data - data: String, + #[error("ledger invalid response")] + InvalidResponse, } -#[async_trait(?Send)] -pub trait SpeculosClient { - type Error: Debug + From; - - /// The endpoint that the speculos emulator is listening on. - /// Ex. "localhost:5000" - fn url(&self) -> &str; - - async fn post(&self, endpoint: &str, json_req: Req) -> Result<(), Self::Error>; - - async fn post_json( - &self, - endpoint: &str, - json_req: Req, - ) -> Result; - - async fn button_press(&self, button: Button) -> Result<(), Self::Error> { - Self::post( - self, - &format!("{}/button/{button}", Self::url(self)), - &ButtonRequest { - action: "press-and-release".into(), - }, - ) - .await - } - - async fn set_automation(&self, automation_json: &T) -> Result<(), Self::Error> { - Self::post( - self, - &format!("{}/automation", Self::url(self)), - automation_json, - ) - .await +impl LedgerTransportTcp { + pub fn new(channel: C) -> Self { + Self { channel } } } #[async_trait(?Send)] -impl Transport for SpeculosTransport { - type Error = ::Error; +impl Transport for LedgerTransportTcp { + type Error = LedgerTcpError; async fn exchange( &mut self, apdu_command: &[u8], _encrypted: bool, ) -> Result, Self::Error> { - let Apdu { data } = self - .client - .post_json( - &format!("{}/apdu", self.client.url()), - &Apdu { - data: hex::encode(apdu_command), - }, - ) - .await?; - Ok(hex::decode(data)?) - } -} + let mut tx = Vec::with_capacity(4 + apdu_command.len()); + tx.extend_from_slice(&(apdu_command.len() as u32).to_be_bytes()); + tx.extend_from_slice(apdu_command); -#[derive(Debug, Clone, Copy)] -pub enum Button { - Left, - Right, - Both, -} + self.channel.send(&tx).await?; -impl Display for Button { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Button::Left => "left", - Button::Right => "right", - Button::Both => "both", - } - ) - } -} + let mut len_buf = [0u8; 4]; + self.channel.receive(&mut len_buf).await?; + + let resp_len = u32::from_be_bytes(len_buf) as usize; -#[derive(Serialize)] -struct ButtonRequest { - action: String, + let mut resp = vec![0u8; resp_len + 2]; + self.channel.receive(&mut resp).await?; + + Ok(resp) + } } diff --git a/bhwi-async/src/transport/mod.rs b/bhwi-async/src/transport/mod.rs index 0e8f0a9..d08e92c 100644 --- a/bhwi-async/src/transport/mod.rs +++ b/bhwi-async/src/transport/mod.rs @@ -4,6 +4,8 @@ pub mod ledger; use async_trait::async_trait; +pub use bhwi::device::DeviceId; + #[async_trait(?Send)] pub trait Channel { async fn send(&self, data: &[u8]) -> Result; diff --git a/bhwi-cli/Cargo.toml b/bhwi-cli/Cargo.toml index d68674c..0e52efd 100644 --- a/bhwi-cli/Cargo.toml +++ b/bhwi-cli/Cargo.toml @@ -13,9 +13,19 @@ name = "bhwi" path = "src/bin/bhwi.rs" [dependencies] +anyhow.workspace = true +async-trait.workspace = true bitcoin.workspace = true -bhwi-async.workspace = true -hex.workspace = true +bhwi-async = { workspace = true, features = ["emulators"] } +futures.workspace = true +hex = { workspace = true, features = ["serde"] } +rand_core.workspace = true +reqwest = { workspace = true, features = ["json"] } +serde.workspace = true +serde_json.workspace = true tokio = { workspace = true, features = ["macros", "net", "rt", "rt-multi-thread", "io-util", "sync"] } +async-hid = "0.5.0" clap = { version = "4.4.7", features = ["derive"] } +strum = { version = "0.28", features = ["derive"] } +tokio-serial = "5.4.5" diff --git a/bhwi-cli/src/bin/bhwi.rs b/bhwi-cli/src/bin/bhwi.rs index 90867fb..8c03a63 100644 --- a/bhwi-cli/src/bin/bhwi.rs +++ b/bhwi-cli/src/bin/bhwi.rs @@ -1,10 +1,11 @@ -use bhwi_cli::{Error, get_device_with_fingerprint, list}; +use anyhow::Result; +use bhwi_cli::{DeviceManager, config::Config}; use bitcoin::{ Network, bip32::{DerivationPath, Fingerprint}, }; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -16,11 +17,25 @@ struct Args { #[arg(long, alias = "fg", value_parser = clap::value_parser!(bitcoin::bip32::Fingerprint))] fingerprint: Option, /// default will be the Bitcoin mainnet network. - #[arg(long, value_parser = clap::value_parser!(bitcoin::Network), default_value_t = bitcoin::Network::Bitcoin)] + #[arg(long, short, value_parser = clap::value_parser!(bitcoin::Network), default_value_t = bitcoin::Network::Bitcoin)] network: Network, } -#[derive(Debug, Subcommand)] +impl From for Config { + fn from(args: Args) -> Self { + let Args { + network, + fingerprint, + .. + } = args; + Self { + network, + fingerprint, + } + } +} + +#[derive(Debug, Clone, Subcommand)] enum Commands { #[command(subcommand)] Device(DeviceCommands), @@ -28,32 +43,61 @@ enum Commands { Xpub(XpubCommands), } -#[derive(Debug, Subcommand)] +#[derive(Debug, Clone, Copy, Subcommand)] enum DeviceCommands { - List, + /// List all available devices + #[command(alias = "enumerate")] + List { + #[arg(long, short)] + format: Option, + }, } -#[derive(Debug, Subcommand)] +#[derive(Debug, Clone, Subcommand)] enum XpubCommands { Get { - #[arg(long, value_parser = clap::value_parser!(bitcoin::bip32::DerivationPath))] + #[arg(value_parser = clap::value_parser!(bitcoin::bip32::DerivationPath))] path: DerivationPath, }, } +#[derive(Debug, Clone, Copy, ValueEnum)] +enum ListFormat { + Pretty, + Json, +} + #[tokio::main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<()> { let args = Args::parse(); - match args.command { - Commands::Device(DeviceCommands::List) => { - for mut device in list(args.network).await? { - eprint!("{}", device.get_master_fingerprint().await?); + let command = args.command.to_owned(); + let config: Config = args.into(); + let dev_man = DeviceManager::new(config); + match command { + Commands::Device(DeviceCommands::List { format }) => { + let devices = dev_man.enumerate().await?; + for (i, mut device) in devices.into_iter().enumerate() { + device.device().unlock(dev_man.config.network).await?; + let fingerprint = device.fingerprint().await?; + let name = device.name(); + let is_emulated = device.is_emulated(); + match format { + Some(ListFormat::Pretty) => { + if i == 0 { + println!("{:<18} | {:<8} | {:<15}", "Name", "Emulated", "Fingerprint"); + } + println!("{}", "-".repeat(55)); + println!("{name:<18} | {is_emulated:<8} | {fingerprint:<15}"); + println!("{}", "-".repeat(55)); + } + Some(ListFormat::Json) => println!("{}", serde_json::to_string(&device)?), + None => println!("{fingerprint}"), + } } } Commands::Xpub(XpubCommands::Get { path }) => { - if let Some(mut d) = get_device_with_fingerprint(args.network, args.fingerprint).await? - { - eprintln!("{}", d.get_extended_pubkey(path, false).await?); + if let Some(mut d) = dev_man.get_device_with_fingerprint().await? { + println!("{}", d.device().get_extended_pubkey(path, false).await?); } } } diff --git a/bhwi-cli/src/coldcard.rs b/bhwi-cli/src/coldcard.rs new file mode 100644 index 0000000..e6145db --- /dev/null +++ b/bhwi-cli/src/coldcard.rs @@ -0,0 +1,140 @@ +use anyhow::Context; +use anyhow::Result; +use async_hid::Device as HidDevice; +use async_hid::HidBackend; +use async_trait::async_trait; +use bhwi_async::{ + coldcard::Coldcard, + transport::{ + DeviceId, + coldcard::hid::{COLDCARD_DEVICE_ID, ColdcardTransportHID}, + }, +}; +use futures::StreamExt; +use futures::TryStreamExt; +use futures::stream::iter; +use rand_core::OsRng; + +use crate::{Device, DeviceEnumerator, config::Config, hid::HidChannel}; + +pub type ColdcardHidDevice = Coldcard>; + +pub struct ColdcardDevice; + +impl ColdcardDevice { + async fn hid_device(hid_dev: HidDevice, rng: &mut OsRng) -> Result> { + Ok(Some( + Device::new( + &hid_dev.name, + Box::new(Coldcard::new( + ColdcardTransportHID::new(HidChannel::new(hid_dev.open().await?)), + rng, + )), + false, + ) + .await?, + )) + } + + #[cfg(unix)] + async fn emulator_device(path: &str, rng: &mut OsRng) -> Result> { + if std::fs::exists(path)? { + Ok(Some( + Device::new( + "Coldcard Emulator", + Box::new(Coldcard::new( + ColdcardTransportHID::new(emulator::EmulatorClient::new(path).await?), + rng, + )), + true, + ) + .await?, + )) + } else { + Ok(None) + } + } + + #[cfg(not(unix))] + async fn emulator_device(_path: &str, _rng: &mut OsRng) -> Result> { + Ok(None) + } +} + +#[async_trait(?Send)] +impl DeviceEnumerator for ColdcardDevice { + async fn enumerate(_config: &Config) -> Result> { + let DeviceId { + vid, + pid, + emulator_path, + .. + } = COLDCARD_DEVICE_ID; + let mut rng = OsRng; + let devices = HidBackend::default() + .enumerate() + .await? + .map(Ok) + .try_filter_map(|dev| async move { + if dev.vendor_id == vid && dev.product_id == pid.context("coldcard pid not set")? { + Self::hid_device(dev, &mut rng).await + } else { + Ok(None) + } + }) + .chain( + iter(emulator_path.map(Ok).into_iter()).try_filter_map(|path| async move { + Self::emulator_device(path, &mut rng).await + }), + ) + .try_collect() + .await?; + Ok(devices) + } +} + +#[cfg(unix)] +pub mod emulator { + use std::sync::Arc; + + use anyhow::Result; + use async_trait::async_trait; + use bhwi_async::{ + coldcard::Coldcard, + transport::{Channel, coldcard::hid::ColdcardTransportHID}, + }; + use tokio::net::UnixDatagram; + + const CLIENT_SOCKET: &str = "/tmp/bhwi-ckcc-client.sock"; + + pub type ColdcardSocketDevice = Coldcard>; + + #[derive(Clone)] + pub struct EmulatorClient { + /// the ckcc simulator socket (used for ckcc cli too) + socket: Arc, + } + + impl EmulatorClient { + pub async fn new(socket_path: &str) -> Result { + let _ = std::fs::remove_file(CLIENT_SOCKET); + let socket = UnixDatagram::bind(CLIENT_SOCKET)?; + socket.connect(socket_path)?; + Ok(Self { + socket: Arc::new(socket), + }) + } + } + + #[async_trait(?Send)] + impl Channel for EmulatorClient { + async fn send(&self, data: &[u8]) -> Result { + self.socket.send(data).await?; + Ok(data.len()) + } + + async fn receive(&mut self, data: &mut [u8]) -> Result { + Ok(self.socket.recv(data).await?) + } + } +} diff --git a/bhwi-cli/src/config.rs b/bhwi-cli/src/config.rs new file mode 100644 index 0000000..6432440 --- /dev/null +++ b/bhwi-cli/src/config.rs @@ -0,0 +1,8 @@ +use bitcoin::{Network, bip32::Fingerprint}; + +// TODO: eventually have this be parsable by toml/yaml, env vars +#[derive(Debug)] +pub struct Config { + pub network: Network, + pub fingerprint: Option, +} diff --git a/bhwi-cli/src/hid.rs b/bhwi-cli/src/hid.rs new file mode 100644 index 0000000..85c9282 --- /dev/null +++ b/bhwi-cli/src/hid.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use async_hid::{AsyncHidRead, AsyncHidWrite, DeviceReaderWriter}; +use async_trait::async_trait; +use bhwi_async::transport::Channel; +use tokio::sync::Mutex; + +pub struct HidChannel { + device: Arc>, +} + +impl HidChannel { + pub fn new(device: DeviceReaderWriter) -> Self { + Self { + device: Arc::new(Mutex::new(device)), + } + } +} + +#[async_trait(?Send)] +impl Channel for HidChannel { + async fn send(&self, data: &[u8]) -> Result { + self.device + .lock() + .await + .write_output_report(data) + .await + .map_err(std::io::Error::other)?; + Ok(data.len()) + } + + async fn receive(&mut self, data: &mut [u8]) -> Result { + self.device + .lock() + .await + .read_input_report(data) + .await + .map_err(std::io::Error::other) + } +} diff --git a/bhwi-cli/src/jade.rs b/bhwi-cli/src/jade.rs new file mode 100644 index 0000000..bd7bec6 --- /dev/null +++ b/bhwi-cli/src/jade.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use bhwi_async::{ + HttpClient, Jade, Transport, + transport::jade::{ + JADE_DEVICE_IDS, + tcp::{TcpClient as TcpClientTrait, TcpTransport}, + }, +}; +use bitcoin::Network; +use futures::{TryStreamExt, stream::iter}; +use reqwest::Client; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + sync::Mutex, +}; +use tokio_serial::{ + SerialPort, SerialPortBuilderExt, SerialPortType, SerialStream, UsbPortInfo, available_ports, +}; + +use crate::{Device, DeviceEnumerator, config::Config}; + +pub type JadeSerialDevice = Jade; +pub type JadeQemuDevice = Jade, PinServerClient>; + +pub const DEFAULT_JADE_BAUD_RATE: u32 = 115200; +pub const DEFAULT_JADE_QEMU_ADDRESS: &str = "localhost:30121"; + +pub struct SerialTransport { + stream: Arc>, +} + +impl SerialTransport { + pub fn new(port_name: &str) -> Result { + let mut transport = + tokio_serial::new(port_name, DEFAULT_JADE_BAUD_RATE).open_native_async()?; + // Ensure RTS and DTR are not set (as this can cause the hw to reboot) + // according to https://github.com/Blockstream/Jade/blob/master/jadepy/jade_serial.py#L56 + transport.write_request_to_send(false)?; + transport.write_data_terminal_ready(false)?; + Ok(Self { + stream: Arc::new(Mutex::new(transport)), + }) + } +} + +#[async_trait(?Send)] +impl Transport for SerialTransport { + type Error = std::io::Error; + async fn exchange(&mut self, command: &[u8], _encrypted: bool) -> Result, Self::Error> { + let mut stream = self.stream.lock().await; + stream.write_all(command).await?; + let mut buf = vec![]; + stream.read_to_end(&mut buf).await?; + Ok(buf) + } +} + +pub struct JadeDevice; + +impl JadeDevice { + fn valid_usb(info: &UsbPortInfo) -> bool { + JADE_DEVICE_IDS + .iter() + .find(|id| id.vid == info.vid && id.pid == Some(info.pid)) + .is_some() + } + + async fn serial_device( + network: Network, + port_name: &str, + info: UsbPortInfo, + ) -> Result> { + Ok(Some( + Device::new( + &format!( + "{} {}", + info.product.unwrap_or_else(|| "Jade".into()), + info.manufacturer.unwrap_or_else(|| "Blockstream".into()) + ), + Box::new(JadeSerialDevice::new( + network, + SerialTransport::new(port_name)?, + PinServerClient::new(), + )), + false, + ) + .await?, + )) + } + + async fn qemu_device(network: Network, stream: TcpStream) -> Result { + Device::new( + "Jade QEMU Emulator", + Box::new(JadeQemuDevice::new( + network, + TcpTransport::new(TcpClient::new(stream)), + PinServerClient::new(), + )), + true, + ) + .await + } +} + +#[async_trait(?Send)] +impl DeviceEnumerator for JadeDevice { + async fn enumerate(config: &Config) -> Result> { + let mut devices: Vec = iter(available_ports()?.into_iter().map(Ok)) + .try_filter_map(|info| async move { + match info.port_type { + SerialPortType::UsbPort(usb) if Self::valid_usb(&usb) => { + Self::serial_device(config.network, &info.port_name, usb).await + } + _ => Ok(None), + } + }) + .try_collect() + .await?; + if let Ok(stream) = TcpStream::connect(DEFAULT_JADE_QEMU_ADDRESS).await { + devices.push(Self::qemu_device(config.network, stream).await?); + } + Ok(devices) + } +} + +pub struct PinServerClient { + inner: Client, +} + +impl PinServerClient { + pub fn new() -> Self { + Self { + inner: Client::new(), + } + } +} + +impl Default for PinServerClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait(?Send)] +impl HttpClient for PinServerClient { + type Error = reqwest::Error; + + async fn request(&self, url: &str, request: &[u8]) -> Result, Self::Error> { + Ok(self + .inner + .post(url) + .header("Content-Type", "application/octet-stream") + .body(request.to_vec()) + .send() + .await? + .bytes() + .await? + .to_vec()) + } +} + +pub struct TcpClient { + stream: TcpStream, +} + +impl TcpClient { + pub fn new(stream: TcpStream) -> Self { + Self { stream } + } +} + +#[async_trait(?Send)] +impl TcpClientTrait for TcpClient { + async fn write_all(&mut self, command: &[u8]) -> Result<(), std::io::Error> { + Ok(self.stream.write_all(command).await?) + } + async fn read(&mut self, buf: &mut [u8]) -> Result { + Ok(self.stream.read(buf).await?) + } +} diff --git a/bhwi-cli/src/ledger.rs b/bhwi-cli/src/ledger.rs new file mode 100644 index 0000000..42c0a79 --- /dev/null +++ b/bhwi-cli/src/ledger.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use async_hid::Device as HidDevice; +use async_hid::HidBackend; +use async_trait::async_trait; +use bhwi_async::{ + Ledger, + transport::{ + Channel, DeviceId, + ledger::{ + hid::{LEDGER_DEVICE_ID, LedgerTransportHID}, + speculos::LedgerTransportTcp, + }, + }, +}; +use futures::stream::{StreamExt, TryStreamExt, iter}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + sync::Mutex, +}; + +use crate::{Device, DeviceEnumerator, config::Config, hid::HidChannel}; + +pub type LedgerHidDevice = Ledger>; +pub type LedgerSpeculosDevice = Ledger>; + +pub struct LedgerDevice; + +impl LedgerDevice { + async fn hid_device(dev: HidDevice) -> Result> { + Ok(Some( + Device::new( + &dev.name, + Box::new(LedgerHidDevice::new(LedgerTransportHID::new( + HidChannel::new(dev.open().await?), + ))), + false, + ) + .await?, + )) + } + + async fn speculos_device(stream: TcpStream) -> Result { + Device::new( + "Ledger Speculos Emulator", + Box::new(LedgerSpeculosDevice::new(LedgerTransportTcp::new( + SpeculosTcpChannel { + stream: Arc::new(Mutex::new(stream)), + }, + ))), + true, + ) + .await + } +} + +#[async_trait(?Send)] +impl DeviceEnumerator for LedgerDevice { + async fn enumerate(_config: &Config) -> Result> { + let DeviceId { + vid, + usage_page, + emulator_path, + .. + } = LEDGER_DEVICE_ID; + let devices = HidBackend::default() + .enumerate() + .await? + .map(Ok) + .try_filter_map(|dev| async move { + if dev.vendor_id == vid + && dev.usage_page == usage_page.context("ledger usage page constant not set")? + { + Self::hid_device(dev).await + } else { + Ok(None) + } + }) + .chain( + iter(emulator_path.map(Ok).into_iter()).try_filter_map(|path| async move { + if let Ok(stream) = TcpStream::connect(path).await { + Ok(Some(Self::speculos_device(stream).await?)) + } else { + Ok(None) + } + }), + ) + .try_collect() + .await?; + Ok(devices) + } +} + +pub struct SpeculosTcpChannel { + stream: Arc>, +} + +impl SpeculosTcpChannel { + pub fn new(stream: TcpStream) -> Self { + Self { + stream: Arc::new(Mutex::new(stream)), + } + } +} + +#[async_trait(?Send)] +impl Channel for SpeculosTcpChannel { + async fn send(&self, data: &[u8]) -> Result { + self.stream.lock().await.write_all(data).await?; + Ok(data.len()) + } + + async fn receive(&mut self, data: &mut [u8]) -> Result { + self.stream.lock().await.read_exact(data).await + } +} diff --git a/bhwi-cli/src/lib.rs b/bhwi-cli/src/lib.rs index 21fdaac..09c9d92 100644 --- a/bhwi-cli/src/lib.rs +++ b/bhwi-cli/src/lib.rs @@ -1,24 +1,123 @@ -use bhwi_async::{Error as HWIError, HWI}; -use bitcoin::{Network, bip32::Fingerprint}; +use anyhow::Result; +use async_trait::async_trait; +use bhwi_async::HWIDevice; +use bitcoin::bip32::Fingerprint; +use futures::future::join_all; +use serde::{Serialize, Serializer}; +use strum::{EnumIter, IntoEnumIterator}; -pub type Error = HWIError<(), ()>; +use crate::{coldcard::ColdcardDevice, config::Config, jade::JadeDevice, ledger::LedgerDevice}; -pub async fn get_device_with_fingerprint( - network: Network, +pub mod coldcard; +pub mod config; +pub mod hid; +pub mod jade; +pub mod ledger; + +#[derive(Serialize)] +pub struct Device { + name: String, + #[serde(skip)] + device: Box, + is_emulated: bool, + #[serde(default, serialize_with = "option_fingerprint")] fingerprint: Option, -) -> Result>>, Error> { - for mut device in list(network).await? { - if let Some(fingerprint) = fingerprint { - if fingerprint == device.get_master_fingerprint().await? { - return Ok(Some(device)); - } +} + +impl Device { + pub async fn new(name: &str, device: Box, is_emulated: bool) -> Result { + Ok(Self { + name: name.into(), + device, + is_emulated, + fingerprint: None, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn device(&mut self) -> &mut Box { + &mut self.device + } + + pub fn is_emulated(&self) -> bool { + self.is_emulated + } + + pub async fn fingerprint(&mut self) -> Result { + if let Some(fingerprint) = self.fingerprint { + Ok(fingerprint) } else { - return Ok(Some(device)); + let fingerprint = self.device.get_master_fingerprint().await?; + self.fingerprint = Some(fingerprint); + Ok(fingerprint) } } - Ok(None) } -pub async fn list(_network: Network) -> Result + Send>>, Error> { - Ok(Vec::new()) +#[derive(Debug, EnumIter, strum::Display)] +pub enum DeviceType { + Coldcard, + Jade, + Ledger, +} + +impl DeviceType { + pub async fn enumerate(self, config: &Config) -> Result> { + Ok(match self { + DeviceType::Ledger => LedgerDevice::enumerate(config).await?, + DeviceType::Coldcard => ColdcardDevice::enumerate(config).await?, + DeviceType::Jade => JadeDevice::enumerate(config).await?, + }) + } +} + +pub struct DeviceManager { + pub config: Config, +} + +impl DeviceManager { + pub fn new(config: Config) -> Self { + Self { config } + } + + pub async fn get_device_with_fingerprint(&self) -> Result> { + for mut d in self.enumerate().await? { + d.device.unlock(self.config.network).await?; + if let Some(fingerprint) = self.config.fingerprint { + if fingerprint == d.fingerprint().await? { + return Ok(Some(d)); + } + } else { + return Ok(Some(d)); + } + } + Ok(None) + } + + pub async fn enumerate(&self) -> Result> { + let res = join_all(DeviceType::iter().map(|t| t.enumerate(&self.config))) + .await + .into_iter() + .collect::>>()?; + Ok(res.into_iter().flatten().collect()) + } +} + +#[async_trait(?Send)] +pub trait DeviceEnumerator { + async fn enumerate(config: &Config) -> Result>; +} + +fn option_fingerprint(value: &Option, ser: S) -> Result +where + S: Serializer, +{ + if let Some(v) = value { + hex::serialize(v, ser) + } else { + ser.serialize_none() + } } diff --git a/bhwi-wasm/Cargo.toml b/bhwi-wasm/Cargo.toml index 1746533..e588ebe 100644 --- a/bhwi-wasm/Cargo.toml +++ b/bhwi-wasm/Cargo.toml @@ -19,6 +19,7 @@ bhwi-async.workspace = true futures.workspace = true log.workspace = true rand_core.workspace = true +thiserror.workspace = true console_log = "0.2" console_error_panic_hook = "0.1.7" diff --git a/bhwi-wasm/src/lib.rs b/bhwi-wasm/src/lib.rs index 36b570c..9fe3ca6 100644 --- a/bhwi-wasm/src/lib.rs +++ b/bhwi-wasm/src/lib.rs @@ -6,11 +6,10 @@ pub mod webserial; use std::str::FromStr; use async_trait::async_trait; +use bhwi::{coldcard::COLDCARD_DEVICE_ID, ledger::LEDGER_DEVICE_ID}; use bhwi_async::{ - HWI as AsyncHWI, Jade, Ledger, - coldcard::Coldcard, - transport::coldcard::hid::{COLDCARD_VID, ColdcardTransportHID}, - transport::ledger::hid::{LEDGER_VID, LedgerTransportHID}, + HWI as AsyncHWI, Jade, Ledger, coldcard::Coldcard, + transport::coldcard::hid::ColdcardTransportHID, transport::ledger::hid::LedgerTransportHID, }; use bitcoin::{Network, bip32::DerivationPath}; use log::Level; @@ -102,9 +101,10 @@ impl Client { #[wasm_bindgen] pub async fn connect_coldcard(&mut self, on_close_cb: JsValue) -> Result<(), JsValue> { - let device = WebHidDevice::get_webhid_device("Coldcard", COLDCARD_VID, None, on_close_cb) - .await - .ok_or(JsValue::from_str("Failed to connect to coldcard"))?; + let device = + WebHidDevice::get_webhid_device("Coldcard", COLDCARD_DEVICE_ID.vid, None, on_close_cb) + .await + .ok_or(JsValue::from_str("Failed to connect to coldcard"))?; let mut rng = rand_core::OsRng; self.device = Some(Device::Coldcard(Coldcard::new( ColdcardTransportHID::new(device), @@ -115,9 +115,10 @@ impl Client { #[wasm_bindgen] pub async fn connect_ledger(&mut self, on_close_cb: JsValue) -> Result<(), JsValue> { - let device = WebHidDevice::get_webhid_device("Ledger", LEDGER_VID, None, on_close_cb) - .await - .ok_or(JsValue::from_str("Failed to connect to ledger"))?; + let device = + WebHidDevice::get_webhid_device("Ledger", LEDGER_DEVICE_ID.vid, None, on_close_cb) + .await + .ok_or(JsValue::from_str("Failed to connect to ledger"))?; self.device = Some(Device::Ledger(Ledger::new(LedgerTransportHID::new(device)))); Ok(()) } @@ -164,3 +165,13 @@ impl Client { } } } + +#[derive(Debug, thiserror::Error)] +#[error("WASM error: {0}")] +pub struct WasmError(String); + +impl From for WasmError { + fn from(value: JsValue) -> Self { + Self(value.as_string().unwrap_or_else(|| format!("{:?}", value))) + } +} diff --git a/bhwi-wasm/src/pinserver.rs b/bhwi-wasm/src/pinserver.rs index 8292cfa..826c702 100644 --- a/bhwi-wasm/src/pinserver.rs +++ b/bhwi-wasm/src/pinserver.rs @@ -5,11 +5,13 @@ use wasm_bindgen::JsValue; use wasm_bindgen_futures::JsFuture; use web_sys::{Headers, Request, RequestInit, RequestMode, Response}; +use crate::WasmError; + pub struct PinServer; #[async_trait(?Send)] impl HttpClient for PinServer { - type Error = JsValue; + type Error = WasmError; async fn request(&self, url: &str, body: &[u8]) -> Result, Self::Error> { // Set up request parameters let opts = RequestInit::new(); @@ -35,7 +37,7 @@ impl HttpClient for PinServer { // Ensure the response is OK if !resp.ok() { - return Err(JsValue::from_str("Network error or non-OK response")); + return Err(JsValue::from_str("Network error or non-OK response").into()); } // Parse the response as an array buffer and convert to Vec diff --git a/bhwi-wasm/src/webserial.rs b/bhwi-wasm/src/webserial.rs index 9f88437..616e634 100644 --- a/bhwi-wasm/src/webserial.rs +++ b/bhwi-wasm/src/webserial.rs @@ -9,6 +9,8 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use web_sys::{ReadableStreamDefaultReader, SerialOptions, SerialPort, SerialPortRequestOptions}; +use crate::WasmError; + #[wasm_bindgen] pub struct WebSerialDevice { port: SerialPort, @@ -195,7 +197,7 @@ impl WebSerialDevice { #[async_trait(?Send)] impl Transport for WebSerialDevice { - type Error = JsValue; + type Error = WasmError; async fn exchange(&mut self, command: &[u8], _encrypted: bool) -> Result, Self::Error> { self.write(command).await?; Ok(self.read().await.unwrap()) diff --git a/bhwi/Cargo.toml b/bhwi/Cargo.toml index 56f680c..94b1071 100644 --- a/bhwi/Cargo.toml +++ b/bhwi/Cargo.toml @@ -21,6 +21,7 @@ log.workspace = true # TODO: remove me serde = { workspace = true, features = ["derive"], optional = true } serde_json.workspace = true serde_cbor = { workspace = true, optional = true } +thiserror.workspace = true serde_bytes = { version = "0.11.14", optional = true } diff --git a/bhwi/src/coldcard/mod.rs b/bhwi/src/coldcard/mod.rs index 5729e9e..84d1f95 100644 --- a/bhwi/src/coldcard/mod.rs +++ b/bhwi/src/coldcard/mod.rs @@ -9,16 +9,31 @@ use bitcoin::secp256k1::ecdsa::Signature; use crate::Interpreter; use crate::coldcard::api::response::ResponseMessage; use crate::common::{Command, Error, Recipient, Response, Transmit}; +use crate::device::DeviceId; -#[derive(Debug)] +pub const DEFAULT_CKCC_SOCKET: &str = "/tmp/ckcc-simulator.sock"; +pub const COLDCARD_DEVICE_ID: DeviceId = DeviceId::new(0xd13e) + .with_pid(0xcc10) + .with_emulator_path(DEFAULT_CKCC_SOCKET); + +#[derive(Debug, thiserror::Error)] pub enum ColdcardError { /// Encryption error + #[error("encryption error: {0}")] Encryption(&'static str), + + #[error("missing command info: {0}")] MissingCommandInfo(&'static str), + + #[error("no error or result returned")] NoErrorOrResult, + /// Serialization error + #[error("serialization error: {0}")] Serialization(String), + /// Unexpected response message from device + #[error("unexpected response message: got {got:?}, expected {expected:?}")] UnexpectedResponseMessage { got: ResponseMessage, expected: Vec, diff --git a/bhwi/src/common.rs b/bhwi/src/common.rs index b599eff..b012e42 100644 --- a/bhwi/src/common.rs +++ b/bhwi/src/common.rs @@ -44,16 +44,30 @@ pub struct Transmit { pub encrypted: bool, } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum Error { + #[error("encryption error: {0}")] Encryption(&'static str), + + #[error("no error or result returned")] NoErrorOrResult, + + #[error("missing command info: {0}")] MissingCommandInfo(&'static str), + + #[error("unexpected result: {0:x?}")] UnexpectedResult(Vec), - // Generic RPC/communication errors - Rpc(i32, Option), // (code, message) + + #[error("rpc error {0}: {1:?}")] + Rpc(i32, Option), + + #[error("serialization error: {0}")] Serialization(String), + + #[error("request error: {0}")] Request(&'static str), + + #[error("authentication refused")] AuthenticationRefused, } diff --git a/bhwi/src/device.rs b/bhwi/src/device.rs new file mode 100644 index 0000000..871e03d --- /dev/null +++ b/bhwi/src/device.rs @@ -0,0 +1,33 @@ +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct DeviceId { + pub vid: u16, + pub pid: Option, + pub usage_page: Option, + pub emulator_path: Option<&'static str>, +} + +impl DeviceId { + pub const fn new(vid: u16) -> DeviceId { + DeviceId { + vid, + pid: None, + usage_page: None, + emulator_path: None, + } + } + + pub const fn with_pid(mut self, pid: u16) -> DeviceId { + self.pid = Some(pid); + self + } + + pub const fn with_usage_page(mut self, usage_page: u16) -> DeviceId { + self.usage_page = Some(usage_page); + self + } + + pub const fn with_emulator_path(mut self, path: &'static str) -> DeviceId { + self.emulator_path = Some(path); + self + } +} diff --git a/bhwi/src/jade/mod.rs b/bhwi/src/jade/mod.rs index 8fa9f1a..8691789 100644 --- a/bhwi/src/jade/mod.rs +++ b/bhwi/src/jade/mod.rs @@ -12,10 +12,20 @@ use serde::de::DeserializeOwned; use crate::Interpreter; use crate::common::{Command, Error, Recipient, Response, Transmit}; +use crate::device::DeviceId; pub const JADE_NETWORK_MAINNET: &str = "mainnet"; pub const JADE_NETWORK_TESTNET: &str = "testnet"; +pub const JADE_DEVICE_IDS: [DeviceId; 6] = [ + DeviceId::new(0x10c4).with_pid(0xea60), + DeviceId::new(0x1a86).with_pid(0x55d4), + DeviceId::new(0x0403).with_pid(0x6001), + DeviceId::new(0x1a86).with_pid(0x7523), + DeviceId::new(0x303a).with_pid(0x4001), + DeviceId::new(0x303a).with_pid(0x1001), +]; + #[derive(Debug)] pub enum JadeError { NoErrorOrResult, @@ -160,7 +170,7 @@ where "sign_message", Some(api::SignMessageParams { path: path.to_u32_vec(), - message: str::from_utf8(message) + message: &String::from_utf8(message.to_vec()) .map_err(|e| JadeError::Serialization(e.to_string()))?, }), ), diff --git a/bhwi/src/ledger/apdu.rs b/bhwi/src/ledger/apdu.rs index 5379f66..f2b195e 100644 --- a/bhwi/src/ledger/apdu.rs +++ b/bhwi/src/ledger/apdu.rs @@ -174,8 +174,11 @@ impl TryFrom> for ApduResponse { } } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ApduError { + #[error("unknown status word")] StatusWordUnknown, + + #[error("response too short")] ResponseTooShort, } diff --git a/bhwi/src/ledger/mod.rs b/bhwi/src/ledger/mod.rs index 4251bf7..27e2e14 100644 --- a/bhwi/src/ledger/mod.rs +++ b/bhwi/src/ledger/mod.rs @@ -18,28 +18,34 @@ pub use wallet::{WalletPolicy, WalletPubKey}; use crate::Interpreter; use crate::common::{Command, Error, Response}; +use crate::device::DeviceId; -#[derive(Debug)] +pub const LEDGER_DEVICE_ID: DeviceId = DeviceId::new(0x2c97) + .with_usage_page(0xffa0) + .with_emulator_path("localhost:9999"); + +#[derive(Debug, thiserror::Error)] pub enum LedgerError { + #[error("missing command info: {0}")] MissingCommandInfo(&'static str), + + #[error("no error or result returned")] NoErrorOrResult, - Apdu(ApduError), - Store(StoreError), + + #[error("APDU error")] + Apdu(#[from] ApduError), + + #[error("store error")] + Store(#[from] StoreError), + + #[error("operation interrupted")] Interrupted, - UnexpectedResult(Vec), - FailedToOpenApp(Vec), -} -impl From for LedgerError { - fn from(value: ApduError) -> Self { - LedgerError::Apdu(value) - } -} + #[error("unexpected result: {0:x?}")] + UnexpectedResult(Vec), -impl From for LedgerError { - fn from(value: StoreError) -> Self { - LedgerError::Store(value) - } + #[error("failed to open app: {0:x?}")] + FailedToOpenApp(Vec), } #[derive(Clone, Debug)] diff --git a/bhwi/src/ledger/store.rs b/bhwi/src/ledger/store.rs index 6cd6a52..778eb67 100644 --- a/bhwi/src/ledger/store.rs +++ b/bhwi/src/ledger/store.rs @@ -296,13 +296,26 @@ pub fn get_merkleized_map_commitment(mapping: &[(Vec, Vec)]) -> Vec commitment } -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum StoreError { + #[error("empty input")] EmptyInput, + + #[error("unknown command: {0}")] UnknownCommand(u8), + + #[error("unsupported request: {0}")] UnsupportedRequest(u8), + + #[error("invalid index or size")] InvalidIndexOrSize, + + #[error("unknown hash")] UnknownHash, + + #[error("unknown merkle root")] UnknownMerkleRoot, + + #[error("unexpected queue state")] UnexpectedQueue, } diff --git a/bhwi/src/lib.rs b/bhwi/src/lib.rs index 0018be5..308c4b7 100644 --- a/bhwi/src/lib.rs +++ b/bhwi/src/lib.rs @@ -2,6 +2,7 @@ pub use bitcoin; pub mod coldcard; pub mod common; +pub mod device; pub mod jade; pub mod ledger; diff --git a/docs/COLDCARD.md b/docs/COLDCARD.md index 7c42084..fc2be7e 100644 --- a/docs/COLDCARD.md +++ b/docs/COLDCARD.md @@ -3,7 +3,7 @@ ## Installation Follow instructions [here](https://github.com/Coldcard/firmware/blob/master/README.md) to build -and simulate a coldcard device. +and emulate a coldcard device. ### Caveat @@ -81,6 +81,5 @@ podman run --rm -it \ -v /tmp/.X11-unix:/tmp/.X11-unix \ -v $PWD/microSD:/build/firmware/unix/work/MicroSD \ -e DISPLAY=unix$DISPLAY \ - -p 9999:9999 \ coldcard-simulator ``` diff --git a/docs/LEDGER.md b/docs/LEDGER.md index 22eea9c..fa8b2d7 100644 --- a/docs/LEDGER.md +++ b/docs/LEDGER.md @@ -33,7 +33,7 @@ The .elf and .apdu files will be available in `build/nanox/bin/` ```sh -podman run --rm -ti -v "$(realpath .):/app" --user $(id -u):$(id -g) -p 5000:5000 ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools:latest +podman run --rm -ti -v "$(realpath .):/app" --user $(id -u):$(id -g) -p 9999:9999 -p 5000:5000 ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools:latest speculos build/nanox/bin/app.elf --model nanox --display headless ``` diff --git a/e2e/coldcard/Cargo.toml b/e2e/coldcard/Cargo.toml index 5ad7d58..ef280b0 100644 --- a/e2e/coldcard/Cargo.toml +++ b/e2e/coldcard/Cargo.toml @@ -11,3 +11,5 @@ base64ct = { workspace = true, features = ["alloc"] } bhwi-async.workspace = true rand_core.workspace = true tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "sync", "time"] } + +bhwi-cli = { path = "../../bhwi-cli" } diff --git a/e2e/coldcard/src/lib.rs b/e2e/coldcard/src/lib.rs index aff569f..49a944d 100644 --- a/e2e/coldcard/src/lib.rs +++ b/e2e/coldcard/src/lib.rs @@ -1,58 +1,17 @@ -use std::sync::Arc; - use anyhow::Result; -use async_trait::async_trait; use bhwi_async::Transport; use bhwi_async::coldcard::Coldcard; -use bhwi_async::transport::Channel; use bhwi_async::transport::coldcard::hid::ColdcardTransportHID; -use tokio::net::UnixDatagram; - -const CLIENT_SOCKET: &str = "/tmp/rust-ckcc-client.sock"; - -pub type ColdcardDevice = Coldcard>; - -#[derive(Clone)] -pub struct SimulatorClient { - /// the ckcc simulator socket (used for ckcc cli too) - socket: Arc, -} - -impl SimulatorClient { - pub async fn new(socket_path: &str) -> Self { - let _ = std::fs::remove_file(CLIENT_SOCKET); - let socket = UnixDatagram::bind(CLIENT_SOCKET).expect("unbound socket"); - socket - .connect(socket_path) - .expect("couldn't connect to socket"); - Self { - socket: Arc::new(socket), - } - } - - pub async fn default() -> Self { - Self::new("/tmp/ckcc-simulator.sock").await - } -} +use bhwi_cli::coldcard::emulator::EmulatorClient; -#[async_trait(?Send)] -impl Channel for SimulatorClient { - async fn send(&self, data: &[u8]) -> Result { - self.socket.send(data).await?; - Ok(data.len()) - } - - async fn receive(&mut self, data: &mut [u8]) -> Result { - Ok(self.socket.recv(data).await?) - } -} +pub type ColdcardDevice = Coldcard>; pub struct DeviceControl { - client: ColdcardTransportHID, + client: ColdcardTransportHID, } impl DeviceControl { - pub fn new(client: SimulatorClient) -> Self { + pub fn new(client: EmulatorClient) -> Self { Self { client: ColdcardTransportHID::new(client), } @@ -70,14 +29,14 @@ impl DeviceControl { #[cfg(test)] mod tests { use base64ct::{Base64, Encoding}; - use bhwi_async::HWI; + use bhwi_async::{HWI, transport::coldcard::DEFAULT_CKCC_SOCKET}; use bitcoin::Network; use super::*; async fn device() -> (ColdcardDevice, DeviceControl) { let mut rng = rand_core::OsRng; - let client = SimulatorClient::default().await; + let client = EmulatorClient::new(DEFAULT_CKCC_SOCKET).await.unwrap(); let mut dev = ColdcardDevice::new(ColdcardTransportHID::new(client.clone()), &mut rng); let control = DeviceControl::new(client); dev.unlock(Network::Testnet).await.expect("can't unlock"); @@ -105,7 +64,7 @@ mod tests { } // NOTE: this can be unstable if you repeat it quickly. It seems that the - // simulator along with the simulated device input sharing the same socket + // emulator along with the emulated device input sharing the same socket // can interfere and sometimes return junk data. #[tokio::test] async fn can_sign_message() { diff --git a/e2e/jade/Cargo.toml b/e2e/jade/Cargo.toml index 9caaf06..70b98c0 100644 --- a/e2e/jade/Cargo.toml +++ b/e2e/jade/Cargo.toml @@ -12,3 +12,5 @@ bitcoin.workspace = true reqwest.workspace = true serde_cbor.workspace = true tokio = { workspace = true, features = ["macros"] } + +bhwi-cli = { path = "../../bhwi-cli" } diff --git a/e2e/jade/src/lib.rs b/e2e/jade/src/lib.rs index 63cdb02..f08eb2e 100644 --- a/e2e/jade/src/lib.rs +++ b/e2e/jade/src/lib.rs @@ -1,67 +1,19 @@ -use async_trait::async_trait; -use bhwi_async::transport::jade::tcp::{TcpClient, TcpTransport}; -use bhwi_async::{HttpClient, Jade}; -use reqwest::Client as ReqwestClient; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; - -pub type JadeDevice = Jade, PinServerClient>; - -pub struct Client { - stream: TcpStream, -} - -#[async_trait(?Send)] -impl TcpClient for Client { - type Error = anyhow::Error; - - async fn write_all(&mut self, command: &[u8]) -> Result<(), Self::Error> { - Ok(self.stream.write_all(command).await?) - } - async fn read(&mut self, buf: &mut [u8]) -> Result { - Ok(self.stream.read(buf).await?) - } -} - -pub struct PinServerClient { - inner: ReqwestClient, -} - -#[async_trait(?Send)] -impl HttpClient for PinServerClient { - type Error = anyhow::Error; - - async fn request(&self, url: &str, request: &[u8]) -> Result, Self::Error> { - Ok(self - .inner - .post(url) - .header("Content-Type", "application/octet-stream") - .body(request.to_vec()) - .send() - .await? - .bytes() - .await? - .to_vec()) - } -} - #[cfg(test)] mod tests { use base64ct::{Base64, Encoding}; use bhwi_async::HWI; + use bhwi_async::transport::jade::tcp::TcpTransport; + use bhwi_cli::jade::{JadeQemuDevice, PinServerClient, TcpClient}; use bitcoin::Network; + use tokio::net::TcpStream; - use super::*; - - async fn device() -> JadeDevice { - let mut dev = JadeDevice::new( + async fn device() -> JadeQemuDevice { + let mut dev = JadeQemuDevice::new( Network::Testnet, - TcpTransport::new(Client { - stream: TcpStream::connect("localhost:30121").await.unwrap(), - }), - PinServerClient { - inner: ReqwestClient::new(), - }, + TcpTransport::new(TcpClient::new( + TcpStream::connect("localhost:30121").await.unwrap(), + )), + PinServerClient::new(), ); dev.unlock(Network::Testnet).await.expect("jade auth"); dev diff --git a/e2e/ledger/Cargo.toml b/e2e/ledger/Cargo.toml index 80c1c2b..9daf8b5 100644 --- a/e2e/ledger/Cargo.toml +++ b/e2e/ledger/Cargo.toml @@ -13,3 +13,5 @@ serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["macros"] } reqwest = { workspace = true, features = ["json"] } + +bhwi-cli = { path = "../../bhwi-cli" } diff --git a/e2e/ledger/src/lib.rs b/e2e/ledger/src/lib.rs index 7743efb..ee71cef 100644 --- a/e2e/ledger/src/lib.rs +++ b/e2e/ledger/src/lib.rs @@ -1,87 +1,102 @@ -use async_trait::async_trait; -use bhwi_async::{ - Ledger, - transport::ledger::speculos::{SpeculosClient, SpeculosTransport}, -}; -use reqwest::Client; -use serde::{Serialize, de::DeserializeOwned}; - -pub type SpeculosDevice = Ledger>; - -pub struct SpeculosReqwestClient { - endpoint: String, - inner: Client, -} +#[cfg(test)] +mod tests { + use std::fmt::Display; -impl SpeculosReqwestClient { - pub fn new(url: &str) -> SpeculosReqwestClient { - SpeculosReqwestClient { - endpoint: url.into(), - inner: Client::new(), - } - } -} + use anyhow::Result; + use base64ct::Encoding; + use bhwi_async::HWI; + use bhwi_async::{Ledger, transport::ledger::speculos::LedgerTransportTcp}; + use bhwi_cli::ledger::SpeculosTcpChannel; + use reqwest::Client; + use serde::Serialize; + use serde_json::Value; + use tokio::net::TcpStream; -#[async_trait(?Send)] -impl SpeculosClient for SpeculosReqwestClient { - type Error = anyhow::Error; + pub type SpeculosDevice = Ledger>; - fn url(&self) -> &str { - &self.endpoint + struct SpeculosReqwestClient { + endpoint: String, + inner: Client, } - async fn post( - &self, - endpoint: &str, - req: Req, - ) -> std::result::Result<(), Self::Error> { - self.inner - .post(endpoint) - .json(&req) - .send() - .await? - .error_for_status()?; - Ok(()) + #[derive(Debug, Clone, Copy)] + pub enum Button { + Left, + Right, + Both, } - async fn post_json( - &self, - endpoint: &str, - req: Req, - ) -> std::result::Result { - Ok(self - .inner - .post(endpoint) - .json(&req) - .send() - .await? - .error_for_status()? - .json() - .await?) + impl Display for Button { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Button::Left => "left", + Button::Right => "right", + Button::Both => "both", + } + ) + } } -} -impl Default for SpeculosReqwestClient { - fn default() -> SpeculosReqwestClient { - SpeculosReqwestClient::new("http://localhost:5000") + #[derive(Serialize)] + struct ButtonRequest { + action: String, } -} -#[cfg(test)] -mod tests { - use base64ct::Encoding; - use bhwi_async::HWI; - use serde_json::Value; + impl SpeculosReqwestClient { + fn new(url: &str) -> SpeculosReqwestClient { + SpeculosReqwestClient { + endpoint: url.into(), + inner: Client::new(), + } + } - use super::*; + async fn post(&self, endpoint: &str, req: Req) -> Result<()> { + self.inner + .post(endpoint) + .json(&req) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + async fn button_press(&self, button: Button) -> Result<()> { + self.post( + &format!("{}/button/{button}", self.endpoint), + &ButtonRequest { + action: "press-and-release".into(), + }, + ) + .await + } + + async fn set_automation(&self, automation_json: &T) -> Result<()> { + self.post(&format!("{}/automation", self.endpoint), automation_json) + .await + } + } + + impl Default for SpeculosReqwestClient { + fn default() -> SpeculosReqwestClient { + SpeculosReqwestClient::new("http://localhost:5000") + } + } - async fn init_device() -> SpeculosDevice { - SpeculosDevice::new(SpeculosTransport::new(SpeculosReqwestClient::default())) + async fn init() -> (SpeculosDevice, SpeculosReqwestClient) { + ( + SpeculosDevice::new(LedgerTransportTcp::new(SpeculosTcpChannel::new( + TcpStream::connect("localhost:9999").await.unwrap(), + ))), + SpeculosReqwestClient::default(), + ) } #[tokio::test] async fn can_get_master_fingerprint() { - let mut dev = init_device().await; + let (mut dev, _) = init().await; let fingerprint = dev .get_master_fingerprint() .await @@ -91,7 +106,7 @@ mod tests { #[tokio::test] async fn can_get_xpub() { - let mut dev = init_device().await; + let (mut dev, _) = init().await; let xpub = dev .get_extended_pubkey("m/44'/1'/0'".parse().unwrap(), false) .await @@ -105,11 +120,10 @@ mod tests { // https://github.com/LedgerHQ/app-bitcoin-new/blob/d30a667239cd15c5a0769f07e60ef5bff1e1cb66/bitcoin_client_rs/tests/client.rs#L45 #[tokio::test] async fn can_sign_message() { - let mut dev = init_device().await; + let (mut dev, client) = init().await; let msg = b"hello"; - dev.transport - .client + client .set_automation( &serde_json::from_str::(include_str!("../automations/sign_message.json")) .unwrap(), @@ -128,8 +142,7 @@ mod tests { "IL3u9GLAzgG5BdtSBqUe0Fo2Zx0UlKwSsYx2TbuVX0VULFgZYRBQCW0W7QOlsB/JgGwWNhl3eYYjXtdfyR7pM+Y=" ); - dev.transport - .client + client .set_automation( &serde_json::from_str::(include_str!( "../automations/sign_message_reject.json"