feat: Blockstream Jade hardware wallet support - #153
Draft
coreyphillips wants to merge 3 commits into
Draft
Conversation
Adds a `jade` vendor adapter covering Bitcoin single signature use: discovery, connect, PIN unlock through the blind pinserver, extended public key and account export, on-device address verification, message signing and PSBT signing. Transports are Bluetooth on every platform, through a native `JadeTransportCallback`, plus USB CDC serial on desktop and Python builds. There is no Rust crate for Jade, so the CBOR protocol is implemented here. Signed PSBTs feed the existing `finalize_psbt` path, the same route Passport already uses. Details worth calling out, each verified against Jade firmware: - Binary fields carry `#[serde(with = "serde_bytes")]`. serde encodes a plain `Vec<u8>` as a CBOR array, and Jade reads `psbt` and `entropy` with `rpc_get_bytes_ptr`, which requires a byte string. A test asserts the encoded header byte, because this fails only against hardware. - Replies with id "00" are treated as terminal errors for the request in flight. Jade uses that id when it rejects a message before recovering the real one, so discarding them would turn every such rejection into a full length timeout. - An HTTP failure during unlock still sends `pin` with no params. The device blocks indefinitely waiting for one, so abandoning the exchange would leave it consuming the next unrelated request as the awaited reply. - Framing reports malformed input rather than returning a truncated frame, caps the read buffer, and poisons the connection on any error, since there is no way to find the next boundary in a corrupt stream. - Session state is kept out of the I/O lock so `jade_cancel` and `jade_disconnect` return promptly while a five minute confirmation is pending. Jade has no cancel message, so closing the link is the only abort mechanism. - Path validation is stricter than `DerivationPath::from_str`, which accepts "" as the master path and accepts a path with no `m/` prefix. - Pinserver requests are constrained to https, port 443, no redirects, no onion hosts and a resolved public address, because the URL list comes from the device. - No `u8` or `u16` in the FFI surface, keeping this module clear of the narrow unsigned return path that needed a generator fix for ARM32. Tests run against a scripted mock device and a fake pinserver, so no hardware or network access is required.
The Jade protocol, pinserver exchange, PSBT checks and serial transport now live in https://github.com/coreyphillips/jade-client-rs. What stays here is the FFI adapter: the transport contract the native application implements, the session lock a free-function FFI surface implies, and UniFFI scaffolding for the crate's types. The module drops from roughly 4,400 lines to 969, and bitkit-core no longer depends on ciborium, serde_bytes or serialport directly. Types are declared with `#[uniffi::remote]` rather than mirrored. That generates the same scaffolding a derive would, against types defined in another crate, so there is no parallel set of structs and no hand-written From conversions in either direction. For comparison, the trezor module carries about 900 lines of exactly that against trezor-connect-rs. `#[uniffi::remote(Error)]` has to match every variant, which is why the crate's JadeError is deliberately not non_exhaustive. Two behavioural improvements come with the split: - Transport failures cross the boundary as a typed JadeTransportErrorCode rather than a sentinel string. The trezor adapter has to encode its code into text and parse it back out, because its upstream crate offers no typed channel; owning both sides here avoided that. - The crate's Jade takes &mut self per operation, so the one request at a time rule the firmware enforces is a compile time property. Aborting goes through a CancelHandle that works while an operation holds the borrow, and it stays outside the session lock so disconnect and status reads never queue behind a five minute confirmation. The FFI functions now take plain arguments instead of parameter records, matching the crate. This surface has not shipped, so nothing depends on the old shape. The dependency is pinned by git revision until the crate is published, so this never depends on an unreleased version. Protocol tests moved to the crate, where 54 of them run against a scripted mock device and a fake pinserver. The 6 left here cover the adapter: the account type mapping and the callback bridge, including chunk size clamping and typed error propagation.
Picks up two transport fixes found while running the crate against a physical Jade v1 on firmware 1.0.41, neither of which the scripted mock could reach. SerialTransport cleared DTR and RTS unconditionally on open and on close. That is right for /dev/tty*, where the kernel asserts them on open and the transition reboots the ESP32, and wrong for the macOS call-out node: on /dev/cu.usbserial-* the device answered 0 of 9 requests with the lines cleared and 9 of 9 with them asserted, and because close cleared them too, every run left the device unresponsive until it was physically power cycled. jadepy keys off the same path prefix. JadeConnection::exchange took a timeout and applied it only to the reply wait, leaving write_all unbounded. A transport whose write stalls therefore hung forever and the caller's timeout never fired. Serial could not show this, since its writes go through spawn_blocking to a port with its own timeout; a Bluetooth write with response can, and did. That matters directly for this adapter, because JadeTransportCallback writes are driven by the application. Also drops the macOS /dev/tty.* dial-in twin from enumeration, which listed one device twice and offered a path that blocks on open. Cargo.lock carries only the source rev: the crate's own dependency set is unchanged between the two revisions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Blockstream Jade as a third hardware wallet, alongside Trezor and Foundation Passport.
The Jade protocol lives in a new crate, jade-client-rs, and this PR carries the UniFFI adapter that exposes it to the apps. There is no existing Rust client for Jade, so the CBOR protocol was implemented from the firmware and
jadepysources.Description
Scope is Bitcoin single signature, matching what Bitkit does today. Liquid, multisig, firmware updates and the airgapped QR mode are out of scope.
FFI surface (18 functions): scan, connect, disconnect, cancel, ping, unlock, logout, version info, xpub, account export, master fingerprint, address verification, message signing, PSBT signing, plus the transport callback setter.
Jade returns a signed PSBT, so it follows the Passport route rather than the Trezor one:
Transports. Bluetooth on every platform, driven by the app through a
JadeTransportCallbackin the same shape asTrezorTransportCallback. USB CDC serial additionally on desktop and Python, driven from Rust.Why a separate crate. The protocol is useful outside Bitkit, iterating on it does not need a bitkit-core release, and it keeps
ciborium,serde_bytesandserialportout of this repo's direct dependencies. The split follows thetrezor-connect-rsprecedent, with two improvements worth noting for review:#[uniffi::remote]rather than mirrored structs. That generates the same scaffolding a derive would, against types defined in another crate, so there are no parallel definitions and no hand-writtenFromconversions in either direction. The trezor module carries roughly 900 lines of exactly that; the adapter here is 969 lines total.JadeTransportErrorCode. The trezor adapter has to encode its code into a sentinel string and parse it back out, because its upstream crate offers no typed channel.Things a reviewer should look at deliberately:
d55fafb. It moves to a published version once the crate is on crates.io; until then the merge should accept the git pin knowingly.HardwareWalletVendor::Blockstreamis a new enum case. That makes exhaustive Kotlinwhenand Swiftswitchover the vendor enum non-exhaustive, which is source breaking for consuming apps. Appended afterFoundation, since UniFFI assigns discriminants by declaration order.u8oru16in the FFI surface.pingreturns an enum andbattery_statusisu32, keeping this module off the narrow unsigned return path that 0.5.14 fixed for Android ARM32.Three protocol details produce code that compiles and then fails only against hardware. Each is verified against firmware source and covered by a test in the crate:
Vec<u8>as an array of integers, which the device rejects forpsbtandentropy."00"are terminal errors, not stray frames. Jade uses that id when it rejects a message before recovering the real one, so discarding them turns every such rejection into a full length timeout.pinwith no params, or the device stays blocked and consumes the next unrelated request as the awaited reply.Preview
Not applicable; this is FFI surface with no UI.
src/modules/jade/README.mddocuments the architecture, and the crate's README documents the wire protocol and the Bluetooth contract a native transport must honour.QA Notes
Run against a physical Jade v1, firmware 1.0.41, from macOS, over both transports. Everything below passed.
wpkhandtrwpkhget_extended_dataSignatures were checked rather than assumed: each PSBT signature verifies against its own p2wpkh sighash under the key the account xpub derives, and the message signature recovers to that same key. Every PSBT spent a fabricated prevout, so nothing was broadcastable.
The 20 input PSBT is the interesting one. Its 2921 byte request went out as six Bluetooth writes, five at the full 509 byte
MAX_CHUNK_BYTES, and the 5005 byte reply came back in two fragments reassembled throughget_extended_data. That path had no hardware coverage before.Of the two constants this draft was waiting to confirm:
m/0'parent-fingerprint route is confirmed. Not merely self-consistently: the device signed a PSBT whose BIP32 origins carried the fingerprint that route produced,54f40d7d. A wrong fingerprint means the device matches none of its own keys and signs nothing.MIN_JADE_FIRMWAREis still unconfirmed. Only 1.0.41 has been exercised.0.1.48remains a reasoned guess from the firmware sources, and the device stays the authority: it answersUNKNOWN_METHODfor anything it cannot do.Two things found and fixed in the crate while doing this, both in the transport layer, neither reachable by the scripted mock. The pin now sits at
d55fafb, which carries both:SerialTransportcleared DTR and RTS unconditionally. Correct for/dev/tty*, inverted for the macOS call-out node, where the device answered 0 of 9 requests with the lines cleared and 9 of 9 with them asserted.closecleared them too, so every run left the device unresponsive until it was physically power cycled.exchangeapplied its timeout only to the reply wait, leavingwrite_allunbounded, so a transport whose write stalls hung forever and the caller's timeout never fired. This one matters directly for this adapter, becauseJadeTransportCallbackwrites are driven by the application: a Bluetooth write with response that is never acknowledged used to hang a signing call with no timeout and no error.Untested and worth stating plainly: Jade Plus, Linux serial, and the
/dev/tty*half of the DTR and RTS rule, which rests on jadepy's behaviour rather than on measurement.A caveat on the QA script below: it uses
Testnet, and a device configuredJADE_NETWORKS: MAINrejects that withNetworkMismatch. UseMainnetpaths on a mainnet-only unit, or reconfigure the device.Automated:
473 pass, 0 fail. Blocktank is skipped because its tests reach
api.stag.blocktank.toand fail on any machine without access to it; that is pre-existing onmaster.Protocol level coverage lives in the crate (
cargo testthere, 57 tests against a scripted mock device and a fake pinserver, no hardware or network needed).Platform gating, which matters because
serialportmust never reach a mobile build:Hardware path once a device is available, over USB so no app is required:
then scan, connect,
jade_unlock(Testnet)entering the PIN on device, master fingerprint returns 8 hex chars,jade_get_xpub("m/84'/1'/0'")returns a tpub,jade_verify_addressmatches a host-derived address,jade_sign_messageverifies against the returned address, andonchain_compose_transactiontojade_sign_psbttofinalize_psbtyields a stable txid. Broadcast on regtest or testnet only.Bluetooth end to end needs an app-side
JadeTransportCallback. The contract is documented on the trait, and the crate now carries reference material for whoever writes the Swift and Kotlin sides:examples/callback_transport.rsis the same shape asJadeTransportCallback, with the platform owning the radio and Rust only moving bytes. It runs against a scripted link, socargo run --example callback_transportexercises the whole path with no hardware.docs/bluetooth.mdcovers the service UUIDs, chunk sizing per platform, and the rules that fail silently when missed.The write-with-response requirement and the two second inter-chunk deadline are still the parts that fail silently. Two more are worth passing to the app teams: notifications must be handed over untouched and in order, since frames are not aligned to notifications, and the link must be disconnected on the way out, because a Jade whose central vanished can refuse new connections until it is physically power cycled.