diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df68d6..e4716ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +- Support host passphrase entry during unlock on firmware v9.28.0+. +- Return typed device states after pairing, with explicit unlock and setup transitions. +- Expose the optional `bootloader_version` field in `device_info()` (firmware v9.27.1+). - Preserve the sign of positive EIP-712 signed integers supplied as JSON numbers. - Reset unfinished sessions when connecting to firmware v9.28.0 or newer, allowing host reconnects while the device remains powered on. diff --git a/Cargo.lock b/Cargo.lock index 70d7acf..4300ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -125,11 +138,13 @@ dependencies = [ "bitcoinconsensus", "byteorder", "chrono", + "cursive", "futures-channel", "futures-util", "getrandom 0.2.17", "hex", "hidapi", + "inquire", "miniscript", "noise-protocol", "noise-rust-crypto", @@ -205,6 +220,12 @@ dependencies = [ "cc", ] +[[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.1" @@ -247,6 +268,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -309,6 +339,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -344,6 +397,64 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -369,6 +480,57 @@ dependencies = [ "cipher", ] +[[package]] +name = "cursive" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "386d5a36020bb856e9a34ecb8a4e6c9bd6b0262d1857bae4db7bc7e2fdaa532e" +dependencies = [ + "ahash", + "cfg-if", + "crossbeam-channel", + "crossterm 0.28.1", + "cursive_core", + "lazy_static", + "libc", + "log", + "signal-hook", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "cursive-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7ac0eb0cede3dfdfebf4d5f22354e05a730b79c25fd03481fc69fcfba0a73e" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "cursive_core" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d40c54bcff5c30d198d0c089b3f1674e2b3b7b438147b798fe247c955e171c4f" +dependencies = [ + "ahash", + "compact_str", + "crossbeam-channel", + "cursive-macros", + "enum-map", + "enumset", + "jiff", + "lazy_static", + "log", + "num", + "parking_lot", + "serde_json", + "unicode-segmentation", + "unicode-width 0.2.2", + "xi-unicode", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -395,6 +557,93 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -417,6 +666,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -432,6 +696,47 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -562,6 +867,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -570,7 +887,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -891,6 +1208,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -933,6 +1256,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.11.1", + "crossterm 0.29.0", + "dyn-clone", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -954,6 +1290,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.69" @@ -963,6 +1353,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -975,6 +1371,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -987,6 +1389,21 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1023,6 +1440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1068,6 +1486,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1078,6 +1509,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1087,6 +1527,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1114,7 +1574,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -1151,6 +1611,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1192,6 +1675,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1252,6 +1750,12 @@ 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 = "r-efi" version = "6.0.0" @@ -1267,6 +1771,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "reqwest" version = "0.12.12" @@ -1350,16 +1863,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1405,6 +1931,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" @@ -1420,6 +1952,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 = "secp256k1" version = "0.29.1" @@ -1445,7 +1983,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1540,6 +2078,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1568,6 +2137,12 @@ 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 = "subtle" version = "2.6.1" @@ -1622,7 +2197,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1646,7 +2221,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1812,6 +2387,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1991,7 +2584,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -2007,6 +2600,28 @@ dependencies = [ "wasm-bindgen", ] +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2242,7 +2857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -2289,6 +2904,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xi-unicode" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" + [[package]] name = "yoke" version = "0.8.2" @@ -2312,6 +2933,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 5d6ba52..6172646 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ serde = { version = "1.0", features = ["derive"] } tokio = { version = "1", optional = true, features = ["time"] } [dev-dependencies] +cursive = { version = "0.21.1", default-features = false, features = ["crossterm-backend"] } +inquire = { version = "0.9.4", default-features = false, features = ["crossterm"] } # For the base64 PSBT input/output in examples/btc_sign_psbt.rs. bitcoin = { version = "0.32", features = ["base64"] } futures-channel = "0.3" @@ -51,6 +53,14 @@ rlp = "0.6" bitcoinconsensus = { version = "0.106.0", default-features = false } miniscript = "13.0.0" +[[example]] +name = "host_passphrase" +required-features = ["usb", "tokio/rt", "tokio/macros"] + +[[example]] +name = "host_passphrase_auto" +required-features = ["usb", "tokio/rt", "tokio/macros"] + [[example]] name = "singlethreaded" required-features = ["usb", "tokio/rt", "tokio/macros"] @@ -92,7 +102,7 @@ name = "simulator" required-features = ["simulator", "tokio/rt", "tokio/macros", "tokio/rt-multi-thread", "multithreaded"] [features] -# Implement Sync+Send for PairedBitBox and PairingBitBox. +# Implement Sync+Send for the BitBox state types. # This may or may not cause trouble on macOS, see: https://github.com/libusb/hidapi/issues/503 multithreaded = [] usb = ["dep:hidapi"] diff --git a/Makefile b/Makefile index 030b0f6..1afbf05 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,10 @@ build-protos: rust-script scripts/build-protos.rs +example-host-passphrase: + cargo run --example host_passphrase --features=usb,tokio/rt,tokio/macros +example-host-passphrase-auto: + cargo run --example host_passphrase_auto --features=usb,tokio/rt,tokio/macros example-singlethreaded: cargo run --example singlethreaded --features=usb,tokio/rt,tokio/macros example-multithreaded: diff --git a/ci.sh b/ci.sh index a964906..704f4b1 100755 --- a/ci.sh +++ b/ci.sh @@ -10,6 +10,10 @@ features=( ) examples=( + "--example host_passphrase --features=usb,tokio/rt,tokio/macros" + "--example host_passphrase --features=usb,tokio/rt,tokio/macros,multithreaded" + "--example host_passphrase_auto --features=usb,tokio/rt,tokio/macros" + "--example host_passphrase_auto --features=usb,tokio/rt,tokio/macros,multithreaded" "--example singlethreaded --features=usb,tokio/rt,tokio/macros" "--example multithreaded --features=usb,tokio/rt,tokio/macros,tokio/rt-multi-thread,multithreaded" "--example btc_signtx --features=usb,tokio/rt,tokio/macros" diff --git a/examples/btc_miniscript.rs b/examples/btc_miniscript.rs index e67b753..a7aca5e 100644 --- a/examples/btc_miniscript.rs +++ b/examples/btc_miniscript.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use bitbox_api::pb; -async fn get_bitbox02() -> bitbox_api::PairedBitBox { +async fn get_bitbox02() -> bitbox_api::UnlockedBitBox { let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); let bitbox = bitbox_api::BitBox::::from_hid_device( bitbox_api::usb::get_any_bitbox02().unwrap(), @@ -16,7 +16,14 @@ async fn get_bitbox02() -> bitbox_api::PairedBitBox device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + } } #[tokio::main(flavor = "current_thread")] diff --git a/examples/btc_sign_msg.rs b/examples/btc_sign_msg.rs index b9dcc01..a3a2a97 100644 --- a/examples/btc_sign_msg.rs +++ b/examples/btc_sign_msg.rs @@ -14,7 +14,14 @@ async fn signmsg() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; let keypath = bitbox_api::Keypath::try_from("m/49'/0'/0'/0/0").unwrap(); diff --git a/examples/btc_sign_op_return.rs b/examples/btc_sign_op_return.rs index b375b4d..5224eed 100644 --- a/examples/btc_sign_op_return.rs +++ b/examples/btc_sign_op_return.rs @@ -14,7 +14,7 @@ use bitcoin::{ }; use semver::VersionReq; -async fn connect_bitbox() -> bitbox_api::PairedBitBox { +async fn connect_bitbox() -> bitbox_api::UnlockedBitBox { let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); let device = bitbox_api::BitBox::::from_hid_device( bitbox_api::usb::get_any_bitbox02().unwrap(), @@ -26,11 +26,18 @@ async fn connect_bitbox() -> bitbox_api::PairedBitBox device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + } } async fn build_op_return_psbt( - paired: &bitbox_api::PairedBitBox, + paired: &bitbox_api::UnlockedBitBox, ) -> Psbt { let coin = pb::BtcCoin::Tbtc; let secp = secp256k1::Secp256k1::new(); diff --git a/examples/btc_sign_psbt.rs b/examples/btc_sign_psbt.rs index 1dbdae6..0a3dd64 100644 --- a/examples/btc_sign_psbt.rs +++ b/examples/btc_sign_psbt.rs @@ -15,7 +15,14 @@ async fn sign_psbt(psbt: &mut bitcoin::psbt::Ps if let Some(pairing_code) = pairing_device.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired = pairing_device.wait_confirm().await.unwrap(); + let paired = match pairing_device.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; paired .btc_sign_psbt( pb::BtcCoin::Tbtc, diff --git a/examples/btc_signtx.rs b/examples/btc_signtx.rs index fa47749..24c13b5 100644 --- a/examples/btc_signtx.rs +++ b/examples/btc_signtx.rs @@ -14,7 +14,14 @@ async fn signtx() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; let prevtx = bitbox_api::btc::PrevTx { version: 1, diff --git a/examples/cardano.rs b/examples/cardano.rs index 5f6336b..fac07b3 100644 --- a/examples/cardano.rs +++ b/examples/cardano.rs @@ -14,7 +14,14 @@ async fn demo() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!("Getting xpubs..."); let xpubs = paired_bitbox diff --git a/examples/eth.rs b/examples/eth.rs index 0c76c59..6274b53 100644 --- a/examples/eth.rs +++ b/examples/eth.rs @@ -60,7 +60,14 @@ async fn eth_demo() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!("Getting xpub..."); let xpub = paired_bitbox diff --git a/examples/host_passphrase.rs b/examples/host_passphrase.rs new file mode 100644 index 0000000..dd427f9 --- /dev/null +++ b/examples/host_passphrase.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! A host-entry button and password dialog driven by the passphrase callbacks. +//! Requires firmware v9.28.0+ and the optional passphrase setting enabled on the device. + +use bitbox_api::{error::Error, runtime::TokioRuntime, PassphraseConfig}; +use cursive::traits::{Nameable, Resizable}; +use cursive::views::{Dialog, DialogFocus, EditView, TextView}; +use cursive::{CbSink, Cursive}; +use futures_channel::oneshot; +use zeroize::Zeroize; + +type Result = std::result::Result>; + +fn finish_input(ui: &mut Cursive, passphrase: Option) { + let reply = ui + .take_user_data::>>() + .expect("pending passphrase input"); + ui.pop_layer(); + if let Err(mut unsent) = reply.send(passphrase) { + unsent.zeroize(); + } +} + +// The callbacks only send UI messages; they never wait synchronously for user input. +fn config(ui: CbSink) -> PassphraseConfig { + let input_ui = ui.clone(); + PassphraseConfig::new(move || { + let ui = input_ui.clone(); + async move { + let (reply, input) = oneshot::channel(); + ui.send(Box::new(move |ui| { + ui.set_user_data(reply); + ui.add_layer( + Dialog::around( + EditView::new() + .secret() + .on_submit(|ui, text| { + // Never trim or normalize the user's input. The device validates it. + finish_input(ui, Some(text.to_owned())); + }) + .fixed_width(40), + ) + .title("Host passphrase (Enter to submit)") + .button("Cancel", |ui| finish_input(ui, None)), + ); + })) + .map_err(|_| Error::PassphraseInput("UI closed".into()))?; + input + .await + .map_err(|_| Error::PassphraseInput("UI closed".into())) + } + }) + .with_on_host_passphrase_available(move |request| { + let _ = ui.send(Box::new(move |ui| { + ui.call_on_name("device", |dialog: &mut Dialog| { + dialog.set_content(TextView::new("Follow the instructions on the device.")); + dialog.clear_buttons(); + if let Some(request) = request { + dialog.add_button("Enter on host", move |_| request.request()); + } + dialog.add_button("Quit", Cursive::quit); + dialog.set_focus(DialogFocus::Button(0)); + }); + })); + }) +} + +fn status(ui: &CbSink, text: String) { + let _ = ui.send(Box::new(move |ui| { + ui.call_on_name("device", |dialog: &mut Dialog| { + dialog.set_content(TextView::new(text)); + }); + })); +} + +async fn connect(ui: &CbSink) -> Result { + let bitbox = bitbox_api::BitBox::::from_hid_device( + bitbox_api::usb::get_any_bitbox02()?, + Box::new(bitbox_api::NoiseConfigNoCache), + ) + .await?; + let pairing = bitbox.unlock_and_pair().await?; + if let Some(code) = pairing.get_pairing_code() { + status( + ui, + format!("Confirm the pairing code on the device:\n{code}"), + ); + } + let paired = pairing.wait_confirm().await?; + status(ui, "Follow the instructions on the device.".into()); + let unlocked = match paired { + bitbox_api::PairedBitBox::Locked(device) => device.unlock(config(ui.clone())).await?, + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => { + return Err("Initialize the BitBox first".into()) + } + }; + Ok(unlocked.root_fingerprint().await?) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + // Cursive owns terminal input on its thread; device communication stays asynchronous. + let (ready, started) = oneshot::channel(); + let mut terminal = tokio::task::spawn_blocking(move || { + let mut ui = cursive::default(); + ui.add_layer( + Dialog::text("Follow the instructions on the device.") + .title("BitBox02 host passphrase") + .button("Quit", Cursive::quit) + .with_name("device"), + ); + let _ = ready.send(ui.cb_sink().clone()); + ui.try_run().map_err(|error| error.to_string()) + }); + let ui = started.await?; + let result = tokio::select! { + result = connect(&ui) => result, + result = &mut terminal => { + // Closing the UI also drops the connection future and any pending input. + result??; + return Ok(()); + } + }; + status( + &ui, + match &result { + Ok(fingerprint) => format!("Connection ready. Root fingerprint: {fingerprint}"), + Err(error) => format!("Connection failed: {error}"), + }, + ); + terminal.await??; + Ok(()) +} diff --git a/examples/host_passphrase_auto.rs b/examples/host_passphrase_auto.rs new file mode 100644 index 0000000..9865242 --- /dev/null +++ b/examples/host_passphrase_auto.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Request host passphrase entry automatically, then prompt after approval on the device. +//! Requires firmware v9.28.0+ and the optional passphrase setting enabled on the device. + +use bitbox_api::{error::Error, runtime::TokioRuntime, PassphraseConfig}; +use inquire::{InquireError, Password}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // Without an availability callback, host entry is requested once automatically. + let config = PassphraseConfig::new(|| async { + let input = tokio::task::spawn_blocking(|| { + Password::new("Host passphrase:") + .without_confirmation() + .with_help_message("Enter submits (empty allowed); Escape or Ctrl-C cancels.") + .prompt() + }) + .await + .map_err(|error| Error::PassphraseInput(error.to_string()))?; + // Never trim or normalize the user's input. The device validates it. + match input { + Ok(passphrase) => Ok(Some(passphrase)), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), + Err(error) => Err(Error::PassphraseInput(error.to_string())), + } + }); + + println!("Follow the instructions on the device; approve host entry when prompted."); + let bitbox = bitbox_api::BitBox::::from_hid_device( + bitbox_api::usb::get_any_bitbox02()?, + Box::new(bitbox_api::NoiseConfigNoCache), + ) + .await?; + let pairing = bitbox.unlock_and_pair().await?; + if let Some(code) = pairing.get_pairing_code() { + println!("Pairing code:\n{code}"); + } + let unlocked = match pairing.wait_confirm().await? { + bitbox_api::PairedBitBox::Locked(device) => device.unlock(config).await?, + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => { + return Err("Initialize the BitBox first".into()) + } + }; + println!( + "Connection ready. Root fingerprint: {}", + unlocked.root_fingerprint().await? + ); + Ok(()) +} diff --git a/examples/multithreaded.rs b/examples/multithreaded.rs index 5f758f5..57dd997 100644 --- a/examples/multithreaded.rs +++ b/examples/multithreaded.rs @@ -15,7 +15,14 @@ async fn demo() { println!("Pairing code\n{pairing_code}"); } multithreading_type_check(&pairing_bitbox); - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!( "root fingerprint: {}", paired_bitbox.root_fingerprint().await.unwrap() diff --git a/examples/simulator.rs b/examples/simulator.rs index 0c6baa5..cff73f2 100644 --- a/examples/simulator.rs +++ b/examples/simulator.rs @@ -6,7 +6,17 @@ async fn demo() { .await .unwrap(); let pairing_bitbox = bitbox.unlock_and_pair().await.unwrap(); - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(device) => { + println!("device info: {:?}", device.device_info().await.unwrap()); + return; + } + }; println!( "device info: {:?}", paired_bitbox.device_info().await.unwrap() diff --git a/examples/singlethreaded.rs b/examples/singlethreaded.rs index fde9a50..f094419 100644 --- a/examples/singlethreaded.rs +++ b/examples/singlethreaded.rs @@ -12,7 +12,14 @@ async fn demo() { if let Some(pairing_code) = pairing_bitbox.get_pairing_code().as_ref() { println!("Pairing code\n{pairing_code}"); } - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); + let paired_bitbox = match pairing_bitbox.wait_confirm().await.unwrap() { + bitbox_api::PairedBitBox::Locked(device) => device + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap(), + bitbox_api::PairedBitBox::Unlocked(device) => device, + bitbox_api::PairedBitBox::Uninitialized(_) => panic!("Initialize the BitBox first"), + }; println!( "root fingerprint: {}", paired_bitbox.root_fingerprint().await.unwrap() diff --git a/messages/bitbox02_system.proto b/messages/bitbox02_system.proto index 463e689..b234b5b 100644 --- a/messages/bitbox02_system.proto +++ b/messages/bitbox02_system.proto @@ -34,6 +34,8 @@ message DeviceInfoResponse { // From v9.25.0. This together with `securechip_model` determines the password stretching // algorithm. string password_stretching_algo = 8; + // Marketing version of the installed stage1 bootloader. Not present on legacy bootloaders. + optional string bootloader_version = 9; } message InsertRemoveSDCardRequest { diff --git a/messages/bitboxsync.proto b/messages/bitboxsync.proto new file mode 100644 index 0000000..6c0e8f1 --- /dev/null +++ b/messages/bitboxsync.proto @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; +package shiftcrypto.bitbox02; + +message BitBoxSyncIdentityRequest {} + +message BitBoxSyncIdentityResponse { + bytes auth_public_key = 1; + bytes wrap_public_key = 2; +} + +message BitBoxSyncSignLoginIntentRequest { + bytes challenge = 1; +} + +message BitBoxSyncSignRefreshIntentRequest { + bytes challenge = 1; +} + +message BitBoxSyncSignRevokeAllTokensIntentRequest { + bytes challenge = 1; +} + +message BitBoxSyncSignCreateNamespaceInviteIntentRequest { + bytes challenge = 1; + bytes namespace_id = 2; + bytes invite_id = 3; + bytes invite_server_secret_hash = 4; + uint64 expires_at = 5; + uint32 max_accepted = 6; +} + +message BitBoxSyncSignJoinRequestIntentRequest { + bytes namespace_id = 1; + bytes invite_id = 2; + string server_origin = 3; + uint64 expires_at = 4; +} + +message BitBoxSyncUnwrapNamespaceDEKRequest { + bytes namespace_id = 1; + bytes wrapped_dek = 2; +} + +message BitBoxSyncSignatureResponse { + bytes signature = 1; +} + +message BitBoxSyncUnwrapNamespaceDEKResponse { + bytes namespace_dek = 1; +} + +message BitBoxSyncRequest { + oneof request { + BitBoxSyncIdentityRequest identity = 1; + BitBoxSyncSignLoginIntentRequest sign_login_intent = 2; + BitBoxSyncSignRefreshIntentRequest sign_refresh_intent = 3; + BitBoxSyncSignRevokeAllTokensIntentRequest sign_revoke_all_tokens_intent = 4; + BitBoxSyncSignCreateNamespaceInviteIntentRequest sign_create_namespace_invite_intent = 5; + BitBoxSyncSignJoinRequestIntentRequest sign_join_request_intent = 6; + BitBoxSyncUnwrapNamespaceDEKRequest unwrap_namespace_dek = 7; + } +} + +message BitBoxSyncResponse { + oneof response { + BitBoxSyncIdentityResponse identity = 1; + BitBoxSyncSignatureResponse signature = 2; + BitBoxSyncUnwrapNamespaceDEKResponse unwrap_namespace_dek = 3; + } +} diff --git a/messages/btc.proto b/messages/btc.proto index bee46ad..08e802b 100644 --- a/messages/btc.proto +++ b/messages/btc.proto @@ -103,7 +103,8 @@ message BTCSignInitRequest { uint32 version = 4; // must be 1 or 2 uint32 num_inputs = 5; uint32 num_outputs = 6; - uint32 locktime = 7; // must be <500000000 + // Consensus nLockTime: values below 500000000 are block heights, otherwise Unix timestamps. + uint32 locktime = 7; enum FormatUnit { // According to `coin` (BTC, LTC, etc.). @@ -152,6 +153,8 @@ message BTCSignInputRequest { repeated uint32 keypath = 6; // all inputs must be ours. // References a script config from BTCSignInitRequest uint32 script_config_index = 7; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 8; } @@ -246,8 +249,27 @@ message BTCPaymentRequestRequest { message TextMemo { string note = 1; } + message CoinPurchaseMemo { + uint32 coin_type = 1; // SLIP-44 coin type + string amount = 2; // Human-readable amount (e.g. "0.25 ETH") + string address = 3; // Address to send the purchased coins to + // Derivation info for verifying address ownership. + // NOT part of the SLIP-24 sighash. + message EthAddressDerivation { + repeated uint32 keypath = 1; // Keypath to the address + } + message BtcAddressDerivation { + // Script config + keypath are needed to derive BTC/LTC-family addresses. + BTCScriptConfigWithKeypath script_config = 1; + } + oneof address_derivation { + EthAddressDerivation eth = 4; + BtcAddressDerivation btc = 5; + } + } oneof memo { TextMemo text_memo = 1; + CoinPurchaseMemo coin_purchase_memo = 2; } } @@ -262,6 +284,8 @@ message BTCSignMessageRequest { BTCCoin coin = 1; BTCScriptConfigWithKeypath script_config = 2; bytes msg = 3; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 4; } diff --git a/messages/eth.proto b/messages/eth.proto index 02c5c00..a2f3012 100644 --- a/messages/eth.proto +++ b/messages/eth.proto @@ -5,6 +5,7 @@ package shiftcrypto.bitbox02; import "common.proto"; import "antiklepto.proto"; +import "btc.proto"; // Kept for backwards compatibility. Use chain_id instead, introduced in v9.10.0. enum ETHCoin { @@ -47,6 +48,8 @@ message ETHSignRequest { bytes recipient = 6; // 20 byte recipient bytes value = 7; // smallest big endian serialization, max. 32 bytes bytes data = 8; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 9; // If non-zero, `coin` is ignored and `chain_id` is used to identify the network. uint64 chain_id = 10; @@ -66,10 +69,13 @@ message ETHSignEIP1559Request { bytes recipient = 7; // 20 byte recipient bytes value = 8; // smallest big endian serialization, max. 32 bytes bytes data = 9; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 10; ETHAddressCase address_case = 11; // For streaming: if non-zero, data field should be empty and data will be requested in chunks uint32 data_length = 12; + BTCPaymentRequestRequest payment_request = 13; } message ETHSignDataRequestChunkResponse { @@ -86,6 +92,8 @@ message ETHSignMessageRequest { ETHCoin coin = 1; repeated uint32 keypath = 2; bytes msg = 3; + // If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + // This differs from plain RFC6979 and does not provide anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 4; // If non-zero, `coin` is ignored and `chain_id` is used to identify the network. uint64 chain_id = 5; @@ -129,6 +137,7 @@ message ETHSignTypedMessageRequest { repeated uint32 keypath = 2; repeated StructType types = 3; string primary_type = 4; + // If omitted, plain RFC6979 signing is used without anti-klepto protection. AntiKleptoHostNonceCommitment host_nonce_commitment = 5; } diff --git a/messages/hww.proto b/messages/hww.proto index 07cb660..bf6d032 100644 --- a/messages/hww.proto +++ b/messages/hww.proto @@ -7,6 +7,7 @@ import "common.proto"; import "backup_commands.proto"; import "bitbox02_system.proto"; +import "bitboxsync.proto"; import "bluetooth.proto"; import "btc.proto"; import "cardano.proto"; @@ -58,6 +59,10 @@ message Request { BIP85Request bip85 = 28; BluetoothRequest bluetooth = 29; ChangePasswordRequest change_password = 30; + BitBoxSyncRequest bitbox_sync = 31; + UnlockRequest unlock = 32; + UnlockContinueRequest unlock_continue = 33; + UnlockHostInfoRequest unlock_host_info = 34; } } @@ -81,5 +86,7 @@ message Response { CardanoResponse cardano = 15; BIP85Response bip85 = 16; BluetoothResponse bluetooth = 17; + BitBoxSyncResponse bitbox_sync = 18; + UnlockResponse unlock = 19; } } diff --git a/messages/keystore.proto b/messages/keystore.proto index 4f63cd3..b793fdf 100644 --- a/messages/keystore.proto +++ b/messages/keystore.proto @@ -8,6 +8,38 @@ package shiftcrypto.bitbox02; import "google/protobuf/empty.proto"; +// Unlock inside the paired Noise channel (since v9.28.0). Uninitialized and already unlocked +// devices return DONE immediately and are left unchanged. Continuations are only valid within +// this workflow. +// If the passphrase feature is enabled, device entry returns PASSPHRASE_PENDING. The host +// polls with UnlockContinueRequest until entry completes or it requests host entry. +message UnlockRequest {} + +message UnlockContinueRequest { + // False polls device entry; true interrupts it to ask for host-entry consent. + // After PASSPHRASE_ENTERED, continue to await confirmation and unlock; + // host entry is ignored. + bool request_host_entry = 1; +} + +message UnlockHostInfoRequest { + // Only sent after HOST_ENTRY_READY. Absent cancels host input and restarts device entry; + // the empty string submits the empty passphrase. The device confirms the actual value. + optional string passphrase = 1; +} + +message UnlockResponse { + enum State { + PASSPHRASE_PENDING = 0; + HOST_ENTRY_READY = 1; + // Passphrase entry on the device finished; confirmation may still be pending. + // Withdraw host entry and send UnlockContinue to await completion. + PASSPHRASE_ENTERED = 2; + DONE = 3; + } + State state = 1; +} + message ElectrumEncryptionKeyRequest { repeated uint32 keypath = 1; } diff --git a/src/btc.rs b/src/btc.rs index 8280d43..49f7487 100644 --- a/src/btc.rs +++ b/src/btc.rs @@ -7,7 +7,7 @@ use crate::runtime::Runtime; use crate::error::Error; use crate::pb::{self, request::Request, response::Response}; use crate::Keypath; -use crate::PairedBitBox; +use crate::UnlockedBitBox; pub use bitcoin::{ bip32::{Fingerprint, Xpub}, @@ -678,7 +678,7 @@ fn find_or_add_script_config( } } -impl PairedBitBox { +impl UnlockedBitBox { /// Retrieves an xpub. For non-standard keypaths, a warning is displayed on the BitBox even if /// `display` is false. pub async fn btc_xpub( @@ -688,7 +688,7 @@ impl PairedBitBox { xpub_type: pb::btc_pub_request::XPubType, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; self.btc_xpub_inner(coin, keypath, xpub_type, display).await } @@ -700,6 +700,7 @@ impl PairedBitBox { display: bool, ) -> Result { match self + .session .query_proto(Request::BtcPub(pb::BtcPubRequest { coin: coin as _, keypath: keypath.to_vec(), @@ -721,8 +722,8 @@ impl PairedBitBox { keypaths: &[Keypath], xpub_type: pb::btc_xpubs_request::XPubType, ) -> Result, Error> { - let _api_call = self.begin_api_call().await; - if self.validate_version(">=9.24.0").is_err() { + let _api_call = self.session.begin_api_call().await; + if self.session.validate_version(">=9.24.0").is_err() { // Fallback to fetching them one-by-one on older firmware. let mut xpubs = Vec::::with_capacity(keypaths.len()); for keypath in keypaths { @@ -762,8 +763,9 @@ impl PairedBitBox { script_config: &pb::BtcScriptConfig, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self + .session .query_proto(Request::BtcPub(pb::BtcPubRequest { coin: coin as _, keypath: keypath.to_vec(), @@ -784,6 +786,7 @@ impl PairedBitBox { request: pb::btc_request::Request, ) -> Result { match self + .session .query_proto(Request::Btc(pb::BtcRequest { request: Some(request), })) @@ -797,7 +800,7 @@ impl PairedBitBox { } async fn get_next_response(&self, request: Request) -> Result { - match self.query_proto(request).await? { + match self.session.query_proto(request).await? { Response::BtcSignNext(next_response) => Ok(next_response), _ => Err(Error::UnexpectedResponse), } @@ -821,7 +824,7 @@ impl PairedBitBox { transaction: &Transaction, format_unit: pb::btc_sign_init_request::FormatUnit, ) -> Result>, Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; self.btc_sign_inner(coin, transaction, &[], &[], format_unit) .await } @@ -841,14 +844,14 @@ impl PairedBitBox { "output script config indices must match transaction outputs".into(), )); } - self.validate_version(">=9.4.0")?; // anti-klepto since 9.4.0 + self.session.validate_version(">=9.4.0")?; // anti-klepto since 9.4.0 if transaction .script_configs .iter() .chain(output_script_configs) .any(is_taproot_simple) { - self.validate_version(">=9.10.0")?; // taproot since 9.10.0 + self.session.validate_version(">=9.10.0")?; // taproot since 9.10.0 } if transaction.outputs.iter().any(|output| { matches!( @@ -857,7 +860,7 @@ impl PairedBitBox { if tx_output.payload.output_type == pb::BtcOutputType::OpReturn ) }) { - self.validate_version(">=9.24.0")?; + self.session.validate_version(">=9.24.0")?; } let mut sigs: Vec> = Vec::new(); @@ -1050,11 +1053,11 @@ impl PairedBitBox { force_script_config: Option, format_unit: pb::btc_sign_init_request::FormatUnit, ) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // since v9.15.0, the BitBox02 accepts "internal" outputs (ones sent to the BitBox02 with // the keypath) even if the keypath is not a change keypath. PSBTs often contain the key // origin info in outputs even in regular send-to-self outputs. - self.validate_version(">=9.15.0")?; + self.session.validate_version(">=9.15.0")?; let our_root_fingerprint = self.root_fingerprint_inner().await?; let psbt_transaction = Transaction::from_psbt( @@ -1113,8 +1116,8 @@ impl PairedBitBox { script_config: pb::BtcScriptConfigWithKeypath, msg: &[u8], ) -> Result { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.5.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.5.0")?; let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::BtcSignMessageRequest { @@ -1176,7 +1179,7 @@ impl PairedBitBox { script_config: &pb::BtcScriptConfig, keypath_account: Option<&Keypath>, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_btc(pb::btc_request::Request::IsScriptConfigRegistered( pb::BtcIsScriptConfigRegisteredRequest { @@ -1214,7 +1217,7 @@ impl PairedBitBox { xpub_type: pb::btc_register_script_config_request::XPubType, name: Option<&str>, ) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_btc(pb::btc_request::Request::RegisterScriptConfig( pb::BtcRegisterScriptConfigRequest { diff --git a/src/cardano.rs b/src/cardano.rs index 0608b20..ce3ecb7 100644 --- a/src/cardano.rs +++ b/src/cardano.rs @@ -5,7 +5,7 @@ use crate::runtime::Runtime; use crate::error::Error; use crate::pb::{self, request::Request, response::Response}; use crate::Keypath; -use crate::PairedBitBox; +use crate::UnlockedBitBox; /// Create a Shelley PaymentKeyHash/StakeKeyHash config. /// @@ -23,14 +23,15 @@ pub fn make_script_config_pkh_skh( } } -impl PairedBitBox { +impl UnlockedBitBox { async fn query_proto_cardano( &self, request: pb::cardano_request::Request, ) -> Result { - self.validate_version(">=9.8.0")?; // Cardano since 9.8.0 + self.session.validate_version(">=9.8.0")?; // Cardano since 9.8.0 match self + .session .query_proto(Request::Cardano(pb::CardanoRequest { request: Some(request), })) @@ -51,7 +52,7 @@ impl PairedBitBox { /// Query the device for xpubs. The result contains one xpub per requested keypath. Each xpub is /// 64 bytes: 32 byte chain code + 32 byte pubkey. pub async fn cardano_xpubs(&self, keypaths: &[Keypath]) -> Result>, Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_cardano(pb::cardano_request::Request::Xpubs( pb::CardanoXpubsRequest { @@ -72,7 +73,7 @@ impl PairedBitBox { script_config: &pb::CardanoScriptConfig, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_cardano(pb::cardano_request::Request::Address( pb::CardanoAddressRequest { @@ -93,9 +94,9 @@ impl PairedBitBox { &self, transaction: pb::CardanoSignTransactionRequest, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; if transaction.tag_cbor_sets { - self.validate_version(">=9.22.0")?; + self.session.validate_version(">=9.22.0")?; } match self .query_proto_cardano(pb::cardano_request::Request::SignTransaction(transaction)) diff --git a/src/communication.rs b/src/communication.rs index 8a29f30..b4b2d8c 100644 --- a/src/communication.rs +++ b/src/communication.rs @@ -18,6 +18,8 @@ pub enum Error { Read, #[error("u2f framing decoding error")] U2fDecode, + #[error("u2f framing encoding error")] + U2fEncode, #[error("error querying device info")] Info, #[error("firmware version {0} required")] @@ -57,7 +59,10 @@ impl U2fHidCommunication { impl ReadWrite for U2fHidCommunication { fn write(&self, msg: &[u8]) -> Result { let mut buf = [0u8; u2fframing::MAX_LEN]; - let size = self.u2fhid.encode(msg, &mut buf).unwrap(); + let size = self + .u2fhid + .encode(msg, &mut buf) + .or(Err(Error::U2fEncode))?; for chunk in buf[..size].chunks(64) { self.read_write.write(chunk)?; } @@ -121,7 +126,6 @@ pub struct Info { #[allow(dead_code)] pub unlocked: bool, // Is None before firmware version 9.20.0. - #[allow(dead_code)] pub initialized: Option, } @@ -224,6 +228,13 @@ impl HwwCommunication { } } + pub(crate) async fn reset_session_once(&self) -> Result<(), Error> { + match self.communication.query(&[HWW_REQ_RESET]).await?.as_slice() { + [HWW_RSP_ACK] => Ok(()), + _ => Err(Error::Info), + } + } + pub async fn query(&self, msg: &[u8]) -> Result, Error> { let mut framed_msg = Vec::from([HWW_REQ_NEW]); framed_msg.extend_from_slice(msg); diff --git a/src/error.rs b/src/error.rs index 574dbd2..4815795 100644 --- a/src/error.rs +++ b/src/error.rs @@ -48,6 +48,8 @@ pub enum Error { NoiseConfig(#[from] crate::noise::ConfigError), #[error("pairing code rejected by user")] NoisePairingRejected, + #[error("host passphrase input failed: {0}")] + PassphraseInput(String), #[error("BitBox returned an unexpected response")] UnexpectedResponse, #[error("protobuf message could not be decoded")] diff --git a/src/eth.rs b/src/eth.rs index a17c4e2..ed166c0 100644 --- a/src/eth.rs +++ b/src/eth.rs @@ -11,7 +11,7 @@ use crate::pb::{ response::Response, }; use crate::Keypath; -use crate::PairedBitBox; +use crate::UnlockedBitBox; use std::collections::HashMap; use std::str::FromStr; @@ -24,12 +24,13 @@ use serde_json::Value; /// Transactions with data larger than this use streaming mode. const STREAMING_THRESHOLD: usize = 6144; -impl PairedBitBox { +impl UnlockedBitBox { async fn query_proto_eth( &self, request: pb::eth_request::Request, ) -> Result { match self + .session .query_proto(Request::Eth(pb::EthRequest { request: Some(request), })) @@ -49,7 +50,7 @@ impl PairedBitBox { /// Query the device for an xpub. pub async fn eth_xpub(&self, keypath: &Keypath) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_eth(pb::eth_request::Request::Pub(pb::EthPubRequest { keypath: keypath.to_vec(), @@ -73,7 +74,7 @@ impl PairedBitBox { keypath: &Keypath, display: bool, ) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self .query_proto_eth(pb::eth_request::Request::Pub(pb::EthPubRequest { keypath: keypath.to_vec(), @@ -442,7 +443,7 @@ fn get_value( } } -impl PairedBitBox { +impl UnlockedBitBox { async fn handle_antiklepto( &self, response: &pb::eth_response::Response, @@ -510,13 +511,13 @@ impl PairedBitBox { tx: &Transaction, address_case: Option, ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // passing chainID instead of coin only since v9.10.0 - self.validate_version(">=9.10.0")?; + self.session.validate_version(">=9.10.0")?; let use_streaming = tx.data.len() > STREAMING_THRESHOLD; if use_streaming { - self.validate_version(">=9.26.0")?; + self.session.validate_version(">=9.26.0")?; } let host_nonce = crate::antiklepto::gen_host_nonce()?; @@ -561,18 +562,19 @@ impl PairedBitBox { tx: &EIP1559Transaction, address_case: Option, ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // EIP1559 is suported from v9.16.0 - self.validate_version(">=9.16.0")?; + self.session.validate_version(">=9.16.0")?; let use_streaming = tx.data.len() > STREAMING_THRESHOLD; if use_streaming { - self.validate_version(">=9.26.0")?; + self.session.validate_version(">=9.26.0")?; } let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::eth_request::Request::SignEip1559(pb::EthSignEip1559Request { chain_id: tx.chain_id, + payment_request: None, keypath: keypath.to_vec(), nonce: crate::util::remove_leading_zeroes(&tx.nonce), max_priority_fee_per_gas: crate::util::remove_leading_zeroes( @@ -615,9 +617,9 @@ impl PairedBitBox { keypath: &Keypath, msg: &[u8], ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; // passing chainID instead of coin only since v9.10.0 - self.validate_version(">=9.10.0")?; + self.session.validate_version(">=9.10.0")?; let host_nonce = crate::antiklepto::gen_host_nonce()?; let request = pb::eth_request::Request::SignMsg(pb::EthSignMessageRequest { @@ -646,10 +648,10 @@ impl PairedBitBox { json_msg: &str, use_antiklepto: bool, ) -> Result<[u8; 65], Error> { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.12.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.12.0")?; if !use_antiklepto { - self.validate_version(">=9.26.0")?; + self.session.validate_version(">=9.26.0")?; } let msg: Eip712Message = serde_json::from_str(json_msg) diff --git a/src/lib.rs b/src/lib.rs index dbd7855..9dc9bcd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ mod communication; mod keypath; mod secp256k1; mod u2fframing; +mod unlock; mod util; /// BitBox protobuf messages. @@ -37,10 +38,12 @@ use prost::Message; use futures_util::lock::{Mutex as AsyncMutex, MutexGuard as ApiMutexGuard}; use std::sync::Mutex; +use zeroize::{Zeroize, Zeroizing}; pub use keypath::Keypath; pub use noise::PersistedNoiseConfig; pub use noise::{ConfigError, NoiseConfig, NoiseConfigData, NoiseConfigNoCache}; +pub use unlock::{HostPassphraseRequest, PassphraseConfig}; pub use util::Threading; use communication::HwwCommunication; @@ -56,6 +59,8 @@ const _OP_ATTESTATION: u8 = b'a'; const OP_UNLOCK: u8 = b'u'; const RESPONSE_SUCCESS: u8 = 0x00; +const RESPONSE_FAILURE: u8 = 0x01; +const RESPONSE_FAILURE_UNINITIALIZED: u8 = 0x02; type Cipher = noise_rust_crypto::ChaCha20Poly1305; type HandshakeState = @@ -119,12 +124,22 @@ impl BitBox { .await } - /// Invokes the device unlock and pairing. - pub async fn unlock_and_pair(self) -> Result, Error> { - self.communication - .query(&[OP_UNLOCK]) - .await - .or(Err(Error::Unknown))?; + /// Starts pairing, unlocking first only on firmware older than v9.28.0. + /// + /// On firmware v9.28.0 and later, call [`LockedBitBox::unlock`] after confirming pairing. + pub async fn unlock_and_pair(mut self) -> Result, Error> { + if !unlock::supports_paired_unlock(&self.communication.info.version) { + // The legacy response also reports initialization on firmware older than + // v9.20.0, whose transport info does not include an initialized flag. + self.communication.info.initialized = Some( + match self.communication.query(&[OP_UNLOCK]).await?.as_slice() { + [RESPONSE_SUCCESS] => true, + [RESPONSE_FAILURE_UNINITIALIZED] => false, + [RESPONSE_FAILURE] => return Err(BitBoxError::Generic.into()), + _ => return Err(Error::UnexpectedResponse), + }, + ); + } self.pair().await } @@ -249,7 +264,15 @@ impl PairingBitBox { self.pairing_code.clone() } - /// Proceed to the paired state. + /// Waits for pairing confirmation and returns the paired device's state. + /// + /// This does not start unlocking. Initialized devices on firmware v9.28.0 and later + /// return [`PairedBitBox::Locked`], even if already unlocked on the device. Older + /// firmware was unlocked by [`BitBox::unlock_and_pair`] and returns + /// [`PairedBitBox::Unlocked`]. Uninitialized devices always return + /// [`PairedBitBox::Uninitialized`]. + /// + /// Once this returns, the application can dismiss its pairing UI before proceeding. pub async fn wait_confirm(self) -> Result, Error> { if self.pairing_code.is_some() { let response = self @@ -265,23 +288,152 @@ impl PairingBitBox { config_data.add_device_static_pubkey(&remote_static_pubkey); self.noise_config.store_config(&config_data)?; } - Ok(PairedBitBox::from(self.communication, self.host)) + let initialized = self + .communication + .info + .initialized + .ok_or(Error::UnexpectedResponse)?; + let needs_unlock = unlock::supports_paired_unlock(&self.communication.info.version); + let session = Session::from(self.communication, self.host); + Ok(if !initialized { + PairedBitBox::Uninitialized(UninitializedBitBox { session }) + } else if needs_unlock { + PairedBitBox::Locked(LockedBitBox { session }) + } else { + PairedBitBox::Unlocked(UnlockedBitBox { session }) + }) + } +} + +/// A confirmed pairing, with the operations available in the device's current state. +/// +/// Match this value to unlock, use the wallet, or set up an uninitialized device. +pub enum PairedBitBox { + Locked(LockedBitBox), + Unlocked(UnlockedBitBox), + Uninitialized(UninitializedBitBox), +} + +impl PairedBitBox { + /// Returns which product we are connected to. + pub fn product(&self) -> Product { + match self { + Self::Locked(device) => device.product(), + Self::Unlocked(device) => device.product(), + Self::Uninitialized(device) => device.product(), + } + } + + /// Returns the firmware version. + pub fn version(&self) -> &semver::Version { + match self { + Self::Locked(device) => device.version(), + Self::Unlocked(device) => device.version(), + Self::Uninitialized(device) => device.version(), + } + } +} + +/// A paired device whose unlock step has not yet completed through this client. +/// +/// Call [`Self::unlock`] to obtain an [`UnlockedBitBox`]. If the device is already +/// unlocked, that call completes without prompting for a password or passphrase. +/// Wallet methods are unavailable before this transition. +/// +/// ```compile_fail +/// async fn fingerprint(device: bitbox_api::LockedBitBox) { +/// device.root_fingerprint().await; +/// } +/// ``` +pub struct LockedBitBox { + session: Session, +} + +impl LockedBitBox { + /// Returns which product we are connected to. + pub fn product(&self) -> Product { + self.session.communication.info.product + } + + /// Returns the firmware version. + pub fn version(&self) -> &semver::Version { + &self.session.communication.info.version + } +} + +/// A paired, initialized and unlocked device, ready for wallet operations. +pub struct UnlockedBitBox { + session: Session, +} + +/// A paired device that needs setup before wallet operations are available. +/// +/// This includes devices whose setup has started but is not yet complete. +/// [`Self::restore_from_mnemonic`] consumes this value and returns an [`UnlockedBitBox`]. +/// +/// ```compile_fail +/// async fn fingerprint(device: bitbox_api::UninitializedBitBox) { +/// device.root_fingerprint().await; +/// } +/// ``` +pub struct UninitializedBitBox { + session: Session, +} + +impl UninitializedBitBox { + pub async fn device_info(&self) -> Result { + self.session.device_info().await + } + + /// Returns which product we are connected to. + pub fn product(&self) -> Product { + self.session.communication.info.product + } + + /// Returns the firmware version. + pub fn version(&self) -> &semver::Version { + &self.session.communication.info.version + } + + /// Restores recovery words on the BitBox and returns the initialized, unlocked device. + pub async fn restore_from_mnemonic(self) -> Result, Error> { + { + let _api_call = self.session.begin_api_call().await; + let now = std::time::SystemTime::now(); + let duration_since_epoch = now.duration_since(std::time::UNIX_EPOCH).unwrap(); + match self + .session + .query_proto(Request::RestoreFromMnemonic( + pb::RestoreFromMnemonicRequest { + timestamp: duration_since_epoch.as_secs() as u32, + timezone_offset: chrono::Local::now().offset().local_minus_utc(), + }, + )) + .await? + { + Response::Success(_) => (), + _ => return Err(Error::UnexpectedResponse), + } + } + Ok(UnlockedBitBox { + session: self.session, + }) } } -/// Paired BitBox. This is where you can invoke most API functions like getting xpubs, displaying -/// receive addresses, etc. -pub struct PairedBitBox { +// The encrypted channel is shared by the public state types. Keeping it private prevents +// callers from bypassing the transitions that enable setup or wallet operations. +struct Session { communication: communication::HwwCommunication, api_mutex: AsyncMutex<()>, noise_send: Mutex, noise_recv: Mutex, } -impl PairedBitBox { +impl Session { fn from(communication: communication::HwwCommunication, host: HandshakeState) -> Self { let (send, recv) = host.get_ciphers(); - PairedBitBox { + Session { communication, api_mutex: AsyncMutex::new(()), noise_send: Mutex::new(send), @@ -295,7 +447,7 @@ impl PairedBitBox { /// multiplexed transport. Some public calls span multiple encrypted /// requests, so this mutex guard is held for the whole public method /// instead of only one query_proto() round trip. - pub(crate) async fn begin_api_call(&self) -> ApiMutexGuard<'_, ()> { + async fn begin_api_call(&self) -> ApiMutexGuard<'_, ()> { self.api_mutex.lock().await } @@ -314,10 +466,14 @@ impl PairedBitBox { let mut encrypted = vec![OP_NOISE_MSG]; encrypted.extend_from_slice({ let mut send = self.noise_send.lock().unwrap(); - let proto_msg = pb::Request { + let mut proto_msg = pb::Request { request: Some(request), }; - &send.encrypt_vec(&proto_msg.encode_to_vec()) + let plaintext = Zeroizing::new(proto_msg.encode_to_vec()); + if let Some(Request::UnlockHostInfo(info)) = &mut proto_msg.request { + info.passphrase.zeroize(); + } + &send.encrypt_vec(&plaintext) }); let response = self.communication.query(&encrypted).await?; @@ -350,7 +506,7 @@ impl PairedBitBox { } } - pub async fn device_info(&self) -> Result { + async fn device_info(&self) -> Result { let _api_call = self.begin_api_call().await; match self .query_proto(Request::DeviceInfo(pb::DeviceInfoRequest {})) @@ -360,10 +516,16 @@ impl PairedBitBox { _ => Err(Error::UnexpectedResponse), } } +} + +impl UnlockedBitBox { + pub async fn device_info(&self) -> Result { + self.session.device_info().await + } /// Returns which product we are connected to. pub fn product(&self) -> Product { - self.communication.info.product + self.session.communication.info.product } fn is_multi_edition(&self) -> bool { @@ -375,17 +537,18 @@ impl PairedBitBox { /// Returns the firmware version. pub fn version(&self) -> &semver::Version { - &self.communication.info.version + &self.session.communication.info.version } /// Returns the hex-encoded 4-byte root fingerprint. pub async fn root_fingerprint(&self) -> Result { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; self.root_fingerprint_inner().await.map(hex::encode) } pub(crate) async fn root_fingerprint_inner(&self) -> Result, Error> { match self + .session .query_proto(Request::Fingerprint(pb::RootFingerprintRequest {})) .await? { @@ -396,8 +559,9 @@ impl PairedBitBox { /// Show recovery words on the Bitbox. pub async fn show_mnemonic(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; + let _api_call = self.session.begin_api_call().await; match self + .session .query_proto(Request::ShowMnemonic(pb::ShowMnemonicRequest {})) .await? { @@ -406,31 +570,13 @@ impl PairedBitBox { } } - /// Restore from recovery words on the Bitbox. - pub async fn restore_from_mnemonic(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; - let now = std::time::SystemTime::now(); - let duration_since_epoch = now.duration_since(std::time::UNIX_EPOCH).unwrap(); - match self - .query_proto(Request::RestoreFromMnemonic( - pb::RestoreFromMnemonicRequest { - timestamp: duration_since_epoch.as_secs() as u32, - timezone_offset: chrono::Local::now().offset().local_minus_utc(), - }, - )) - .await? - { - Response::Success(_) => Ok(()), - _ => Err(Error::UnexpectedResponse), - } - } - /// Invokes the password change workflow on the device. /// Requires firmware version >=9.25.0. pub async fn change_password(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.25.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.25.0")?; match self + .session .query_proto(Request::ChangePassword(pb::ChangePasswordRequest {})) .await? { @@ -442,9 +588,10 @@ impl PairedBitBox { /// Invokes the BIP85-BIP39 workflow on the device, letting the user select the number of words /// (12, 28, 24) and an index and display a derived BIP-39 mnemonic. pub async fn bip85_app_bip39(&self) -> Result<(), Error> { - let _api_call = self.begin_api_call().await; - self.validate_version(">=9.17.0")?; + let _api_call = self.session.begin_api_call().await; + self.session.validate_version(">=9.17.0")?; match self + .session .query_proto(Request::Bip85(pb::Bip85Request { app: Some(pb::bip85_request::App::Bip39(())), })) @@ -521,6 +668,7 @@ mod tests { securechip_model: "ATECC608B".into(), bluetooth: None, password_stretching_algo: "pwhash".into(), + bootloader_version: None, })), } .encode_to_vec(); @@ -535,21 +683,23 @@ mod tests { } } - fn paired_for_test(state: Rc>) -> PairedBitBox { + fn paired_for_test(state: Rc>) -> UnlockedBitBox { let key = [7u8; 32]; - PairedBitBox { - communication: communication::HwwCommunication::from_test_parts( - Box::new(BlockingReadWrite { state }), - communication::Info { - version: semver::Version::parse("9.26.0").unwrap(), - product: Product::BitBox02Multi, - unlocked: true, - initialized: Some(true), - }, - ), - api_mutex: AsyncMutex::new(()), - noise_send: Mutex::new(CipherState::new(&key, 0)), - noise_recv: Mutex::new(CipherState::new(&key, 0)), + UnlockedBitBox { + session: Session { + communication: communication::HwwCommunication::from_test_parts( + Box::new(BlockingReadWrite { state }), + communication::Info { + version: semver::Version::parse("9.26.0").unwrap(), + product: Product::BitBox02Multi, + unlocked: true, + initialized: Some(true), + }, + ), + api_mutex: AsyncMutex::new(()), + noise_send: Mutex::new(CipherState::new(&key, 0)), + noise_recv: Mutex::new(CipherState::new(&key, 0)), + }, } } diff --git a/src/shiftcrypto.bitbox02.rs b/src/shiftcrypto.bitbox02.rs index 0e339da..34ecad6 100644 --- a/src/shiftcrypto.bitbox02.rs +++ b/src/shiftcrypto.bitbox02.rs @@ -121,6 +121,9 @@ pub struct DeviceInfoResponse { /// algorithm. #[prost(string, tag = "8")] pub password_stretching_algo: ::prost::alloc::string::String, + /// Marketing version of the installed stage1 bootloader. Not present on legacy bootloaders. + #[prost(string, optional, tag = "9")] + pub bootloader_version: ::core::option::Option<::prost::alloc::string::String>, } /// Nested message and enum types in `DeviceInfoResponse`. pub mod device_info_response { @@ -201,6 +204,117 @@ pub struct SetPasswordRequest { #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct ChangePasswordRequest {} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncIdentityRequest {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncIdentityResponse { + #[prost(bytes = "vec", tag = "1")] + pub auth_public_key: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub wrap_public_key: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignLoginIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignRefreshIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignRevokeAllTokensIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignCreateNamespaceInviteIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub challenge: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub namespace_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "3")] + pub invite_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "4")] + pub invite_server_secret_hash: ::prost::alloc::vec::Vec, + #[prost(uint64, tag = "5")] + pub expires_at: u64, + #[prost(uint32, tag = "6")] + pub max_accepted: u32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignJoinRequestIntentRequest { + #[prost(bytes = "vec", tag = "1")] + pub namespace_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub invite_id: ::prost::alloc::vec::Vec, + #[prost(string, tag = "3")] + pub server_origin: ::prost::alloc::string::String, + #[prost(uint64, tag = "4")] + pub expires_at: u64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncUnwrapNamespaceDekRequest { + #[prost(bytes = "vec", tag = "1")] + pub namespace_id: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub wrapped_dek: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncSignatureResponse { + #[prost(bytes = "vec", tag = "1")] + pub signature: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncUnwrapNamespaceDekResponse { + #[prost(bytes = "vec", tag = "1")] + pub namespace_dek: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncRequest { + #[prost(oneof = "bit_box_sync_request::Request", tags = "1, 2, 3, 4, 5, 6, 7")] + pub request: ::core::option::Option, +} +/// Nested message and enum types in `BitBoxSyncRequest`. +pub mod bit_box_sync_request { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Request { + #[prost(message, tag = "1")] + Identity(super::BitBoxSyncIdentityRequest), + #[prost(message, tag = "2")] + SignLoginIntent(super::BitBoxSyncSignLoginIntentRequest), + #[prost(message, tag = "3")] + SignRefreshIntent(super::BitBoxSyncSignRefreshIntentRequest), + #[prost(message, tag = "4")] + SignRevokeAllTokensIntent(super::BitBoxSyncSignRevokeAllTokensIntentRequest), + #[prost(message, tag = "5")] + SignCreateNamespaceInviteIntent( + super::BitBoxSyncSignCreateNamespaceInviteIntentRequest, + ), + #[prost(message, tag = "6")] + SignJoinRequestIntent(super::BitBoxSyncSignJoinRequestIntentRequest), + #[prost(message, tag = "7")] + UnwrapNamespaceDek(super::BitBoxSyncUnwrapNamespaceDekRequest), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct BitBoxSyncResponse { + #[prost(oneof = "bit_box_sync_response::Response", tags = "1, 2, 3")] + pub response: ::core::option::Option, +} +/// Nested message and enum types in `BitBoxSyncResponse`. +pub mod bit_box_sync_response { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Response { + #[prost(message, tag = "1")] + Identity(super::BitBoxSyncIdentityResponse), + #[prost(message, tag = "2")] + Signature(super::BitBoxSyncSignatureResponse), + #[prost(message, tag = "3")] + UnwrapNamespaceDek(super::BitBoxSyncUnwrapNamespaceDekResponse), + } +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct BluetoothToggleEnabledRequest {} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct BluetoothUpgradeInitRequest { @@ -549,7 +663,7 @@ pub struct BtcSignInitRequest { pub num_inputs: u32, #[prost(uint32, tag = "6")] pub num_outputs: u32, - /// must be <500000000 + /// Consensus nLockTime: values below 500000000 are block heights, otherwise Unix timestamps. #[prost(uint32, tag = "7")] pub locktime: u32, #[prost(enumeration = "btc_sign_init_request::FormatUnit", tag = "8")] @@ -703,6 +817,8 @@ pub struct BtcSignInputRequest { /// References a script config from BTCSignInitRequest #[prost(uint32, tag = "7")] pub script_config_index: u32, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "8")] pub host_nonce_commitment: ::core::option::Option, } @@ -866,9 +982,9 @@ pub struct BtcPaymentRequestRequest { } /// Nested message and enum types in `BTCPaymentRequestRequest`. pub mod btc_payment_request_request { - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + #[derive(Clone, PartialEq, ::prost::Message)] pub struct Memo { - #[prost(oneof = "memo::Memo", tags = "1")] + #[prost(oneof = "memo::Memo", tags = "1, 2")] pub memo: ::core::option::Option, } /// Nested message and enum types in `Memo`. @@ -878,10 +994,54 @@ pub mod btc_payment_request_request { #[prost(string, tag = "1")] pub note: ::prost::alloc::string::String, } - #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct CoinPurchaseMemo { + /// SLIP-44 coin type + #[prost(uint32, tag = "1")] + pub coin_type: u32, + /// Human-readable amount (e.g. "0.25 ETH") + #[prost(string, tag = "2")] + pub amount: ::prost::alloc::string::String, + /// Address to send the purchased coins to + #[prost(string, tag = "3")] + pub address: ::prost::alloc::string::String, + #[prost(oneof = "coin_purchase_memo::AddressDerivation", tags = "4, 5")] + pub address_derivation: ::core::option::Option< + coin_purchase_memo::AddressDerivation, + >, + } + /// Nested message and enum types in `CoinPurchaseMemo`. + pub mod coin_purchase_memo { + /// Derivation info for verifying address ownership. + /// NOT part of the SLIP-24 sighash. + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] + pub struct EthAddressDerivation { + /// Keypath to the address + #[prost(uint32, repeated, tag = "1")] + pub keypath: ::prost::alloc::vec::Vec, + } + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct BtcAddressDerivation { + /// Script config + keypath are needed to derive BTC/LTC-family addresses. + #[prost(message, optional, tag = "1")] + pub script_config: ::core::option::Option< + super::super::super::BtcScriptConfigWithKeypath, + >, + } + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum AddressDerivation { + #[prost(message, tag = "4")] + Eth(EthAddressDerivation), + #[prost(message, tag = "5")] + Btc(BtcAddressDerivation), + } + } + #[derive(Clone, PartialEq, ::prost::Oneof)] pub enum Memo { #[prost(message, tag = "1")] TextMemo(TextMemo), + #[prost(message, tag = "2")] + CoinPurchaseMemo(CoinPurchaseMemo), } } } @@ -893,6 +1053,8 @@ pub struct BtcSignMessageRequest { pub script_config: ::core::option::Option, #[prost(bytes = "vec", tag = "3")] pub msg: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "4")] pub host_nonce_commitment: ::core::option::Option, } @@ -1404,6 +1566,8 @@ pub struct EthSignRequest { pub value: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "8")] pub data: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "9")] pub host_nonce_commitment: ::core::option::Option, /// If non-zero, `coin` is ignored and `chain_id` is used to identify the network. @@ -1416,7 +1580,7 @@ pub struct EthSignRequest { pub data_length: u32, } /// TX payload for an EIP-1559 (type 2) transaction: -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct EthSignEip1559Request { #[prost(uint64, tag = "1")] pub chain_id: u64, @@ -1442,6 +1606,8 @@ pub struct EthSignEip1559Request { pub value: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "9")] pub data: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "10")] pub host_nonce_commitment: ::core::option::Option, #[prost(enumeration = "EthAddressCase", tag = "11")] @@ -1449,6 +1615,8 @@ pub struct EthSignEip1559Request { /// For streaming: if non-zero, data field should be empty and data will be requested in chunks #[prost(uint32, tag = "12")] pub data_length: u32, + #[prost(message, optional, tag = "13")] + pub payment_request: ::core::option::Option, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct EthSignDataRequestChunkResponse { @@ -1471,6 +1639,8 @@ pub struct EthSignMessageRequest { pub keypath: ::prost::alloc::vec::Vec, #[prost(bytes = "vec", tag = "3")] pub msg: ::prost::alloc::vec::Vec, + /// If omitted, the signature uses the historical deterministic zero-contribution S2C fallback. + /// This differs from plain RFC6979 and does not provide anti-klepto protection. #[prost(message, optional, tag = "4")] pub host_nonce_commitment: ::core::option::Option, /// If non-zero, `coin` is ignored and `chain_id` is used to identify the network. @@ -1493,6 +1663,7 @@ pub struct EthSignTypedMessageRequest { pub types: ::prost::alloc::vec::Vec, #[prost(string, tag = "4")] pub primary_type: ::prost::alloc::string::String, + /// If omitted, plain RFC6979 signing is used without anti-klepto protection. #[prost(message, optional, tag = "5")] pub host_nonce_commitment: ::core::option::Option, } @@ -1750,6 +1921,80 @@ impl EthAddressCase { } } } +/// Unlock inside the paired Noise channel (since v9.28.0). Uninitialized and already unlocked +/// devices return DONE immediately and are left unchanged. Continuations are only valid within +/// this workflow. +/// If the passphrase feature is enabled, device entry returns PASSPHRASE_PENDING. The host +/// polls with UnlockContinueRequest until entry completes or it requests host entry. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockRequest {} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockContinueRequest { + /// False polls device entry; true interrupts it to ask for host-entry consent. + /// After PASSPHRASE_ENTERED, continue to await confirmation and unlock; + /// host entry is ignored. + #[prost(bool, tag = "1")] + pub request_host_entry: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockHostInfoRequest { + /// Only sent after HOST_ENTRY_READY. Absent cancels host input and restarts device entry; + /// the empty string submits the empty passphrase. The device confirms the actual value. + #[prost(string, optional, tag = "1")] + pub passphrase: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlockResponse { + #[prost(enumeration = "unlock_response::State", tag = "1")] + pub state: i32, +} +/// Nested message and enum types in `UnlockResponse`. +pub mod unlock_response { + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + ::prost::Enumeration + )] + #[repr(i32)] + pub enum State { + PassphrasePending = 0, + HostEntryReady = 1, + /// Passphrase entry on the device finished; confirmation may still be pending. + /// Withdraw host entry and send UnlockContinue to await completion. + PassphraseEntered = 2, + Done = 3, + } + impl State { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::PassphrasePending => "PASSPHRASE_PENDING", + Self::HostEntryReady => "HOST_ENTRY_READY", + Self::PassphraseEntered => "PASSPHRASE_ENTERED", + Self::Done => "DONE", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PASSPHRASE_PENDING" => Some(Self::PassphrasePending), + "HOST_ENTRY_READY" => Some(Self::HostEntryReady), + "PASSPHRASE_ENTERED" => Some(Self::PassphraseEntered), + "DONE" => Some(Self::Done), + _ => None, + } + } + } +} #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ElectrumEncryptionKeyRequest { #[prost(uint32, repeated, tag = "1")] @@ -1887,7 +2132,7 @@ pub struct Success {} pub struct Request { #[prost( oneof = "request::Request", - tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30" + tags = "2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34" )] pub request: ::core::option::Option, } @@ -1953,13 +2198,21 @@ pub mod request { Bluetooth(super::BluetoothRequest), #[prost(message, tag = "30")] ChangePassword(super::ChangePasswordRequest), + #[prost(message, tag = "31")] + BitboxSync(super::BitBoxSyncRequest), + #[prost(message, tag = "32")] + Unlock(super::UnlockRequest), + #[prost(message, tag = "33")] + UnlockContinue(super::UnlockContinueRequest), + #[prost(message, tag = "34")] + UnlockHostInfo(super::UnlockHostInfoRequest), } } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Response { #[prost( oneof = "response::Response", - tags = "1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17" + tags = "1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19" )] pub response: ::core::option::Option, } @@ -2000,5 +2253,9 @@ pub mod response { Bip85(super::Bip85Response), #[prost(message, tag = "17")] Bluetooth(super::BluetoothResponse), + #[prost(message, tag = "18")] + BitboxSync(super::BitBoxSyncResponse), + #[prost(message, tag = "19")] + Unlock(super::UnlockResponse), } } diff --git a/src/u2fframing.rs b/src/u2fframing.rs index a28aa2e..cb82154 100644 --- a/src/u2fframing.rs +++ b/src/u2fframing.rs @@ -14,7 +14,7 @@ const HEADER_CONT_LEN: usize = 5; // number 0, which then increments by one to a maximum of 127. // With a packet size of 64 bytes (max for full-speed devices), this means that the maximum message // payload length is 64 - 7 + 128 * (64 - 5) = 7609 bytes. -//pub const MAX_PAYLOAD_LEN: usize = 64 - HEADER_INIT_LEN + 128 * (64 - HEADER_CONT_LEN); +const MAX_PAYLOAD_LEN: usize = 64 - HEADER_INIT_LEN + 128 * (64 - HEADER_CONT_LEN); // This is the buffer size needed to fit the largest possible u2f package with headers pub const MAX_LEN: usize = 129 * 64; @@ -92,6 +92,12 @@ impl U2fHid { /// Encode function. pub fn encode(&self, mut message: &[u8], mut buf: &mut [u8]) -> io::Result { + if message.len() > MAX_PAYLOAD_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Message too long", + )); + } let enc_len = Self::get_encoded_len(message.len() as u16); if buf.len() < enc_len { return Err(std::io::Error::new( @@ -121,7 +127,7 @@ impl U2fHid { message = &message[len..]; seq += 1; - if seq > 127 { + if seq > 127 && !message.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "More frames than allowed", @@ -173,6 +179,21 @@ impl U2fHid { #[cfg(test)] mod tests { use super::*; + #[test] + fn test_u2fhid_payload_length_limit() { + let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); + let payload = vec![0x42; MAX_PAYLOAD_LEN]; + let mut buf = [0; MAX_LEN]; + assert_eq!(codec.encode(&payload, &mut buf).unwrap(), MAX_LEN); + assert_eq!(codec.decode(&buf).unwrap().unwrap(), payload); + for len in [MAX_PAYLOAD_LEN + 1, 65536] { + assert_eq!( + codec.encode(&vec![0; len], &mut buf).unwrap_err().kind(), + io::ErrorKind::InvalidInput, + ); + } + } + #[test] fn test_u2fhid_encode_single() { let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); @@ -199,6 +220,19 @@ mod tests { assert_eq!(&data[..len], &expect[..]); } + #[test] + fn test_u2fhid_encode_last_continuation_frame() { + let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); + // These payloads require a partial and a full frame with sequence 127. + for len in [7551, 7609] { + let payload = vec![0x42; len]; + let mut buf = [0; MAX_LEN]; + assert_eq!(codec.encode(&payload, &mut buf).unwrap(), MAX_LEN); + assert_eq!(buf[128 * 64 + 4], 127); + assert_eq!(codec.decode(&buf).unwrap().unwrap(), payload); + } + } + #[test] fn test_u2fhid_decode_single() { let codec = U2fHid::with_cid(0xEEEEEEEE, 0x55); diff --git a/src/unlock.rs b/src/unlock.rs new file mode 100644 index 0000000..43d04b5 --- /dev/null +++ b/src/unlock.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::{error::Error, pb, runtime::Runtime, LockedBitBox, Session, UnlockedBitBox}; +use futures_util::future::{poll_fn, select, Either}; +use futures_util::task::AtomicWaker; +use pb::{request::Request, response::Response, unlock_response::State}; +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use std::task::Poll; +use std::time::Duration; + +#[cfg(feature = "multithreaded")] +type InputFuture = futures_util::future::BoxFuture<'static, Result, Error>>; +#[cfg(not(feature = "multithreaded"))] +type InputFuture = futures_util::future::LocalBoxFuture<'static, Result, Error>>; + +#[cfg(feature = "multithreaded")] +type InputCallback = dyn Fn() -> InputFuture + Send + Sync; +#[cfg(not(feature = "multithreaded"))] +type InputCallback = dyn Fn() -> InputFuture; + +#[cfg(feature = "multithreaded")] +type AvailabilityCallback = dyn Fn(Option) + Send + Sync; +#[cfg(not(feature = "multithreaded"))] +type AvailabilityCallback = dyn Fn(Option); + +/// Optional host entry for the device's BIP39 passphrase setting (firmware v9.28.0+). +/// +/// With only an input callback, host entry is requested automatically once per unlock. +/// Rejection, cancellation or invalid input falls back to device entry. Add an availability +/// callback to let the application decide when to request host entry instead. +/// +/// Callbacks run as part of [`LockedBitBox::unlock`] and must not make device +/// queries. The input callback must yield while waiting for the user; GUI applications should +/// send a message to their UI and await its reply. Dropping `unlock` drops that future, +/// so the callback should arrange to dismiss its dialog on cancellation. +/// +/// The default configuration uses device entry only. Older firmware unlocks on the device before +/// pairing, without using this configuration. +#[derive(Default)] +pub struct PassphraseConfig { + enter: Option>, + available: Option>, +} + +impl PassphraseConfig { + /// Configure async host input, called only after approval on the device. + /// Return `Ok(None)` to cancel, or `Ok(Some(passphrase))` to submit, including an empty + /// string. Errors abort unlocking; [`Error::PassphraseInput`] can describe UI errors. + #[cfg(feature = "multithreaded")] + pub fn new(enter_mnemonic_passphrase: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future, Error>> + Send + 'static, + { + Self { + enter: Some(Box::new(move || Box::pin(enter_mnemonic_passphrase()))), + available: None, + } + } + + /// Configure async host input, called only after approval on the device. + /// Return `Ok(None)` to cancel, or `Ok(Some(passphrase))` to submit, including an empty + /// string. Errors abort unlocking; [`Error::PassphraseInput`] can describe UI errors. + #[cfg(not(feature = "multithreaded"))] + pub fn new(enter_mnemonic_passphrase: F) -> Self + where + F: Fn() -> Fut + 'static, + Fut: Future, Error>> + 'static, + { + Self { + enter: Some(Box::new(move || Box::pin(enter_mnemonic_passphrase()))), + available: None, + } + } + + /// Let the application offer a host-entry control instead of requesting automatically. + /// `Some(handle)` enables the control; `None` withdraws it. Notify once per device-entry + /// phase, and again with a fresh handle if entry restarts. This callback must return promptly. + #[cfg(feature = "multithreaded")] + pub fn with_on_host_passphrase_available(mut self, callback: F) -> Self + where + F: Fn(Option) + Send + Sync + 'static, + { + self.available = Some(Box::new(callback)); + self + } + + /// Let the application offer a host-entry control instead of requesting automatically. + /// `Some(handle)` enables the control; `None` withdraws it. Notify once per device-entry + /// phase, and again with a fresh handle if entry restarts. This callback must return promptly. + #[cfg(not(feature = "multithreaded"))] + pub fn with_on_host_passphrase_available(mut self, callback: F) -> Self + where + F: Fn(Option) + 'static, + { + self.available = Some(Box::new(callback)); + self + } +} + +#[derive(Default)] +struct HostEntrySignal { + requested: AtomicBool, + waker: AtomicWaker, +} + +/// Requests device consent to enter the passphrase on the host. +/// +/// Safe to clone and call from a UI thread, even without the `multithreaded` feature. +/// Handles from withdrawn controls have no effect on subsequent entry phases or connections. +#[derive(Clone)] +pub struct HostPassphraseRequest { + signal: Weak, +} + +impl HostPassphraseRequest { + /// Queue one request and wake the unlock loop, without doing device I/O. + /// Repeated calls for the same entry phase coalesce into a single request. + pub fn request(&self) { + if let Some(signal) = self.signal.upgrade() { + signal.requested.store(true, Ordering::Release); + signal.waker.wake(); + } + } +} + +struct Availability<'a> { + notify: Option<&'a AvailabilityCallback>, + current: Option>, +} + +impl Availability<'_> { + fn offer(&mut self) { + if self.current.is_none() { + if let Some(notify) = self.notify { + let signal = Arc::new(HostEntrySignal::default()); + let handle = HostPassphraseRequest { + signal: Arc::downgrade(&signal), + }; + self.current = Some(signal); + notify(Some(handle)); + } + } + } + + fn withdraw(&mut self) { + if self.current.take().is_some() { + if let Some(notify) = self.notify { + notify(None); + } + } + } + + async fn wait(&self) -> bool { + let delay = std::pin::pin!(R::sleep(Duration::from_millis(100))); + let Some(signal) = &self.current else { + delay.await; + return false; + }; + let requested = std::pin::pin!(poll_fn(|cx| { + signal.waker.register(cx.waker()); + if signal.requested.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + Poll::Pending + } + })); + matches!(select(requested, delay).await, Either::Left(_)) + } +} + +impl Drop for Availability<'_> { + fn drop(&mut self) { + self.withdraw(); + } +} + +pub(crate) fn supports_paired_unlock(version: &semver::Version) -> bool { + *version >= semver::Version::new(9, 28, 0) +} + +impl LockedBitBox { + /// Unlocks the device and enables wallet operations. + /// + /// Pass [`PassphraseConfig::default`] for device entry, or configure host entry callbacks. + /// This consumes the locked device. On failure or cancellation, reconnect to try again. + pub async fn unlock(self, config: PassphraseConfig) -> Result, Error> { + self.session.unlock(&config).await?; + Ok(UnlockedBitBox { + session: self.session, + }) + } +} + +impl Session { + async fn unlock(&self, config: &PassphraseConfig) -> Result<(), Error> { + let _api_call = self.begin_api_call().await; + let result = self.unlock_inner(config).await; + if result.is_err() { + // Release an abandoned workflow after a known failure. A dropped future cannot do + // async cleanup; reconnect also resets the session before starting a new handshake. + let _ = self.communication.reset_session_once().await; + } + result + } + + async fn unlock_inner(&self, config: &PassphraseConfig) -> Result<(), Error> { + let mut availability = Availability { + notify: config.available.as_deref(), + current: None, + }; + let mut auto_request = config.enter.is_some() && config.available.is_none(); + let mut consent_requested = false; + let mut request = Request::Unlock(pb::UnlockRequest {}); + + loop { + let Response::Unlock(reply) = self.query_proto(request).await? else { + return Err(Error::UnexpectedResponse); + }; + match State::try_from(reply.state).map_err(|_| Error::UnexpectedResponse)? { + State::Done => return Ok(()), + State::PassphrasePending => { + consent_requested = if auto_request { + // Request only once so rejection/cancellation can fall back to device + // entry without immediately prompting for host entry again. + auto_request = false; + true + } else { + if config.enter.is_some() { + availability.offer(); + } + availability.wait::().await + }; + if consent_requested { + availability.withdraw(); + } + request = Request::UnlockContinue(pb::UnlockContinueRequest { + request_host_entry: consent_requested, + }); + } + State::PassphraseEntered => { + consent_requested = false; + availability.withdraw(); + // Confirmation may still be pending; the host control must stay hidden. + request = Request::UnlockContinue(pb::UnlockContinueRequest::default()); + } + State::HostEntryReady => { + if !consent_requested { + return Err(Error::UnexpectedResponse); + } + consent_requested = false; + let enter = config.enter.as_ref().ok_or(Error::UnexpectedResponse)?; + request = Request::UnlockHostInfo(pb::UnlockHostInfoRequest { + passphrase: enter().await?, + }); + } + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/unlock/tests.rs b/src/unlock/tests.rs new file mode 100644 index 0000000..32e5df6 --- /dev/null +++ b/src/unlock/tests.rs @@ -0,0 +1,813 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::communication::{Error as CommunicationError, ReadWrite}; +use crate::{ + BitBox, CipherState, HandshakeState, NoiseConfig, NoiseConfigData, PairedBitBox, PairingBitBox, +}; +use async_trait::async_trait; +use futures_channel::oneshot; +use noise_protocol::DH; +use prost::Message; +use std::collections::VecDeque; +use std::sync::Mutex; + +async fn poll_once(mut future: std::pin::Pin<&mut F>) -> Poll { + poll_fn(|cx| Poll::Ready(future.as_mut().poll(cx))).await +} + +struct TestRuntime; + +#[cfg_attr(feature = "multithreaded", async_trait)] +#[cfg_attr(not(feature = "multithreaded"), async_trait(?Send))] +impl Runtime for TestRuntime { + async fn sleep(_: Duration) { + tokio::task::yield_now().await; + } +} + +enum Reply { + Proto(Response), + Malformed, + ReadError, + Pending, +} + +fn state(state: State) -> Reply { + Reply::Proto(Response::Unlock(pb::UnlockResponse { + state: state as i32, + })) +} + +struct Peer { + version: String, + initialized: Option, + unlocked: bool, + legacy_response: Vec, + codec: crate::u2fframing::U2fHid, + incoming: Vec, + outgoing: Vec, + handshake: HandshakeState, + ciphers: Option<(CipherState, CipherState)>, + script: VecDeque, + commands: Vec, + requests: Vec, + read_error: bool, + pending: bool, + pairing_response: u8, + require_pairing: bool, +} + +impl Peer { + fn respond(&mut self, request: &[u8]) -> Vec { + self.commands.push(if request[0] == 0 { + request[1] + } else { + request[0] + }); + match request { + [b'i'] => { + let version = format!("v{}", self.version); + let mut info = vec![version.len() as u8]; + info.extend_from_slice(version.as_bytes()); + info.extend_from_slice(&[0, 0, u8::from(self.unlocked)]); + if let Some(initialized) = self.initialized { + info.push(u8::from(initialized)); + } + info + } + [3] => vec![0], + [0, b'h'] => vec![0, 0], + [0, b'u'] => { + let mut response = vec![0]; + response.extend_from_slice(&self.legacy_response); + response + } + [0, b'v'] => vec![0, self.pairing_response], + [0, b'H', message @ ..] => { + self.handshake.read_message_vec(message).unwrap(); + let mut response = vec![0, 0]; + if self.handshake.completed() { + self.ciphers = Some(self.handshake.get_ciphers()); + response.push(u8::from(self.require_pairing)); + } else { + response.extend_from_slice(&self.handshake.write_message_vec(b"").unwrap()); + } + response + } + [0, b'n', message @ ..] => { + let (receive, send) = self.ciphers.as_mut().unwrap(); + let plaintext = receive.decrypt_vec(message).unwrap(); + let request = pb::Request::decode(plaintext.as_slice()) + .unwrap() + .request + .unwrap(); + self.requests.push(request); + let plaintext = match self.script.pop_front().unwrap() { + Reply::Proto(response) => pb::Response { + response: Some(response), + } + .encode_to_vec(), + Reply::Malformed => vec![0xff], + Reply::ReadError => { + self.read_error = true; + return vec![]; + } + Reply::Pending => { + self.pending = true; + return vec![]; + } + }; + let mut response = vec![0, 0]; + response.extend_from_slice(&send.encrypt_vec(&plaintext)); + response + } + _ => panic!("unexpected command: {request:?}"), + } + } +} + +struct Transport(Arc>); +impl crate::Threading for Transport {} + +#[cfg_attr(feature = "multithreaded", async_trait)] +#[cfg_attr(not(feature = "multithreaded"), async_trait(?Send))] +impl ReadWrite for Transport { + fn write(&self, msg: &[u8]) -> Result { + let mut peer = self.0.lock().unwrap(); + peer.incoming.extend_from_slice(msg); + if let Some(request) = peer.codec.decode(&peer.incoming).unwrap() { + peer.incoming.clear(); + let response = peer.respond(&request); + if !peer.read_error && !peer.pending { + let mut framed = vec![0; crate::u2fframing::MAX_LEN]; + let len = peer.codec.encode(&response, &mut framed).unwrap(); + framed.truncate(len); + peer.outgoing = framed; + } + } + Ok(msg.len()) + } + + async fn read(&self) -> Result, CommunicationError> { + let pending = { + let mut peer = self.0.lock().unwrap(); + if std::mem::take(&mut peer.read_error) { + return Err(CommunicationError::Read); + } + if !peer.pending { + return Ok(std::mem::take(&mut peer.outgoing)); + } + peer.pending + }; + assert!(pending); + futures_util::future::pending().await + } +} + +struct CachedNoise; +impl crate::Threading for CachedNoise {} +impl NoiseConfig for CachedNoise { + fn read_config(&self) -> Result { + let key = noise_rust_crypto::sensitive::Sensitive::from([7u8; 32].into()); + Ok(NoiseConfigData { + app_static_privkey: Some([8; 32]), + device_static_pubkeys: vec![noise_rust_crypto::X25519::pubkey(&key).to_vec()], + }) + } +} + +struct Fixture(Arc>); + +impl Fixture { + fn new(version: &str, script: Vec) -> Self { + Self(Arc::new(Mutex::new(Peer { + version: version.into(), + initialized: (semver::Version::parse(version).unwrap() + >= semver::Version::new(9, 20, 0)) + .then_some(true), + unlocked: false, + legacy_response: vec![0], + codec: crate::u2fframing::U2fHid::new(crate::communication::FIRMWARE_CMD), + incoming: vec![], + outgoing: vec![], + handshake: HandshakeState::new( + noise_protocol::patterns::noise_xx(), + false, + b"Noise_XX_25519_ChaChaPoly_SHA256", + Some(noise_rust_crypto::sensitive::Sensitive::from( + [7u8; 32].into(), + )), + None, + None, + None, + ), + ciphers: None, + script: script.into(), + commands: vec![], + requests: vec![], + read_error: false, + pending: false, + pairing_response: 0, + require_pairing: true, + }))) + } + + async fn pair(&self) -> PairingBitBox { + BitBox::from_transport(Box::new(Transport(self.0.clone())), Box::new(CachedNoise)) + .await + .unwrap() + .unlock_and_pair() + .await + .unwrap() + } + + async fn locked(&self) -> LockedBitBox { + let PairedBitBox::Locked(device) = self.pair().await.wait_confirm().await.unwrap() else { + panic!("expected a locked device"); + }; + device + } + + async fn connect( + &self, + config: PassphraseConfig, + ) -> Result, Error> { + self.locked().await.unlock(config).await + } + + fn requests(&self) -> Vec { + self.0.lock().unwrap().requests.clone() + } +} + +fn start() -> Request { + Request::Unlock(pb::UnlockRequest {}) +} +fn poll(host: bool) -> Request { + Request::UnlockContinue(pb::UnlockContinueRequest { + request_host_entry: host, + }) +} +fn input(value: Option<&str>) -> Request { + Request::UnlockHostInfo(pb::UnlockHostInfoRequest { + passphrase: value.map(str::to_owned), + }) +} + +#[derive(Debug, PartialEq)] +enum Event { + Available, + Withdrawn, + Input, +} + +#[derive(Default)] +struct Ui { + events: Vec, + handles: Vec, + input: VecDeque>, +} + +fn manual(ui: &Arc>, clicks: usize) -> PassphraseConfig { + let entry_ui = ui.clone(); + let available_ui = ui.clone(); + PassphraseConfig::new(move || { + let mut ui = entry_ui.lock().unwrap(); + assert_eq!(ui.events.last(), Some(&Event::Withdrawn)); + ui.events.push(Event::Input); + let input = ui.input.pop_front().unwrap(); + async move { Ok(input) } + }) + .with_on_host_passphrase_available(move |handle| { + let mut ui = available_ui.lock().unwrap(); + match handle { + Some(handle) => { + ui.events.push(Event::Available); + if ui.handles.len() < clicks { + handle.request(); + handle.request(); + } + // A stale click during a new attempt must never request host entry. + for old in &ui.handles { + old.request(); + } + ui.handles.push(handle); + } + None => ui.events.push(Event::Withdrawn), + } + }) +} + +#[tokio::test] +async fn pairing_returns_the_correct_state() { + for version in ["7.0.0", "9.19.0", "9.20.0", "9.27.1", "9.28.0"] { + for initialized in [false, true] { + for cached in [false, true] { + let modern = version == "9.28.0"; + let fixture = Fixture::new(version, vec![state(State::Done)]); + { + let mut peer = fixture.0.lock().unwrap(); + peer.require_pairing = !cached; + peer.initialized = peer.initialized.map(|_| initialized); + peer.legacy_response = vec![if initialized { 0 } else { 2 }]; + } + let pairing = fixture.pair().await; + assert_eq!(pairing.get_pairing_code().is_none(), cached); + let paired = pairing.wait_confirm().await.unwrap(); + { + let peer = fixture.0.lock().unwrap(); + let expected: &[u8] = match (modern, cached) { + (false, false) => b"iuhHHv", + (false, true) => b"iuhHH", + (true, false) => b"i\x03hHHv", + (true, true) => b"i\x03hHH", + }; + assert_eq!(peer.commands, expected); + assert!(peer.requests.is_empty()); + } + match paired { + PairedBitBox::Uninitialized(_) => assert!(!initialized), + PairedBitBox::Unlocked(_) => assert!(initialized && !modern), + PairedBitBox::Locked(device) => { + assert!(initialized && modern); + let config = PassphraseConfig::new(|| async { panic!("unexpected input") }) + .with_on_host_passphrase_available(|_| { + panic!("unexpected availability") + }); + device.unlock(config).await.unwrap(); + } + } + assert_eq!( + fixture.requests(), + if modern && initialized { + vec![start()] + } else { + vec![] + } + ); + } + } + } +} + +#[tokio::test] +async fn legacy_unlock_rejects_failure_and_malformed_responses() { + for response in [vec![1], vec![], vec![3], vec![0, 0]] { + let fixture = Fixture::new("9.19.0", vec![]); + fixture.0.lock().unwrap().legacy_response = response.clone(); + let bitbox = BitBox::::from_transport( + Box::new(Transport(fixture.0.clone())), + Box::new(CachedNoise), + ) + .await + .unwrap(); + let result = bitbox.unlock_and_pair().await; + if response == [1] { + assert!(matches!( + result, + Err(Error::BitBox(crate::error::BitBoxError::Generic)) + )); + } else { + assert!(matches!(result, Err(Error::UnexpectedResponse))); + } + assert_eq!(fixture.0.lock().unwrap().commands, b"iu"); + } +} + +#[tokio::test] +async fn modern_firmware_requires_initialization_info() { + let fixture = Fixture::new("9.28.0", vec![]); + fixture.0.lock().unwrap().initialized = None; + assert!(matches!( + fixture.pair().await.wait_confirm().await, + Err(Error::UnexpectedResponse) + )); + assert!(fixture.requests().is_empty()); +} + +#[tokio::test] +async fn pairing_completes_before_unlock_starts() { + for cached in [false, true] { + for unlocked in [false, true] { + let fixture = Fixture::new("9.28.0", vec![Reply::Pending]); + { + let mut peer = fixture.0.lock().unwrap(); + peer.require_pairing = !cached; + peer.unlocked = unlocked; + } + // Pairing returns even when the firmware would wait indefinitely for a password. + let device = fixture.locked().await; + assert!(fixture.requests().is_empty()); + let mut unlock = Box::pin(device.unlock(PassphraseConfig::default())); + assert!(poll_once(unlock.as_mut()).await.is_pending()); + assert_eq!(fixture.requests(), [start()]); + } + } +} + +#[tokio::test] +async fn rejected_pairing_never_starts_unlock() { + let fixture = Fixture::new("9.28.0", vec![]); + fixture.0.lock().unwrap().pairing_response = 1; + assert!(matches!( + fixture.pair().await.wait_confirm().await, + Err(Error::NoisePairingRejected) + )); + assert!(fixture.requests().is_empty()); +} + +#[tokio::test] +async fn device_entry_withdraws_before_confirmation() { + for clicks in [0, 1] { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::PassphraseEntered), + Reply::Pending, + ], + ); + let ui = Arc::new(Mutex::new(Ui::default())); + let locked = fixture.locked().await; + let mut wait = Box::pin(locked.unlock(manual(&ui, clicks))); + assert!(poll_once(wait.as_mut()).await.is_pending()); + while fixture.requests().len() < 3 { + assert!(poll_once(wait.as_mut()).await.is_pending()); + } + assert_eq!( + ui.lock().unwrap().events, + [Event::Available, Event::Withdrawn] + ); + assert_eq!(fixture.requests(), [start(), poll(clicks > 0), poll(false)]); + drop(wait); + assert!(ui.lock().unwrap().handles[0].signal.upgrade().is_none()); + } +} + +#[tokio::test] +async fn device_entry_polls_and_reoffers_after_rejection() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::PassphrasePending), + state(State::PassphraseEntered), + state(State::PassphrasePending), + state(State::Done), + ], + ); + let ui = Arc::new(Mutex::new(Ui::default())); + fixture.connect(manual(&ui, 0)).await.unwrap(); + assert_eq!( + ui.lock().unwrap().events, + [ + Event::Available, + Event::Withdrawn, + Event::Available, + Event::Withdrawn + ] + ); + assert_eq!( + fixture.requests(), + [start(), poll(false), poll(false), poll(false), poll(false)] + ); +} + +#[tokio::test] +async fn host_input_requires_consent_and_preserves_exact_value() { + for value in [Some(" host passphrase "), Some(""), None] { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + state(State::PassphrasePending), + state(State::Done), + ], + ); + let ui = Arc::new(Mutex::new(Ui { + input: vec![value.map(str::to_owned)].into(), + ..Ui::default() + })); + fixture.connect(manual(&ui, 1)).await.unwrap(); + assert_eq!( + fixture.requests(), + [start(), poll(true), input(value), poll(false)] + ); + assert_eq!( + ui.lock().unwrap().events, + [ + Event::Available, + Event::Withdrawn, + Event::Input, + Event::Available, + Event::Withdrawn + ] + ); + assert!(ui + .lock() + .unwrap() + .handles + .iter() + .all(|handle| handle.signal.upgrade().is_none())); + } +} + +#[tokio::test] +async fn consent_rejection_allows_a_fresh_host_request() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::PassphrasePending), + state(State::HostEntryReady), + state(State::Done), + ], + ); + let ui = Arc::new(Mutex::new(Ui { + input: vec![Some("retry".into())].into(), + ..Ui::default() + })); + fixture.connect(manual(&ui, 2)).await.unwrap(); + assert_eq!( + fixture.requests(), + [start(), poll(true), poll(true), input(Some("retry"))] + ); + assert_eq!( + ui.lock().unwrap().events, + [ + Event::Available, + Event::Withdrawn, + Event::Available, + Event::Withdrawn, + Event::Input + ] + ); +} + +#[tokio::test] +async fn automatic_request_happens_only_once() { + for (next, value) in [ + (State::HostEntryReady, Some("")), + (State::HostEntryReady, None), + (State::HostEntryReady, Some("invalid\ninput")), + (State::PassphrasePending, None), + (State::PassphraseEntered, None), + ] { + let script = if next == State::HostEntryReady { + vec![ + state(State::PassphrasePending), + state(next), + state(State::PassphrasePending), + state(State::Done), + ] + } else { + vec![ + state(State::PassphrasePending), + state(next), + state(State::Done), + ] + }; + let fixture = Fixture::new("9.28.0", script); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let input_calls = calls.clone(); + fixture + .connect(PassphraseConfig::new(move || { + input_calls.fetch_add(1, Ordering::Relaxed); + async move { Ok(value.map(str::to_owned)) } + })) + .await + .unwrap(); + let mut expected = vec![start(), poll(true)]; + if next == State::HostEntryReady { + expected.push(input(value)); + } + expected.push(poll(false)); + assert_eq!(fixture.requests(), expected); + assert_eq!( + calls.load(Ordering::Relaxed), + usize::from(next == State::HostEntryReady) + ); + } +} + +#[tokio::test] +async fn no_input_callback_means_device_entry() { + let fixture = Fixture::new( + "9.28.0", + vec![state(State::PassphrasePending), state(State::Done)], + ); + fixture + .connect( + PassphraseConfig::default() + .with_on_host_passphrase_available(|_| panic!("no host input configured")), + ) + .await + .unwrap(); + assert_eq!(fixture.requests(), [start(), poll(false)]); +} + +#[tokio::test] +async fn errors_withdraw_controls_and_reset_once() { + for reply in [ + state(State::HostEntryReady), + Reply::Proto(Response::Unlock(pb::UnlockResponse { state: 99 })), + Reply::Proto(Response::Success(pb::Success {})), + Reply::Proto(Response::Error(pb::Error { + code: 104, + message: String::new(), + })), + Reply::Malformed, + Reply::ReadError, + ] { + let fixture = Fixture::new("9.28.0", vec![state(State::PassphrasePending), reply]); + let ui = Arc::new(Mutex::new(Ui::default())); + assert!(fixture.connect(manual(&ui, 0)).await.is_err()); + assert_eq!( + ui.lock().unwrap().events, + [Event::Available, Event::Withdrawn] + ); + assert!(ui.lock().unwrap().handles[0].signal.upgrade().is_none()); + assert_eq!( + fixture + .0 + .lock() + .unwrap() + .commands + .iter() + .filter(|&&cmd| cmd == 3) + .count(), + 2 + ); + } +} + +#[tokio::test] +async fn input_errors_do_not_submit_a_passphrase() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + ], + ); + let result = fixture + .connect(PassphraseConfig::new(|| async { + Err(Error::PassphraseInput("dialog closed".into())) + })) + .await; + assert!(matches!(result, Err(Error::PassphraseInput(_)))); + assert_eq!(fixture.requests(), [start(), poll(true)]); + assert_eq!(fixture.0.lock().unwrap().commands.last(), Some(&3)); +} + +#[tokio::test] +async fn dropping_unlock_withdraws_availability() { + let fixture = Fixture::new("9.28.0", vec![state(State::PassphrasePending)]); + let ui = Arc::new(Mutex::new(Ui::default())); + let locked = fixture.locked().await; + let mut wait = Box::pin(locked.unlock(manual(&ui, 0))); + assert!(poll_once(wait.as_mut()).await.is_pending()); + assert_eq!(ui.lock().unwrap().events, [Event::Available]); + drop(wait); + assert_eq!( + ui.lock().unwrap().events, + [Event::Available, Event::Withdrawn] + ); + assert!(ui.lock().unwrap().handles[0].signal.upgrade().is_none()); +} + +#[tokio::test] +async fn host_input_is_async_and_canceled_with_unlock() { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + ], + ); + let (tx, rx) = oneshot::channel::>(); + let rx = Mutex::new(Some(rx)); + let locked = fixture.locked().await; + let config = PassphraseConfig::new(move || { + let rx = rx.lock().unwrap().take().unwrap(); + async move { Ok(rx.await.unwrap()) } + }); + let mut wait = Box::pin(locked.unlock(config)); + assert!(poll_once(wait.as_mut()).await.is_pending()); + tokio::task::yield_now().await; + assert_eq!(fixture.requests(), [start(), poll(true)]); + assert!(!tx.is_canceled()); + drop(wait); + assert!(tx.is_canceled()); +} + +#[test] +fn a_request_from_another_thread_wakes_the_waiter() { + struct WaitingRuntime; + #[cfg_attr(feature = "multithreaded", async_trait)] + #[cfg_attr(not(feature = "multithreaded"), async_trait(?Send))] + impl Runtime for WaitingRuntime { + async fn sleep(_: Duration) { + futures_util::future::pending().await + } + } + let (tx, rx) = std::sync::mpsc::channel(); + let notify = move |handle| { + if let Some(handle) = handle { + tx.send(handle).unwrap(); + } + }; + let mut availability = Availability { + notify: Some(¬ify), + current: None, + }; + availability.offer(); + let handle: HostPassphraseRequest = rx.recv().unwrap(); + #[derive(Default)] + struct WakeCounter(std::sync::atomic::AtomicUsize); + impl std::task::Wake for WakeCounter { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + let counter = Arc::new(WakeCounter::default()); + let waker = std::task::Waker::from(counter.clone()); + let mut context = std::task::Context::from_waker(&waker); + let mut waiter = std::pin::pin!(availability.wait::()); + assert!(waiter.as_mut().poll(&mut context).is_pending()); + std::thread::spawn(move || handle.request()).join().unwrap(); + assert_eq!(counter.0.load(Ordering::Relaxed), 1); + assert_eq!(waiter.as_mut().poll(&mut context), Poll::Ready(true)); +} + +#[tokio::test] +async fn oversized_input_returns_an_error_without_panicking() { + for len in [8000, 65536] { + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + ], + ); + let result = fixture + .connect(PassphraseConfig::new(move || async move { + Ok(Some("x".repeat(len))) + })) + .await; + assert!(matches!( + result, + Err(Error::Communication(CommunicationError::U2fEncode)) + )); + assert_eq!(fixture.requests(), [start(), poll(true)]); + } +} + +#[cfg(not(feature = "multithreaded"))] +#[tokio::test] +async fn callbacks_can_capture_local_state() { + let called = std::rc::Rc::new(std::cell::Cell::new(false)); + let input_called = called.clone(); + let fixture = Fixture::new( + "9.28.0", + vec![ + state(State::PassphrasePending), + state(State::HostEntryReady), + state(State::Done), + ], + ); + fixture + .connect(PassphraseConfig::new(move || { + let called = input_called.clone(); + async move { + called.set(true); + Ok(Some(String::new())) + } + })) + .await + .unwrap(); + assert!(called.get()); +} + +#[cfg(feature = "multithreaded")] +#[tokio::test] +async fn configured_connection_future_is_send() { + fn assert_send(_: &T) {} + fn assert_send_sync(_: &T) {} + let config = PassphraseConfig::new(|| async { Ok(None) }); + assert_send_sync(&config); + let fixture = Fixture::new("9.28.0", vec![state(State::Done)]); + let pairing = fixture.pair().await; + assert_send_sync(&pairing); + let wait = pairing.wait_confirm(); + assert_send(&wait); + let PairedBitBox::Locked(device) = wait.await.unwrap() else { + panic!("expected a locked device"); + }; + assert_send_sync(&device); + let unlock = device.unlock(config); + assert_send(&unlock); + assert_send_sync(&unlock.await.unwrap()); +} diff --git a/tests/test_btc_psbt.rs b/tests/test_btc_psbt.rs index b121c95..ecf1a7c 100644 --- a/tests/test_btc_psbt.rs +++ b/tests/test_btc_psbt.rs @@ -521,7 +521,7 @@ fn assert_invalid_input_error(vector: &TestVector, error: &bitbox_api::error::Er } async fn register_script_configs( - bitbox: &bitbox_api::PairedBitBox, + bitbox: &bitbox_api::UnlockedBitBox, coin: pb::BtcCoin, vector: &TestVector, ) -> Result<(), bitbox_api::error::Error> { @@ -562,7 +562,7 @@ fn convert_format_unit(format_unit: FormatUnit) -> pb::btc_sign_init_request::Fo } async fn run_vector( - bitbox: &bitbox_api::PairedBitBox, + bitbox: &bitbox_api::UnlockedBitBox, stdout: &SimulatorStdout, vector: &TestVector, ) { diff --git a/tests/test_device.rs b/tests/test_device.rs index a37854b..ca91dc7 100644 --- a/tests/test_device.rs +++ b/tests/test_device.rs @@ -9,7 +9,7 @@ compile_error!("Enable the tokio feature to run simulator tests"); mod util; -use util::{test_initialized_simulators, test_simulators_after_pairing}; +use util::{test_initialized_simulators, test_uninitialized_simulators}; #[tokio::test] async fn test_reconnect() { @@ -52,8 +52,10 @@ async fn test_reconnect() { } util::test_simulators(async |_stdout| { - let bitbox = connect().await; - bitbox.restore_from_mnemonic().await.unwrap(); + let PairedBitBox::Uninitialized(bitbox) = connect().await else { + panic!("expected an uninitialized simulator"); + }; + let bitbox = bitbox.restore_from_mnemonic().await.unwrap(); if *bitbox.version() >= semver::Version::new(9, 28, 0) { // Missing previous transaction data stops the host while the firmware is still // waiting for the next signing request. @@ -117,9 +119,29 @@ async fn test_reconnect() { .wait_confirm() .await .unwrap(); - // The first reply must be a two-byte HWW ACK + unlock SUCCESS. An unfinished - // workflow would return its encrypted error, which unlock_and_pair currently ignores. + // The first reply must be a two-byte HWW ACK + SUCCESS: from the handshake on + // firmware v9.28.0+, or from legacy unlock on older firmware. An unfinished + // workflow would instead return its encrypted error. assert_eq!(&responses.lock().unwrap()[5..9], &[0, 2, 0, 0]); + let modern = match &bitbox { + PairedBitBox::Locked(device) => device.version(), + PairedBitBox::Unlocked(device) => device.version(), + PairedBitBox::Uninitialized(_) => panic!("restored wallet must stay initialized"), + } >= &semver::Version::new(9, 28, 0); + let bitbox = if modern { + let PairedBitBox::Locked(bitbox) = bitbox else { + panic!("modern firmware must require the explicit unlock step"); + }; + bitbox + .unlock(bitbox_api::PassphraseConfig::default()) + .await + .unwrap() + } else { + let PairedBitBox::Unlocked(bitbox) = bitbox else { + panic!("legacy firmware must already be unlocked"); + }; + bitbox + }; assert_eq!(bitbox.root_fingerprint().await.unwrap(), "4c00739d"); }) .await @@ -127,7 +149,7 @@ async fn test_reconnect() { #[tokio::test] async fn test_device_info() { - test_simulators_after_pairing(async |paired_bitbox| { + test_uninitialized_simulators(async |paired_bitbox| { let device_info = paired_bitbox.device_info().await.unwrap(); // Since v9.24.0, the simulator simulates a Nova device. diff --git a/tests/util/mod.rs b/tests/util/mod.rs index 0fb2549..1d2cf5f 100644 --- a/tests/util/mod.rs +++ b/tests/util/mod.rs @@ -564,9 +564,12 @@ pub async fn test_simulators(run: impl AsyncFn(&SimulatorStdout)) { } } -/// Tests on an initialized device, which is not yet seeded. -pub async fn test_simulators_after_pairing_with_stdout( - run: impl AsyncFn(&bitbox_api::PairedBitBox, &SimulatorStdout), +/// Tests on a paired, uninitialized device, which is not yet seeded. +pub async fn test_uninitialized_simulators_with_stdout( + run: impl AsyncFn( + bitbox_api::UninitializedBitBox, + &SimulatorStdout, + ), ) { test_simulators(async |stdout| { let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); @@ -577,17 +580,21 @@ pub async fn test_simulators_after_pairing_with_stdout( .await .unwrap(); let pairing_bitbox = bitbox.unlock_and_pair().await.unwrap(); - let paired_bitbox = pairing_bitbox.wait_confirm().await.unwrap(); - run(&paired_bitbox, stdout).await; + let bitbox_api::PairedBitBox::Uninitialized(device) = + pairing_bitbox.wait_confirm().await.unwrap() + else { + panic!("expected an uninitialized simulator"); + }; + run(device, stdout).await; }) .await } -/// Tests on an initialized device, which is not yet seeded. -pub async fn test_simulators_after_pairing( - run: impl AsyncFn(&bitbox_api::PairedBitBox), +/// Tests on a paired, uninitialized device, which is not yet seeded. +pub async fn test_uninitialized_simulators( + run: impl AsyncFn(bitbox_api::UninitializedBitBox), ) { - test_simulators_after_pairing_with_stdout(async |paired_bitbox, _stdout| { + test_uninitialized_simulators_with_stdout(async |paired_bitbox, _stdout| { run(paired_bitbox).await; }) .await @@ -597,18 +604,18 @@ pub async fn test_simulators_after_pairing( /// The simulator is initialized with the following mnemonic: /// boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide pub async fn test_initialized_simulators_with_stdout( - run: impl AsyncFn(&bitbox_api::PairedBitBox, &SimulatorStdout), + run: impl AsyncFn(&bitbox_api::UnlockedBitBox, &SimulatorStdout), ) { - test_simulators_after_pairing_with_stdout(async |paired_bitbox, stdout| { - assert!(paired_bitbox.restore_from_mnemonic().await.is_ok()); - run(paired_bitbox, stdout).await; + test_uninitialized_simulators_with_stdout(async |paired_bitbox, stdout| { + let unlocked = paired_bitbox.restore_from_mnemonic().await.unwrap(); + run(&unlocked, stdout).await; }) .await } /// Tests on an initialized/seeded device. pub async fn test_initialized_simulators( - run: impl AsyncFn(&bitbox_api::PairedBitBox), + run: impl AsyncFn(&bitbox_api::UnlockedBitBox), ) { test_initialized_simulators_with_stdout(async |paired_bitbox, _stdout| { run(paired_bitbox).await;