feat: Blockstream Jade hardware wallet support - #152
Closed
coreyphillips wants to merge 2 commits into
Closed
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.
Collaborator
Author
|
Superseded by #153. The branch was renamed from |
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:
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
Not yet run against a physical Jade. That is the main thing this draft is waiting on. Two constants need confirming on hardware:
MIN_JADE_FIRMWARE, and them/0'parent-fingerprint route used for the master fingerprint.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, 54 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; the two second inter-chunk deadline and the write-with-response requirement are the parts that fail silently if missed.