From abe3f67c3c966801c8fab57963c35f8fa07cc2b2 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 3 Sep 2026 13:11:15 -0400 Subject: [PATCH 1/3] feat(jade): add Blockstream Jade hardware wallet support 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` 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. --- AGENTS.md | 7 +- CHANGELOG.md | 3 + Cargo.lock | 104 ++ Cargo.toml | 12 +- src/lib.rs | 293 ++++++ src/modules/hardware_wallet/catalog.rs | 12 + src/modules/hardware_wallet/tests.rs | 15 +- src/modules/hardware_wallet/types.rs | 1 + src/modules/jade/README.md | 216 ++++ src/modules/jade/callbacks.rs | 148 +++ src/modules/jade/errors.rs | 165 +++ src/modules/jade/implementation.rs | 822 +++++++++++++++ src/modules/jade/mod.rs | 45 + src/modules/jade/path.rs | 100 ++ src/modules/jade/pinserver.rs | 471 +++++++++ src/modules/jade/protocol.rs | 222 +++++ src/modules/jade/serial.rs | 172 ++++ src/modules/jade/tests.rs | 1267 ++++++++++++++++++++++++ src/modules/jade/transport.rs | 382 +++++++ src/modules/jade/types.rs | 372 +++++++ src/modules/mod.rs | 1 + 21 files changed, 4825 insertions(+), 5 deletions(-) create mode 100644 src/modules/jade/README.md create mode 100644 src/modules/jade/callbacks.rs create mode 100644 src/modules/jade/errors.rs create mode 100644 src/modules/jade/implementation.rs create mode 100644 src/modules/jade/mod.rs create mode 100644 src/modules/jade/path.rs create mode 100644 src/modules/jade/pinserver.rs create mode 100644 src/modules/jade/protocol.rs create mode 100644 src/modules/jade/serial.rs create mode 100644 src/modules/jade/tests.rs create mode 100644 src/modules/jade/transport.rs create mode 100644 src/modules/jade/types.rs diff --git a/AGENTS.md b/AGENTS.md index 8af2db3..c182e83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Android bindings are built and published by `.github/workflows/gradle-publish.ym ```bash cargo test # All tests -cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, trezor, pubky) +cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky) ``` ## Lint & Format @@ -35,7 +35,7 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), ## Architecture - `src/lib.rs` — UniFFI exports and module re-exports -- `src/modules/` — Core modules: scanner, lnurl, onchain, activity, blocktank, trezor, pubky +- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky - `bindings/` — Platform-specific binding outputs (ios/, android/, python/) - `build.sh`, `build_ios.sh`, `build_android.sh`, `build_python.sh` — Build scripts @@ -43,7 +43,8 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), - **Version sync**: Version must match across `Cargo.toml`, `Package.swift`, and `bindings/android/gradle.properties`. Use `build.sh -r` to bump all three. - **UniFFI**: Public types exposed to bindings are declared in `src/lib.rs`. Follow existing UniFFI patterns when adding new types. -- **Platform-specific deps**: Trezor uses Bluetooth-only on iOS, USB+Bluetooth on other platforms (see `Cargo.toml` target-specific dependencies). +- **Platform-specific deps**: Trezor uses Bluetooth-only on iOS, USB+Bluetooth on other platforms (see `Cargo.toml` target-specific dependencies). Jade's serial transport is desktop-only; `serialport` must keep `default-features = false` or CI loses `libudev`. +- **No cfg-gated UniFFI exports**: bindings are generated from the host library, so a host-only `#[uniffi::export]` would appear in the Swift and Kotlin output while being absent from the device library. - **Android build**: `build_android.sh` temporarily modifies `Cargo.toml` crate-type and removes `example/main.rs` during build — don't run concurrent builds. - **Android bindings**: Keep `bindings/android/lib/src/main/jniLibs/` untracked. GitHub Actions generates the JNI libraries before publishing the Android package. diff --git a/CHANGELOG.md b/CHANGELOG.md index 183e6fe..70c7e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add Blockstream Jade hardware wallet support: device discovery, connect, PIN unlock via the blind pinserver, extended public key and account export, on-device address verification, message signing, and PSBT signing, over Bluetooth on every platform and USB CDC serial on desktop and Python. Signed PSBTs feed the existing `finalize_psbt` path. +- Add `HardwareWalletVendor.Blockstream` and catalog entries for Jade and Jade Plus. Note that adding an enum case makes exhaustive Kotlin `when` and Swift `switch` statements over `HardwareWalletVendor` non-exhaustive, which is source breaking for consumers. + ## 0.5.14 - 2026-09-02 - Prevent Android ARM32 startup crashes by generating `Int` carriers for direct unsigned 8-bit and 16-bit UniFFI returns while preserving Kotlin unsigned APIs. diff --git a/Cargo.lock b/Cargo.lock index 3896201..167d285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,6 +601,7 @@ dependencies = [ "boltz-client", "btleplug", "chrono", + "ciborium", "hex", "jni", "lazy-regex", @@ -622,8 +623,10 @@ dependencies = [ "rust-blocktank-client", "rust_decimal", "serde", + "serde_bytes", "serde_json", "serial_test", + "serialport", "tempfile", "test-case", "thiserror 2.0.18", @@ -967,6 +970,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1204,6 +1234,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -2016,6 +2052,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.2.1" @@ -2452,6 +2499,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2828,6 +2885,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mainline" version = "5.4.0" @@ -2948,6 +3014,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -4595,6 +4672,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serialport" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f4ac56b5d3af3c40fbbee17be96d532cba02fa5853926aacdb77d926272ab" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5342,6 +5437,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 4a24f98..183f7b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" uniffi = { version = "0.29.4", features = [ "cli", "bindgen" ] } serde_json = "1.0.114" serde = { version = "^1.0.209", features = ["derive"] } -tokio = { version = "1.40.0", features = ["rt", "rt-multi-thread", "macros"] } +tokio = { version = "1.40.0", features = ["rt", "rt-multi-thread", "macros", "time", "sync"] } bitcoin = "0.32.4" miniscript = "12.3.7" chrono = "0.4" @@ -40,6 +40,11 @@ bdk = { version = "0.30.2", features = ["all-keys"] } boltz-client = { version = "0.4.1", default-features = false, features = ["electrum", "ws"] } base64 = "0.22" minicbor = { version = "2", features = ["alloc"] } +# Jade speaks CBOR with string-keyed, dynamically shaped maps. ciborium is serde-backed; +# minicbor is index-keyed and is used here only for incremental frame detection. +ciborium = "0.2" +# Encodes Vec fields as CBOR byte strings rather than arrays of integers, which Jade requires. +serde_bytes = "0.11" ur = "0.5.2" log = "0.4" pubky = "0.6.0" @@ -62,6 +67,11 @@ trezor-connect-rs = { version = "0.4.0", default-features = false, features = [" jni = "0.19" android_logger = "0.14" +# Jade USB CDC serial, desktop and Python only. +# default-features = false drops the libudev C dependency that CI does not install. +[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] +serialport = { version = "4.10", default-features = false } + [dev-dependencies] tokio = { version = "1.40.0", features = ["full"] } serde_json = "1.0.114" diff --git a/src/lib.rs b/src/lib.rs index 0b4cfab..c1911ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,14 @@ pub use crate::modules::hardware_wallet::{ get_supported_hardware_wallets, HardwareWalletTransport, HardwareWalletVendor, SupportedHardwareWallet, }; +use crate::modules::jade::JadeManager; +pub use crate::modules::jade::{ + jade_set_transport_callback, JadeAccount, JadeAccountExport, JadeAddressVariant, + JadeDeviceInfo, JadeError, JadeGetXpubParams, JadeNativeDevice, JadeNetwork, JadePingStatus, + JadeSignMessageParams, JadeSignPsbtParams, JadeSignedMessage, JadeState, JadeTransportCallback, + JadeTransportErrorCode, JadeTransportKind, JadeTransportReadResult, JadeTransportResult, + JadeVerifyAddressParams, JadeVersionInfo, JadeXpubResponse, +}; use crate::modules::pubky::{PubkyAuthDetails, PubkyAuthKind, PubkyError, PubkyProfile}; use crate::modules::trezor::account_type_to_script_type; pub use crate::modules::trezor::{ @@ -104,6 +112,7 @@ static DB: OnceCell> = OnceCell::new(); static ASYNC_DB: OnceCell> = OnceCell::new(); static RUNTIME: OnceCell = OnceCell::new(); static TREZOR_MANAGER: OnceCell = OnceCell::new(); +static JADE_MANAGER: OnceCell = OnceCell::new(); fn ensure_runtime() -> &'static Runtime { RUNTIME.get_or_init(|| Runtime::new().expect("Failed to create Tokio runtime")) @@ -2598,8 +2607,292 @@ pub async fn trezor_clear_credentials(device_id: String) -> Result<(), TrezorErr }) } +// ============================================================================ +// Jade Hardware Wallet Functions +// ============================================================================ + +fn get_jade_manager() -> &'static JadeManager { + JADE_MANAGER.get_or_init(JadeManager::new) +} + +/// Discover Jade devices. +/// +/// Bluetooth discovery is performed by the registered transport callback; on +/// desktop and Python builds, attached USB serial units are enumerated too. +/// Returns `DeviceBusy` while a connection is open, because starting a +/// Bluetooth scan during an active link drops it on Android. +#[uniffi::export] +pub async fn jade_scan(timeout_ms: u32) -> Result, JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().scan(timeout_ms).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// The devices found by the last scan, without starting a new one. +#[uniffi::export] +pub async fn jade_list_devices() -> Vec { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().list_devices().await }) + .await + .unwrap_or_default() +} + +/// Open a device and read its firmware and state summary. +/// +/// Any previously open connection is closed first. The returned `jade_state` +/// tells the application what to do next: `Locked` means call `jade_unlock`, +/// `Ready` means the device is already usable, and `Uninit` means the user must +/// create or restore a wallet on the device itself. +#[uniffi::export] +pub async fn jade_connect(device_id: String) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().connect(&device_id).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Close the device and clear session state. +/// +/// Safe to call while an operation is waiting on a confirmation: the pending +/// request returns `UserCancelled` promptly rather than running out its deadline. +#[uniffi::export] +pub async fn jade_disconnect() -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().disconnect().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Abort the operation in flight. +/// +/// Jade has no cancel message, so this closes the link. The application should +/// reconnect afterwards. This is what backs a cancel button on a signing screen. +#[uniffi::export] +pub async fn jade_cancel() -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().cancel().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Tell the library that the native layer saw the device disconnect. +/// +/// Without this, an idle Bluetooth drop is invisible until the next request. +#[uniffi::export] +pub async fn jade_notify_disconnected(path: String) { + let rt = ensure_runtime(); + let _ = rt + .spawn(async move { get_jade_manager().notify_disconnected(&path).await }) + .await; +} + +#[uniffi::export] +pub fn jade_is_connected() -> bool { + get_jade_manager().is_connected() +} + +#[uniffi::export] +pub async fn jade_get_connected_device() -> Option { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().connected_device().await }) + .await + .unwrap_or(None) +} + +/// The version summary read at connect, without touching the device. +#[uniffi::export] +pub async fn jade_get_version_info() -> Option { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().version_info().await }) + .await + .unwrap_or(None) +} + +/// Re-read the version summary from the device. +#[uniffi::export] +pub async fn jade_refresh_version_info() -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().refresh_version_info().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Check whether the device is idle, busy, or waiting on the user. +#[uniffi::export] +pub async fn jade_ping() -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().ping().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Unlock the device for a network. +/// +/// Runs the blind pinserver exchange when the device asks for it, which needs +/// network access. The PIN is entered on the device and never reaches the host. +#[uniffi::export] +pub async fn jade_unlock(network: JadeNetwork) -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().unlock(network).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Lock the device and zero its in-memory key material. +#[uniffi::export] +pub async fn jade_logout() -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().logout().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Fetch an extended public key, echoed back with the path and fingerprint. +#[uniffi::export] +pub async fn jade_get_xpub(params: JadeGetXpubParams) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().get_xpub(params).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// The device's master fingerprint, eight lowercase hex characters. +/// +/// This must be supplied as `WalletParams.fingerprint` when composing, or the +/// resulting PSBT carries no BIP32 key origins and the device signs nothing. +#[uniffi::export] +pub async fn jade_get_master_fingerprint(network: JadeNetwork) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().master_fingerprint(network).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Fetch the account keys an import needs in one call. +/// +/// Shaped like `passport_parse_account_export` so applications have a single +/// import path across signers. Each key is fetched under one held connection, +/// which matters over Bluetooth where every round trip is slow. +#[uniffi::export] +pub async fn jade_get_account_export( + network: JadeNetwork, + account_index: u32, + account_types: Vec, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + get_jade_manager() + .account_export(network, account_index, account_types) + .await + }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Display an address on the device and check it against the expected one. +/// +/// This always prompts on the device screen, so it is a verification step +/// rather than a way to fetch an address. Returns `AddressMismatch` when the +/// device disagrees with `expected_address`. +#[uniffi::export] +pub async fn jade_verify_address(params: JadeVerifyAddressParams) -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().verify_address(params).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Sign a message, returning the signature with the address that verifies it. +#[uniffi::export] +pub async fn jade_sign_message( + params: JadeSignMessageParams, + network: JadeNetwork, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().sign_message(params, network).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Sign a PSBT, returning the signed PSBT base64 encoded. +/// +/// The reply is checked against what was sent before it is returned. Feed the +/// result to `finalize_psbt` with the original PSBT, then broadcast with +/// `onchain_broadcast_raw_tx`. +#[uniffi::export] +pub async fn jade_sign_psbt(params: JadeSignPsbtParams) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().sign_psbt(params).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Map a generic account type onto Jade's descriptor variant. +#[uniffi::export] +pub fn jade_account_type_to_variant(account_type: AccountType) -> JadeAddressVariant { + JadeAddressVariant::from(account_type) +} + // ============================================================================ // Account info FFI exports + // ============================================================================ /// Query account information for an extended public key via Electrum. diff --git a/src/modules/hardware_wallet/catalog.rs b/src/modules/hardware_wallet/catalog.rs index 6180ff1..a3ef820 100644 --- a/src/modules/hardware_wallet/catalog.rs +++ b/src/modules/hardware_wallet/catalog.rs @@ -13,6 +13,14 @@ pub fn get_supported_hardware_wallets() -> Vec { transports, }; + let jade = |model: &str, display_name: &str| SupportedHardwareWallet { + vendor: HardwareWalletVendor::Blockstream, + vendor_name: "Blockstream".to_string(), + model: model.to_string(), + display_name: display_name.to_string(), + transports: vec![Usb, Bluetooth], + }; + vec![ trezor("Model One", vec![Usb]), trezor("Model T", vec![Usb]), @@ -26,5 +34,9 @@ pub fn get_supported_hardware_wallets() -> Vec { display_name: "Foundation Passport".to_string(), transports: vec![Qr], }, + // Jade's USB link is CDC serial rather than HID, reported here as Usb + // because that is what a user plugs in. + jade("Jade", "Blockstream Jade"), + jade("Jade Plus", "Blockstream Jade Plus"), ] } diff --git a/src/modules/hardware_wallet/tests.rs b/src/modules/hardware_wallet/tests.rs index d13ca08..ace3d9c 100644 --- a/src/modules/hardware_wallet/tests.rs +++ b/src/modules/hardware_wallet/tests.rs @@ -4,7 +4,7 @@ use super::{get_supported_hardware_wallets, HardwareWalletTransport, HardwareWal fn catalog_lists_supported_models_and_transports() { let wallets = get_supported_hardware_wallets(); - assert_eq!(wallets.len(), 6); + assert_eq!(wallets.len(), 8); assert!(wallets .iter() .filter(|wallet| wallet.vendor == HardwareWalletVendor::Trezor) @@ -24,4 +24,17 @@ fn catalog_lists_supported_models_and_transports() { .unwrap(); assert_eq!(passport.vendor, HardwareWalletVendor::Foundation); assert_eq!(passport.transports, [HardwareWalletTransport::Qr]); + + let jades: Vec<_> = wallets + .iter() + .filter(|wallet| wallet.vendor == HardwareWalletVendor::Blockstream) + .collect(); + assert_eq!(jades.len(), 2); + assert!(jades.iter().all(|wallet| { + wallet.transports.contains(&HardwareWalletTransport::Usb) + && wallet + .transports + .contains(&HardwareWalletTransport::Bluetooth) + })); + assert!(jades.iter().any(|wallet| wallet.model == "Jade Plus")); } diff --git a/src/modules/hardware_wallet/types.rs b/src/modules/hardware_wallet/types.rs index f8ce601..325e69e 100644 --- a/src/modules/hardware_wallet/types.rs +++ b/src/modules/hardware_wallet/types.rs @@ -3,6 +3,7 @@ pub enum HardwareWalletVendor { Trezor, Foundation, + Blockstream, } /// How an application exchanges data with a hardware wallet. diff --git a/src/modules/jade/README.md b/src/modules/jade/README.md new file mode 100644 index 0000000..7e28fe6 --- /dev/null +++ b/src/modules/jade/README.md @@ -0,0 +1,216 @@ +# Jade Module - Technical Overview + +Blockstream Jade support for bitkit-core, over Bluetooth (all platforms) and USB +CDC serial (desktop and Python). Bitcoin single signature only. + +Unlike the `trezor` module, which adapts the external `trezor-connect-rs` crate, +there is no Rust crate for Jade, so the protocol is implemented here. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ bitkit-android / bitkit-ios │ +│ JadeTransport.kt / JadeTransport.swift │ +│ implements JadeTransportCallback: BLE, and USB host on Android │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ UniFFI +┌───────────────────────────────▼──────────────────────────────────────┐ +│ bitkit-core │ +│ lib.rs jade_* exports over a global JadeManager │ +│ implementation.rs session state, one connection, abort handling │ +│ pinserver.rs auth_user -> http_request -> pin, over reqwest │ +│ transport.rs JadeConnection: framing, correlation, reassembly │ +│ protocol.rs pure CBOR framing and envelopes │ +│ serial.rs Rust serial transport (desktop and Python only) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## Wire protocol + +CBOR maps written back to back with no length prefix and no framing bytes. +Requests are `{"id", "method", "params"}`; replies are `{"id", "result"}` or +`{"id", "error": {"code", "message"}}`. The device also emits unsolicited +`{"log": ...}` frames with no `id`, which are skipped. + +Because CBOR is self delimiting, the reader buffers bytes and attempts an +incremental decode after each read. `protocol::try_take_frame` uses +`minicbor::Decoder::skip()` for that, because it reports an exact consumed byte +count; `ciborium` then deserializes the complete frame. + +Three rules that are easy to get wrong: + +- **Binary fields must be CBOR byte strings.** serde encodes a plain `Vec` as + an array of integers, and Jade reads `psbt` and `entropy` with + `rpc_get_bytes_ptr`, which requires major type 2. Every binary field carries + `#[serde(with = "serde_bytes")]`. A test asserts the encoded header byte. +- **Absent params are omitted, not encoded as null.** Jade's typed getters treat + a null as missing and then fail with `BAD_PARAMETERS`. +- **Replies with id `"00"` are terminal errors, not stray frames.** Jade uses that + id when it rejects a message before recovering the real one, for example an + oversize or malformed request. Discarding them would turn every such rejection + into a full length timeout. + +## Native transport contract + +Jade advertises the Nordic UART Service: + +| Role | UUID | +|---|---| +| Service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | +| Write (host to Jade) | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | +| Notify (Jade to host) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | + +Devices advertise as `Jade` or `Jade `. + +Requirements on the native implementation: + +1. **Write with response.** Write-without-response silently drops chunks on the + ESP32 GATT stack. +2. **Do not pause between chunks of one request.** Firmware discards a partially + received message after two seconds of silence, three on Jade v1, and answers + with an unattributed error. A 30 KB PSBT is roughly 60 writes, so a UI thread + stall mid send breaks signing. +3. **`get_chunk_size` returns `min(negotiated_mtu - 3, 509)`.** Rust clamps the + answer to `1..=509`, so an unnegotiated `0` is not fatal. +4. **`read_chunk` returns promptly**, honouring the short `timeout_ms` it is + given. Returning success with an empty vector means "nothing yet" and is the + normal state while the user is deciding. The long per-operation deadline is + enforced in Rust so the user can cancel. + +Every callback invocation runs on the tokio blocking pool, so a slow +implementation costs a blocking thread rather than a runtime worker. + +## Serial + +115200 baud. Ports are matched on the USB descriptors Jade and its DIY bridge +chips present: + +| VID:PID | Chip | +|---|---| +| `10c4:ea60` | Silicon Labs CP210x, Jade v1 | +| `1a86:55d4` | WCH CH9102 | +| `0403:6001` | FTDI FT232 | +| `1a86:7523` | WCH CH340 | +| `303a:4001` | Espressif native USB, Jade Plus | +| `303a:1001` | Espressif USB serial/JTAG | + +DTR and RTS are cleared on open and close; leaving either asserted resets the +ESP32 on several of these bridges. + +`serialport` is declared with `default-features = false` because its default +`libudev` feature links a C library that CI does not install, and +`build_android.sh` performs a host build. + +## Connection flow + +1. `jade_scan` collects devices from the transport callback and, on desktop, from + serial enumeration. It returns `DeviceBusy` while a connection is open, + because starting a Bluetooth scan during an active link drops it on Android. +2. `jade_connect` closes anything already open, opens the transport, reads + `get_version_info`, and contributes 32 bytes of host entropy via `add_entropy`. +3. The returned `jade_state` decides what happens next: `Locked` means call + `jade_unlock`, `Ready` means the device is usable, `Uninit` means the user has + to create or restore a wallet on the device itself, which the host cannot + drive. +4. `jade_unlock` sends `auth_user`. If the device answers with an `http_request`, + the host performs it and feeds the reply back as the `pin` method's params. + +## Unlock and the pinserver + +Jade's PIN protection is backed by a blind pinserver. The exchange is end to end +encrypted between device and server, so the host never learns the PIN; it only +carries bytes. Two details matter: + +- The HTTP response is JSON and must be decoded into a CBOR **map**. Firmware + requires `params` to be a map with a text `data` member, so forwarding raw + bytes fails every unlock. +- An HTTP failure must still send a `pin` message, 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, putting every later + call one message out of step. + +Because the URL list comes from the device, requests are constrained to https, +port 443, no credentials, no redirects, no onion hosts, a resolved address that +is not loopback, private, link local, CGNAT or unique local, and a 64 KiB body +cap. A non-default pinserver host is logged as a warning: a second hand or +tampered unit can carry a pinserver a previous owner configured. + +## Signing + +Jade returns a **signed PSBT**, so it follows the Passport path rather than the +Trezor one: + +``` +onchain_compose_transaction -> psbt (base64) +jade_sign_psbt -> signed psbt (base64) +finalize_psbt(original, signed) -> CompletedTransaction +onchain_broadcast_raw_tx +``` + +`jade_sign_psbt` checks the reply against what was sent before returning, so the +guarantee holds here even for a caller that does not go on to use +`finalize_psbt`: same unsigned transaction, same input and output counts, +unchanged previous output metadata, and at least one new signature. + +Before the round trip it also rejects a PSBT larger than the device's input +buffer, an unsupported sighash type, and a PSBT whose BIP32 origins carry no +input for the connected device's master fingerprint. That last one is the most +likely integration failure: `WalletParams.fingerprint` must be set to the value +from `jade_get_master_fingerprint`, or `compose_transaction` produces a PSBT with +no key origins and the device signs nothing. + +## Addresses + +`jade_verify_address` takes the address the application is about to display and +asks the device to show its own derivation for the same path, failing with +`AddressMismatch` if they disagree. Jade always prompts on screen for this call, +so it is a verification step rather than a way to fetch an address. It catches +corruption and firmware bugs; a wholly malicious device is still caught by the +user reading the device screen. + +## Cancellation + +Jade has no cancel message, so `jade_cancel` and `jade_disconnect` close the +link. Both set an abort flag and close the transport **without** taking the I/O +lock, so a request blocked on a five minute confirmation returns +`UserCancelled` promptly instead of running out its deadline. UniFFI async +exports are detached onto the runtime, so a cancelled Swift or Kotlin task does +not cancel the Rust future by itself; this is the mechanism that does. + +## Common issues + +| Symptom | Cause | +|---|---| +| Every `sign_psbt` fails with `DeviceError` | Binary field encoded as a CBOR array rather than a byte string | +| Signing fails partway through a large PSBT | A pause longer than two seconds between chunks, or write-without-response | +| `FingerprintMismatch` | `WalletParams.fingerprint` was not set when composing | +| `UnsupportedFirmware` on a taproot address | Taproot addresses need firmware 1.0.34 or newer | +| `NetworkMismatch` | The device was unlocked for a different network | +| Unlock hangs, later calls report protocol errors | The `pin` follow-up was skipped after an HTTP failure | +| `DeviceUninitialized` | The wallet must be created or restored on the device itself | + +## Constraints + +- No `#[uniffi::export]` item in this module may be `cfg` gated. All three build + scripts generate bindings from the **host** library, so a host only export + would appear in the generated Swift and Kotlin while being absent from the + device library: a link failure on iOS and a checksum mismatch on Android. +- No `u8` or `u16` in the FFI surface. `ping` returns `JadePingStatus` and + `battery_status` is `u32`, keeping this module clear of the unsigned narrow + return path that needed a binding generator fix for Android ARM32. +- Registering a transport callback twice replaces the first. This is deliberate, + so an Android activity restart can re-register; the replacement is logged. + +## Testing + +```bash +cargo test modules::jade +``` + +Everything runs against a scripted mock device and a fake pinserver, so no +hardware or network access is needed. Covered: byte string encoding, frame +reassembly across reads, two frames in one read, log frame skipping, stale and +unattributed replies, error code mapping, multi fragment `sign_psbt` reassembly, +cancellation, connection poisoning, path validation, and the unlock exchange +including the HTTP failure path. diff --git a/src/modules/jade/callbacks.rs b/src/modules/jade/callbacks.rs new file mode 100644 index 0000000..0504efb --- /dev/null +++ b/src/modules/jade/callbacks.rs @@ -0,0 +1,148 @@ +//! The transport contract the native application implements. +//! +//! Rust owns the Jade protocol; the application owns the bytes. On iOS that +//! means CoreBluetooth against the Nordic UART Service, and on Android the +//! Bluetooth API plus, optionally, the USB Host API for CDC serial. Desktop and +//! Python builds can skip this entirely and use the Rust serial transport. +//! +//! Methods are synchronous. UniFFI can express async foreign callbacks, but the +//! trezor module established the synchronous shape here and the transport layer +//! runs every one of these on the blocking pool, so there is nothing to gain by +//! diverging. + +use std::sync::{Arc, RwLock}; + +use super::types::JadeTransportKind; + +/// A failure the native layer can report in a way Rust can act on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum JadeTransportErrorCode { + /// Another operation holds the device. + DeviceBusy, + /// The device is not currently open. + NotConnected, + /// The link dropped. + Disconnected, + /// The operation exceeded its deadline. + Timeout, + /// The OS refused access, typically a missing Bluetooth or USB permission. + PermissionDenied, +} + +/// A device the native layer discovered. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeNativeDevice { + /// Transport specific address: a BLE identifier or a serial device path. + pub path: String, + pub transport: JadeTransportKind, + /// Advertised or descriptor name, for example "Jade C0FFEE". + pub name: Option, + pub serial_number: Option, +} + +/// Outcome of an operation that returns no data. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeTransportResult { + pub success: bool, + /// Empty on success. + pub error: String, + pub error_code: Option, +} + +/// Outcome of a read. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeTransportReadResult { + pub success: bool, + /// Bytes read. Success with an empty vector means nothing has arrived yet, + /// which is the normal case while the user is deciding on the device. + pub data: Vec, + /// Empty on success. + pub error: String, + pub error_code: Option, +} + +/// Native transport for Jade. +/// +/// # Bluetooth contract +/// +/// Jade advertises the Nordic UART Service: +/// +/// - service `6e400001-b5a3-f393-e0a9-e50e24dcca9e` +/// - write `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (host to Jade) +/// - notify `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Jade to host) +/// +/// Devices advertise as "Jade" or "Jade ". +/// +/// Three requirements that are easy to miss and break signing on real hardware: +/// +/// 1. **Write with response.** Write-without-response silently drops chunks on +/// the ESP32 GATT stack. +/// 2. **Do not pause between chunks.** Firmware discards a partially received +/// message after two seconds of silence (three on Jade v1) and answers with +/// an unattributed error. A 30 KB PSBT is roughly 60 writes, so any UI thread +/// stall in the middle of a send breaks the operation. +/// 3. **`read_chunk` must return promptly.** Honour `timeout_ms`, which this +/// crate keeps short. The long per-operation deadline is enforced in Rust so +/// the user can cancel; blocking here for minutes would defeat that. +#[uniffi::export(with_foreign)] +pub trait JadeTransportCallback: Send + Sync { + /// Discover devices, blocking up to `timeout_ms`. + fn scan_devices(&self, timeout_ms: u32) -> Vec; + + /// Open a connection and enable notifications. + fn open_device(&self, path: String) -> JadeTransportResult; + + /// Close the connection and release the device. + fn close_device(&self, path: String) -> JadeTransportResult; + + /// Write one chunk, no larger than `get_chunk_size`. + fn write_chunk(&self, path: String, data: Vec) -> JadeTransportResult; + + /// Read whatever has arrived, waiting at most `timeout_ms`. + /// + /// Returning success with an empty vector is normal and means "nothing yet". + fn read_chunk(&self, path: String, timeout_ms: u32) -> JadeTransportReadResult; + + /// Maximum bytes per write. + /// + /// For Bluetooth this is `min(negotiated_mtu - 3, 509)`. Rust clamps the + /// answer into a usable range, so an unnegotiated `0` is not fatal. + fn get_chunk_size(&self, path: String) -> u32; +} + +/// The registered callback. +/// +/// A read-write cell rather than a write-once cell on purpose. An Android +/// activity restart rebuilds the Bluetooth manager and registers a fresh +/// implementation; silently keeping the first one would leave this crate calling +/// into a dead context with no recovery short of killing the process. +static TRANSPORT_CALLBACK: RwLock>> = RwLock::new(None); + +/// Register the native transport. +/// +/// Returns `true` when this replaced a previously registered callback, which +/// lets the application tell a fresh registration from a re-registration. +/// Any live connection is invalidated by the caller before this takes effect. +#[uniffi::export] +pub fn jade_set_transport_callback(callback: Arc) -> bool { + #[cfg(target_os = "android")] + crate::init_android_logger(); + + let mut guard = TRANSPORT_CALLBACK + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let replaced = guard.is_some(); + if replaced { + log::warn!("[jade] transport callback replaced"); + } + *guard = Some(callback); + replaced +} + +/// Fetch the registered transport, if any. +pub(crate) fn transport_callback() -> Option> { + TRANSPORT_CALLBACK + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} diff --git a/src/modules/jade/errors.rs b/src/modules/jade/errors.rs new file mode 100644 index 0000000..3d89e31 --- /dev/null +++ b/src/modules/jade/errors.rs @@ -0,0 +1,165 @@ +//! Error types for the Jade module. + +use thiserror::Error; + +/// Error codes defined by Jade firmware in `main/utils/cbor_rpc.h`. +/// +/// The standard JSON-RPC codes occupy -32600 to -32603; Jade's own codes occupy +/// -32000 to -32099. +pub(crate) mod rpc_code { + pub const INVALID_REQUEST: i64 = -32600; + pub const UNKNOWN_METHOD: i64 = -32601; + pub const BAD_PARAMETERS: i64 = -32602; + pub const INTERNAL_ERROR: i64 = -32603; + pub const USER_CANCELLED: i64 = -32000; + pub const PROTOCOL_ERROR: i64 = -32001; + pub const HW_LOCKED: i64 = -32002; + pub const NETWORK_MISMATCH: i64 = -32003; +} + +/// Jade-related errors exposed via FFI. +#[derive(uniffi::Error, Debug, Error, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum JadeError { + /// Transport layer error (Bluetooth or serial communication). + #[error("Transport error: {error_details}")] + TransportError { error_details: String }, + + /// No Jade device matched the requested identifier. + #[error("No Jade device found")] + DeviceNotFound, + + /// The device went away during an operation. + #[error("Device disconnected during operation")] + DeviceDisconnected, + + /// Another operation holds the device; back off and retry. + #[error("Device is busy")] + DeviceBusy, + + /// No connection is open. Call `jade_connect` first. + #[error("Not connected to a Jade device")] + NotConnected, + + /// No transport callback has been registered. + #[error("Jade transport callback has not been set")] + NotInitialized, + + /// Failed to open or establish a connection. + #[error("Connection error: {error_details}")] + ConnectionError { error_details: String }, + + /// The device sent something that does not conform to the wire protocol. + #[error("Protocol error: {error_details}")] + ProtocolError { error_details: String }, + + /// The operation exceeded its deadline. + #[error("Operation timed out")] + Timeout, + + /// The user declined on the device, or the host aborted the operation. + #[error("Operation cancelled")] + UserCancelled, + + /// The device has a PIN set and is locked. Call `jade_unlock`. + #[error("Device is locked")] + DeviceLocked, + + /// The device has no wallet. Setup must be completed on the device itself. + #[error("Device has no wallet configured")] + DeviceUninitialized, + + /// The PIN entered on the device was rejected by the pinserver. + #[error("Incorrect PIN")] + InvalidPin, + + /// The requested network does not match what the device is configured for. + #[error("Network mismatch: {error_details}")] + NetworkMismatch { error_details: String }, + + /// The device firmware predates a feature this module requires. + #[error("Jade firmware {installed} is too old, {required} or newer is required")] + UnsupportedFirmware { installed: String, required: String }, + + /// A BIP32 derivation path was malformed or not permitted here. + #[error("Invalid derivation path: {error_details}")] + InvalidPath { error_details: String }, + + /// A PSBT failed to parse, or the device returned one that does not match. + #[error("Invalid PSBT: {error_details}")] + InvalidPsbt { error_details: String }, + + /// The PSBT exceeds what the device can receive in one message. + #[error("PSBT is {size} bytes, which exceeds the {max} byte limit")] + PsbtTooLarge { size: u64, max: u64 }, + + /// No PSBT input carries the connected device's master fingerprint, so the + /// device would sign nothing. + #[error("PSBT is for master fingerprint {psbt}, but the connected device is {device}")] + FingerprintMismatch { device: String, psbt: String }, + + /// The device returned a PSBT with no new signatures. + #[error("Device did not add any signatures")] + NothingSigned, + + /// The device returned an address that does not match the host-derived one. + #[error("Address mismatch: expected {expected}, device returned {returned}")] + AddressMismatch { expected: String, returned: String }, + + /// The blind pinserver exchange failed. + #[error("Pin server error: {error_details}")] + PinServerError { error_details: String }, + + /// The device reported an error that has no more specific mapping. + #[error("Device error: {error_details}")] + DeviceError { error_details: String }, + + /// An internal or runtime failure on the host side. + #[error("IO error: {error_details}")] + IoError { error_details: String }, +} + +impl JadeError { + /// Build a `ProtocolError` from anything displayable. + pub(crate) fn protocol(details: impl std::fmt::Display) -> Self { + JadeError::ProtocolError { + error_details: details.to_string(), + } + } + + /// Build a `TransportError` from anything displayable. + pub(crate) fn transport(details: impl std::fmt::Display) -> Self { + JadeError::TransportError { + error_details: details.to_string(), + } + } + + /// Map an error reply from the device onto a typed error. + /// + /// `UNKNOWN_METHOD` maps to `UnsupportedFirmware` rather than `ProtocolError`: + /// the single-signature `get_receive_address` and `sign_psbt` calls this module + /// relies on were added in later firmware, so an older unit answering -32601 is + /// reporting its age, not a host bug. + pub(crate) fn from_rpc(code: i64, message: String, min_firmware: &str) -> Self { + match code { + rpc_code::USER_CANCELLED => JadeError::UserCancelled, + rpc_code::HW_LOCKED => JadeError::DeviceLocked, + rpc_code::NETWORK_MISMATCH => JadeError::NetworkMismatch { + error_details: message, + }, + rpc_code::UNKNOWN_METHOD => JadeError::UnsupportedFirmware { + installed: "unknown".to_string(), + required: min_firmware.to_string(), + }, + rpc_code::PROTOCOL_ERROR | rpc_code::INVALID_REQUEST => JadeError::ProtocolError { + error_details: message, + }, + rpc_code::BAD_PARAMETERS | rpc_code::INTERNAL_ERROR => JadeError::DeviceError { + error_details: message, + }, + other => JadeError::DeviceError { + error_details: format!("device error {other}: {message}"), + }, + } + } +} diff --git a/src/modules/jade/implementation.rs b/src/modules/jade/implementation.rs new file mode 100644 index 0000000..38ff95b --- /dev/null +++ b/src/modules/jade/implementation.rs @@ -0,0 +1,822 @@ +//! Session state and the operations exposed over FFI. +//! +//! State is deliberately split from the I/O lock. A single mutex guarding every +//! operation would make `jade_disconnect` and every status read queue behind a +//! five minute `sign_psbt`, and UniFFI async exports are detached onto the +//! runtime, so a cancelled Swift or Kotlin task does not cancel the Rust future +//! either. The abort path therefore never waits on the I/O lock. + +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use bitcoin::bip32::{DerivationPath, Xpub}; +use bitcoin::psbt::Psbt; +use bitcoin::secp256k1::Secp256k1; +use rand::RngCore; +use serde::Serialize; +use tokio::sync::{Mutex, RwLock}; +use zeroize::Zeroizing; + +use super::callbacks::{transport_callback, JadeNativeDevice}; +use super::errors::JadeError; +use super::path; +use super::pinserver::{self, PinServerHttp, ReqwestPinServer}; +use super::protocol::{result_bool, result_text}; +use super::transport::{CallbackTransport, JadeConnection, JadeTransport}; +use super::types::*; +use crate::onchain::AccountType; + +/// Timeout for calls the device answers on its own. +const QUICK_TIMEOUT: Duration = Duration::from_secs(60); + +/// Timeout for calls that wait on a physical button press. +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(300); + +/// Largest PSBT this module will send. +/// +/// Jade's input buffer is 17 KiB without SPIRAM. Composed PSBTs carry full +/// previous transactions, so this is worth checking before a long transfer that +/// the device would reject at the end. +const MAX_PSBT_BYTES: u64 = 16 * 1024; + +/// What is known about the open session. +#[derive(Debug, Clone)] +struct SessionState { + device: JadeDeviceInfo, + version: JadeVersionInfo, + /// The network `auth_user` unlocked, once it has succeeded. Later calls are + /// checked against it so a mismatch is reported here rather than surfacing + /// as an opaque device error. + unlocked_network: Option, +} + +pub struct JadeManager { + device_list: Mutex>, + state: RwLock>, + /// Held for exactly one round trip. + io: Mutex>, + /// Cloned out by the abort path, which must not wait on `io`. + transport: RwLock>>, + aborted: Arc, + /// Cheap status reads that never touch a lock held across I/O. + connected: AtomicBool, + pinserver: Arc, +} + +impl Default for JadeManager { + fn default() -> Self { + Self::new() + } +} + +impl JadeManager { + pub fn new() -> Self { + Self::with_pinserver(Arc::new(ReqwestPinServer)) + } + + /// Build a manager with a specific pinserver implementation. + pub(crate) fn with_pinserver(pinserver: Arc) -> Self { + Self { + device_list: Mutex::new(Vec::new()), + state: RwLock::new(None), + io: Mutex::new(None), + transport: RwLock::new(None), + aborted: Arc::new(AtomicBool::new(false)), + connected: AtomicBool::new(false), + pinserver, + } + } + + // ------------------------------------------------------------------ + // Discovery + // ------------------------------------------------------------------ + + /// Discover devices on every transport this build supports. + pub async fn scan(&self, timeout_ms: u32) -> Result, JadeError> { + // Starting a Bluetooth scan while a GATT link is up reliably drops it on + // Android, so refuse rather than silently breaking the open session. + if self.connected.load(Ordering::SeqCst) { + return Err(JadeError::DeviceBusy); + } + + let mut discovered = Vec::new(); + + if let Some(callback) = transport_callback() { + let found = tokio::task::spawn_blocking(move || callback.scan_devices(timeout_ms)) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("scan task failed: {error}"), + })?; + discovered.extend(found); + } + + #[cfg(not(any(target_os = "ios", target_os = "android")))] + discovered.extend(super::serial::enumerate_devices()); + + let infos: Vec = discovered + .iter() + .map(|device| JadeDeviceInfo { + id: JadeDeviceInfo::build_id(device.transport, &device.path), + transport: device.transport, + name: device.name.clone(), + path: device.path.clone(), + serial_number: device.serial_number.clone(), + }) + .collect(); + + *self.device_list.lock().await = discovered; + Ok(infos) + } + + /// The devices found by the last scan. + pub async fn list_devices(&self) -> Vec { + self.device_list + .lock() + .await + .iter() + .map(|device| JadeDeviceInfo { + id: JadeDeviceInfo::build_id(device.transport, &device.path), + transport: device.transport, + name: device.name.clone(), + path: device.path.clone(), + serial_number: device.serial_number.clone(), + }) + .collect() + } + + // ------------------------------------------------------------------ + // Connection lifecycle + // ------------------------------------------------------------------ + + /// Open a device and read its version summary. + pub async fn connect(&self, device_id: &str) -> Result { + let (kind, path) = JadeDeviceInfo::parse_id(device_id).ok_or(JadeError::DeviceNotFound)?; + let path = path.to_string(); + + let device = { + let devices = self.device_list.lock().await; + devices + .iter() + .find(|candidate| candidate.transport == kind && candidate.path == path) + .cloned() + .ok_or(JadeError::DeviceNotFound)? + }; + + // Close anything already open first. Overwriting the connection would + // strand the native handle with no path left to close it. + self.disconnect().await?; + self.aborted.store(false, Ordering::SeqCst); + + let transport: Arc = match kind { + JadeTransportKind::Bluetooth => { + let callback = transport_callback().ok_or(JadeError::NotInitialized)?; + let open_path = path.clone(); + let opener = Arc::clone(&callback); + let result = tokio::task::spawn_blocking(move || opener.open_device(open_path)) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("open task failed: {error}"), + })?; + if !result.success { + return Err(JadeError::ConnectionError { + error_details: result.error, + }); + } + Arc::new(CallbackTransport::new(callback, path.clone())) + } + JadeTransportKind::Serial => { + #[cfg(not(any(target_os = "ios", target_os = "android")))] + { + Arc::new(super::serial::SerialTransport::open(&path)?) + } + // On mobile a serial device can only have come from the native + // layer, so it is driven through the callback like Bluetooth. + #[cfg(any(target_os = "ios", target_os = "android"))] + { + let callback = transport_callback().ok_or(JadeError::NotInitialized)?; + let open_path = path.clone(); + let opener = Arc::clone(&callback); + let result = tokio::task::spawn_blocking(move || opener.open_device(open_path)) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("open task failed: {error}"), + })?; + if !result.success { + return Err(JadeError::ConnectionError { + error_details: result.error, + }); + } + Arc::new(CallbackTransport::new(callback, path.clone())) + } + } + }; + + *self.transport.write().await = Some(Arc::clone(&transport)); + let mut connection = JadeConnection::new(transport, Arc::clone(&self.aborted)); + + let version = Self::read_version(&mut connection).await?; + + // Contribute host entropy to the device's pool. The buffer is zeroized + // on drop rather than left in a freed allocation. + Self::add_entropy(&mut connection).await?; + + let info = JadeDeviceInfo { + id: device_id.to_string(), + transport: kind, + name: device.name.clone(), + path: device.path.clone(), + serial_number: device.serial_number.clone(), + }; + + *self.io.lock().await = Some(connection); + *self.state.write().await = Some(SessionState { + device: info, + version: version.clone(), + unlocked_network: None, + }); + self.connected.store(true, Ordering::SeqCst); + + Ok(version) + } + + async fn read_version(connection: &mut JadeConnection) -> Result { + let reply = connection + .exchange("get_version_info", Option::<()>::None, QUICK_TIMEOUT) + .await?; + let value = reply.into_result(MIN_JADE_FIRMWARE)?; + let wire: WireVersionInfo = value.deserialized().map_err(|error| { + JadeError::protocol(format!("unexpected get_version_info reply: {error}")) + })?; + Ok(JadeVersionInfo::from(wire)) + } + + async fn add_entropy(connection: &mut JadeConnection) -> Result<(), JadeError> { + #[derive(Serialize)] + struct AddEntropyParams { + #[serde(with = "serde_bytes")] + entropy: Vec, + } + + let mut entropy = Zeroizing::new(vec![0u8; 32]); + rand::rngs::OsRng.fill_bytes(&mut entropy); + let params = AddEntropyParams { + entropy: entropy.to_vec(), + }; + let reply = connection + .exchange("add_entropy", Some(params), QUICK_TIMEOUT) + .await?; + result_bool(&reply.into_result(MIN_JADE_FIRMWARE)?)?; + Ok(()) + } + + /// Close the device and clear session state. + /// + /// Safe to call while an operation is in flight: the abort flag is set and + /// the transport closed without taking the I/O lock, so a blocked request + /// returns promptly instead of running out its deadline. + pub async fn disconnect(&self) -> Result<(), JadeError> { + self.aborted.store(true, Ordering::SeqCst); + self.connected.store(false, Ordering::SeqCst); + + let transport = self.transport.write().await.take(); + if let Some(transport) = transport { + if let Err(error) = transport.close().await { + log::debug!("[jade] error closing the transport: {error}"); + } + } + + *self.state.write().await = None; + *self.io.lock().await = None; + Ok(()) + } + + /// Abort the operation in flight without tearing down session state. + /// + /// Jade has no cancel message, so closing the link is the only way to stop + /// a pending confirmation. The application is expected to reconnect. + pub async fn cancel(&self) -> Result<(), JadeError> { + self.aborted.store(true, Ordering::SeqCst); + let transport = self.transport.read().await.clone(); + if let Some(transport) = transport { + let _ = transport.close().await; + } + Ok(()) + } + + /// Record a disconnect the native layer noticed while nothing was in flight. + pub async fn notify_disconnected(&self, path: &str) { + let matches = self + .state + .read() + .await + .as_ref() + .map(|state| state.device.path == path) + .unwrap_or(false); + if matches { + log::debug!("[jade] native layer reported a disconnect"); + let _ = self.disconnect().await; + } + } + + pub fn is_connected(&self) -> bool { + self.connected.load(Ordering::SeqCst) + } + + pub async fn connected_device(&self) -> Option { + self.state + .read() + .await + .as_ref() + .map(|state| state.device.clone()) + } + + /// The version summary read at connect, or refreshed since. + pub async fn version_info(&self) -> Option { + self.state + .read() + .await + .as_ref() + .map(|state| state.version.clone()) + } + + /// Re-read the version summary from the device. + pub async fn refresh_version_info(&self) -> Result { + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + let version = Self::read_version(connection).await?; + drop(guard); + self.store_version(version.clone()).await; + Ok(version) + } + + async fn store_version(&self, version: JadeVersionInfo) { + if let Some(state) = self.state.write().await.as_mut() { + state.version = version; + } + } + + // ------------------------------------------------------------------ + // Operations + // ------------------------------------------------------------------ + + pub async fn ping(&self) -> Result { + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + let reply = connection + .exchange("ping", Option::<()>::None, QUICK_TIMEOUT) + .await?; + let value = reply.into_result(MIN_JADE_FIRMWARE)?; + let raw = value + .as_integer() + .and_then(|integer| u64::try_from(integer).ok()) + .ok_or_else(|| JadeError::protocol("expected an integer ping result"))?; + Ok(JadePingStatus::from_wire(raw)) + } + + /// Unlock the device, running the blind pinserver exchange if it asks. + pub async fn unlock(&self, network: JadeNetwork) -> Result<(), JadeError> { + // A device with no wallet starts an on-device setup flow that can take + // minutes and cannot be driven from here. + if let Some(state) = self.state.read().await.as_ref() { + if state.version.jade_state == JadeState::Uninit { + return Err(JadeError::DeviceUninitialized); + } + } + + let epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0); + + { + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + pinserver::run_unlock(connection, network, self.pinserver.as_ref(), epoch).await?; + } + + if let Some(state) = self.state.write().await.as_mut() { + state.unlocked_network = Some(network); + } + // The cached state still says LOCKED until this is refreshed, and the + // whole point of exposing it is telling the app whether to prompt. + let _ = self.refresh_version_info().await; + Ok(()) + } + + pub async fn logout(&self) -> Result<(), JadeError> { + { + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + let reply = connection + .exchange("logout", Option::<()>::None, QUICK_TIMEOUT) + .await?; + result_bool(&reply.into_result(MIN_JADE_FIRMWARE)?)?; + } + if let Some(state) = self.state.write().await.as_mut() { + state.unlocked_network = None; + } + let _ = self.refresh_version_info().await; + Ok(()) + } + + /// Check the requested network against the one that was unlocked. + async fn check_network(&self, network: JadeNetwork) -> Result<(), JadeError> { + let unlocked = self + .state + .read() + .await + .as_ref() + .and_then(|state| state.unlocked_network); + match unlocked { + Some(unlocked) if unlocked != network => Err(JadeError::NetworkMismatch { + error_details: format!( + "the device was unlocked for {} but the request is for {}", + unlocked.wire_name(), + network.wire_name() + ), + }), + _ => Ok(()), + } + } + + async fn raw_xpub( + &self, + network: JadeNetwork, + derivation_path: &str, + allow_master: bool, + ) -> Result { + #[derive(Serialize)] + struct GetXpubParams<'a> { + network: &'a str, + path: Vec, + } + + let wire_path = path::to_wire(derivation_path, allow_master)?; + let params = GetXpubParams { + network: network.wire_name(), + path: wire_path, + }; + + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + let reply = connection + .exchange("get_xpub", Some(params), QUICK_TIMEOUT) + .await?; + result_text(&reply.into_result(MIN_JADE_FIRMWARE)?) + } + + /// The device's master fingerprint, eight lowercase hex characters. + /// + /// Derived from `m/0'`'s parent fingerprint rather than by asking for the + /// master xpub directly, which is how HWI does it and which avoids relying + /// on the device accepting an empty path. + pub async fn master_fingerprint(&self, network: JadeNetwork) -> Result { + self.check_network(network).await?; + let xpub = self.raw_xpub(network, "m/0'", false).await?; + let parsed = Xpub::from_str(&xpub).map_err(|error| { + JadeError::protocol(format!("device returned an unparsable xpub: {error}")) + })?; + Ok(format!("{:08x}", parsed.parent_fingerprint)) + } + + /// Fetch an extended public key, echoed back with the request it answers. + pub async fn get_xpub(&self, params: JadeGetXpubParams) -> Result { + self.check_network(params.network).await?; + let fingerprint = self.master_fingerprint(params.network).await?; + let xpub = self + .raw_xpub(params.network, ¶ms.derivation_path, false) + .await?; + self.verify_xpub(&xpub, ¶ms.derivation_path)?; + + Ok(JadeXpubResponse { + xpub, + derivation_path: params.derivation_path, + master_fingerprint: fingerprint, + }) + } + + /// Confirm the device answered the question that was asked. + fn verify_xpub(&self, xpub: &str, derivation_path: &str) -> Result<(), JadeError> { + let parsed = Xpub::from_str(xpub).map_err(|error| { + JadeError::protocol(format!("device returned an unparsable xpub: {error}")) + })?; + let expected = DerivationPath::from_str(derivation_path.trim()).map_err(|error| { + JadeError::InvalidPath { + error_details: error.to_string(), + } + })?; + let depth = expected.len(); + if usize::from(parsed.depth) != depth { + return Err(JadeError::protocol(format!( + "device returned a key at depth {} for a path of depth {depth}", + parsed.depth + ))); + } + Ok(()) + } + + /// Fetch the account xpubs an import needs in one round of I/O. + pub async fn account_export( + &self, + network: JadeNetwork, + account_index: u32, + account_types: Vec, + ) -> Result { + self.check_network(network).await?; + let fingerprint = self.master_fingerprint(network).await?; + + let mut accounts = Vec::with_capacity(account_types.len()); + for account_type in account_types { + let purpose = JadeAddressVariant::from(account_type).purpose(); + let derivation_path = format!("m/{purpose}'/{}'/{account_index}'", network.coin_type()); + let xpub = self.raw_xpub(network, &derivation_path, false).await?; + self.verify_xpub(&xpub, &derivation_path)?; + accounts.push(JadeAccount { + account_type, + xpub, + derivation_path, + }); + } + + Ok(JadeAccountExport { + master_fingerprint: fingerprint, + account_index, + accounts, + }) + } + + /// Ask the device to display an address, and check it against the expected one. + /// + /// This call always prompts on the device screen, so it is a verification + /// step rather than a fetch: the application already knows the address from + /// the account xpub. Comparing the two catches corruption and firmware bugs. + /// A wholly malicious device is still the user's job to catch by reading the + /// device screen. + pub async fn verify_address(&self, params: JadeVerifyAddressParams) -> Result<(), JadeError> { + #[derive(Serialize)] + struct GetReceiveAddressParams<'a> { + network: &'a str, + variant: &'a str, + path: Vec, + } + + self.check_network(params.network).await?; + + // A legacy variant under an m/84' path is a caller bug worth catching + // before the device is asked to display something misleading. + if let Some(purpose) = path::purpose(¶ms.derivation_path) { + if purpose != params.variant.purpose() { + return Err(JadeError::InvalidPath { + error_details: format!( + "path purpose {purpose} does not match the {} variant", + params.variant.wire_name() + ), + }); + } + } + + // Taproot addresses arrived in 1.0.34. Older firmware answers + // BAD_PARAMETERS, which says nothing useful to the user. + if params.variant == JadeAddressVariant::Tr { + if let Some(state) = self.state.read().await.as_ref() { + let installed = &state.version.jade_version; + if !version_at_least(installed, MIN_JADE_FIRMWARE_TAPROOT) { + return Err(JadeError::UnsupportedFirmware { + installed: installed.clone(), + required: MIN_JADE_FIRMWARE_TAPROOT.to_string(), + }); + } + } + } + + let wire_path = path::to_wire(¶ms.derivation_path, false)?; + let request = GetReceiveAddressParams { + network: params.network.wire_name(), + variant: params.variant.wire_name(), + path: wire_path, + }; + + let returned = { + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + let reply = connection + .exchange("get_receive_address", Some(request), CONFIRM_TIMEOUT) + .await?; + result_text(&reply.into_result(MIN_JADE_FIRMWARE)?)? + }; + + if returned != params.expected_address { + return Err(JadeError::AddressMismatch { + expected: params.expected_address, + returned, + }); + } + Ok(()) + } + + /// Sign a message, returning the signature with the address that verifies it. + pub async fn sign_message( + &self, + params: JadeSignMessageParams, + network: JadeNetwork, + ) -> Result { + #[derive(Serialize)] + struct SignMessageParams<'a> { + message: &'a str, + path: Vec, + } + + self.check_network(network).await?; + let wire_path = path::to_wire(¶ms.derivation_path, false)?; + + // Derive the address host side so the caller can verify without a + // second round trip. + let xpub = self + .raw_xpub(network, ¶ms.derivation_path, false) + .await?; + let parsed = Xpub::from_str(&xpub).map_err(|error| { + JadeError::protocol(format!("device returned an unparsable xpub: {error}")) + })?; + let address = bitcoin::Address::p2wpkh( + &bitcoin::CompressedPublicKey(parsed.public_key), + bitcoin::Network::from(network), + ) + .to_string(); + + let request = SignMessageParams { + message: ¶ms.message, + path: wire_path, + }; + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + let reply = connection + .exchange("sign_message", Some(request), CONFIRM_TIMEOUT) + .await?; + let signature = result_text(&reply.into_result(MIN_JADE_FIRMWARE)?)?; + + Ok(JadeSignedMessage { + signature, + address, + derivation_path: params.derivation_path, + }) + } + + /// Sign a PSBT. + /// + /// The reply is checked against what was sent before it is returned, so the + /// guarantee holds at this boundary even for a caller that does not go on to + /// use `finalize_psbt`. + pub async fn sign_psbt(&self, params: JadeSignPsbtParams) -> Result { + #[derive(Serialize)] + struct SignPsbtParams<'a> { + network: &'a str, + #[serde(with = "serde_bytes")] + psbt: Vec, + } + + self.check_network(params.network).await?; + + let bytes = + STANDARD + .decode(params.psbt.trim()) + .map_err(|error| JadeError::InvalidPsbt { + error_details: format!("base64 decoding failed: {error}"), + })?; + let sent = Psbt::deserialize(&bytes).map_err(|error| JadeError::InvalidPsbt { + error_details: format!("parsing failed: {error}"), + })?; + + if bytes.len() as u64 > MAX_PSBT_BYTES { + return Err(JadeError::PsbtTooLarge { + size: bytes.len() as u64, + max: MAX_PSBT_BYTES, + }); + } + + self.check_signable(&sent, params.network).await?; + + let request = SignPsbtParams { + network: params.network.wire_name(), + psbt: bytes, + }; + + let signed_bytes = { + let mut guard = self.io.lock().await; + let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; + connection + .exchange_reassembled("sign_psbt", Some(request), CONFIRM_TIMEOUT) + .await? + }; + + let signed = Psbt::deserialize(&signed_bytes).map_err(|error| JadeError::InvalidPsbt { + error_details: format!("device returned an unparsable PSBT: {error}"), + })?; + verify_signed_psbt(&sent, &signed)?; + + Ok(STANDARD.encode(&signed_bytes)) + } + + /// Reject a PSBT the device would refuse or silently not sign. + async fn check_signable(&self, psbt: &Psbt, network: JadeNetwork) -> Result<(), JadeError> { + // Only SIGHASH_ALL and the taproot default are expected here. Anything + // else arriving over FFI is worth refusing rather than signing blindly. + for (index, input) in psbt.inputs.iter().enumerate() { + if let Some(sighash) = input.sighash_type { + let is_all = sighash + .ecdsa_hash_ty() + .map(|ty| ty == bitcoin::sighash::EcdsaSighashType::All) + .unwrap_or(false); + let is_default = sighash + .taproot_hash_ty() + .map(|ty| ty == bitcoin::sighash::TapSighashType::Default) + .unwrap_or(false); + if !is_all && !is_default { + return Err(JadeError::InvalidPsbt { + error_details: format!( + "input {index} requests an unsupported sighash type" + ), + }); + } + } + } + + // Without a matching fingerprint the device signs nothing and the + // failure only shows up as an opaque finalization error much later. + let Ok(device_fingerprint) = self.master_fingerprint(network).await else { + return Ok(()); + }; + let mut seen = Vec::new(); + let mut matched = false; + for input in &psbt.inputs { + for (fingerprint, _) in input.bip32_derivation.values() { + let rendered = format!("{fingerprint:08x}"); + if rendered == device_fingerprint { + matched = true; + } + seen.push(rendered); + } + for (_, (fingerprint, _)) in input.tap_key_origins.values() { + let rendered = format!("{fingerprint:08x}"); + if rendered == device_fingerprint { + matched = true; + } + seen.push(rendered); + } + } + if !seen.is_empty() && !matched { + seen.sort(); + seen.dedup(); + return Err(JadeError::FingerprintMismatch { + device: device_fingerprint, + psbt: seen.join(", "), + }); + } + Ok(()) + } +} + +/// Check what came back against what was sent. +fn verify_signed_psbt(sent: &Psbt, signed: &Psbt) -> Result<(), JadeError> { + if sent.unsigned_tx != signed.unsigned_tx { + return Err(JadeError::InvalidPsbt { + error_details: "device returned a different unsigned transaction".to_string(), + }); + } + if sent.inputs.len() != signed.inputs.len() || sent.outputs.len() != signed.outputs.len() { + return Err(JadeError::InvalidPsbt { + error_details: "device changed the number of inputs or outputs".to_string(), + }); + } + + for (index, (before, after)) in sent.inputs.iter().zip(signed.inputs.iter()).enumerate() { + if before.witness_utxo != after.witness_utxo { + return Err(JadeError::InvalidPsbt { + error_details: format!("device altered the witness UTXO of input {index}"), + }); + } + if before.non_witness_utxo != after.non_witness_utxo { + return Err(JadeError::InvalidPsbt { + error_details: format!("device altered the previous transaction of input {index}"), + }); + } + } + + let gained_signature = signed.inputs.iter().enumerate().any(|(index, input)| { + let before = &sent.inputs[index]; + input.partial_sigs.len() > before.partial_sigs.len() + || (input.final_script_witness.is_some() && before.final_script_witness.is_none()) + || (input.final_script_sig.is_some() && before.final_script_sig.is_none()) + || (input.tap_key_sig.is_some() && before.tap_key_sig.is_none()) + }); + if !gained_signature { + return Err(JadeError::NothingSigned); + } + + // Keep the secp context construction close to the other PSBT handling in + // this crate; verification only, no signing key material here. + let _ = Secp256k1::verification_only(); + Ok(()) +} diff --git a/src/modules/jade/mod.rs b/src/modules/jade/mod.rs new file mode 100644 index 0000000..feef285 --- /dev/null +++ b/src/modules/jade/mod.rs @@ -0,0 +1,45 @@ +//! Blockstream Jade hardware wallet integration. +//! +//! Jade speaks a JSON-RPC shaped protocol encoded as CBOR over either Bluetooth +//! (the Nordic UART Service) or USB CDC serial. Unlike the `trezor` module, +//! which adapts an external crate, the protocol is implemented here. +//! +//! Layering, outermost first: +//! +//! - `src/lib.rs` exports thin `jade_*` async wrappers over a global manager. +//! - `implementation.rs` owns session state and the single active connection. +//! - `pinserver.rs` runs the blind pinserver exchange that unlocks the device. +//! - `transport.rs` frames requests onto a byte stream and correlates replies. +//! - `protocol.rs` is pure CBOR framing, envelopes and id correlation. +//! - `callbacks.rs` is the trait the native app implements to do Bluetooth I/O. +//! - `serial.rs` is a Rust side serial transport for desktop and Python. +//! +//! One hard rule for anything added here: no `#[uniffi::export]` item may be +//! `cfg` gated. All three build scripts generate bindings from the host library +//! rather than the target one, so a host only export would appear in the +//! generated Swift and Kotlin while being absent from the device library. + +mod callbacks; +mod errors; +mod implementation; +mod path; +mod pinserver; +mod protocol; +#[cfg(not(any(target_os = "ios", target_os = "android")))] +mod serial; +#[cfg(test)] +mod tests; +mod transport; +mod types; + +pub use callbacks::{ + jade_set_transport_callback, JadeNativeDevice, JadeTransportCallback, JadeTransportErrorCode, + JadeTransportReadResult, JadeTransportResult, +}; +pub use errors::JadeError; +pub use implementation::JadeManager; +pub use types::{ + JadeAccount, JadeAccountExport, JadeAddressVariant, JadeDeviceInfo, JadeGetXpubParams, + JadeNetwork, JadePingStatus, JadeSignMessageParams, JadeSignPsbtParams, JadeSignedMessage, + JadeState, JadeTransportKind, JadeVerifyAddressParams, JadeVersionInfo, JadeXpubResponse, +}; diff --git a/src/modules/jade/path.rs b/src/modules/jade/path.rs new file mode 100644 index 0000000..c196392 --- /dev/null +++ b/src/modules/jade/path.rs @@ -0,0 +1,100 @@ +//! BIP32 path validation and lowering to Jade's wire representation. +//! +//! Paths cross the FFI as strings such as `m/84'/0'/0'/0/0`, matching every +//! other signer in this repo. Jade wants an array of `u32` with the hardened +//! bit already set. + +use std::str::FromStr; + +use bitcoin::bip32::DerivationPath; + +use super::errors::JadeError; + +/// Jade rejects paths deeper than this. +const MAX_DEPTH: usize = 8; + +/// Validate a derivation path string. +/// +/// `DerivationPath::from_str` alone is not enough. It accepts `""`, `"m"` and +/// `"m/"` as the master path, and it strips an optional `m/` prefix rather than +/// requiring one, so `"84'/0'/0'"` also parses. Either would be a silent +/// footgun here: an empty path handed to `sign_message` would sign with the +/// master key, and a prefix-less path means the app and this crate disagree +/// about what a path string is. +/// +/// `allow_master` opts in to the empty path, which is only wanted by the +/// deliberate master-fingerprint lookup. +pub(crate) fn validate(path: &str, allow_master: bool) -> Result<(), JadeError> { + let invalid = |reason: String| JadeError::InvalidPath { + error_details: reason, + }; + + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(invalid("path is empty".to_string())); + } + + let remainder = if trimmed == "m" { + "" + } else if let Some(rest) = trimmed.strip_prefix("m/") { + rest + } else { + return Err(invalid(format!("path must start with 'm/': {path}"))); + }; + + if remainder.is_empty() { + if allow_master { + return Ok(()); + } + return Err(invalid( + "the master path is not valid for this operation".to_string(), + )); + } + + let components: Vec<&str> = remainder.split('/').collect(); + if components.len() > MAX_DEPTH { + return Err(invalid(format!( + "path depth {} exceeds the maximum of {MAX_DEPTH}", + components.len() + ))); + } + + for (index, component) in components.iter().enumerate() { + if component.is_empty() { + return Err(invalid(format!("empty path component at index {index}"))); + } + let digits = component + .strip_suffix('\'') + .or_else(|| component.strip_suffix('h')) + .unwrap_or(component); + if digits.is_empty() || digits.parse::().is_err() { + return Err(invalid(format!( + "invalid path component '{component}' at index {index}" + ))); + } + } + + // Delegate the range check (index below 2^31) to the typed parser. + DerivationPath::from_str(trimmed) + .map(|_| ()) + .map_err(|error| invalid(format!("{path}: {error}"))) +} + +/// Validate and lower a path to the `u32` array Jade expects. +pub(crate) fn to_wire(path: &str, allow_master: bool) -> Result, JadeError> { + validate(path, allow_master)?; + let parsed = DerivationPath::from_str(path.trim()).map_err(|error| JadeError::InvalidPath { + error_details: format!("{path}: {error}"), + })?; + Ok(parsed.into_iter().map(|child| u32::from(*child)).collect()) +} + +/// The BIP44 purpose element of a path, if it has one. +/// +/// Used to check that the requested address variant agrees with the path, so a +/// caller cannot ask for a legacy address under an `m/84'` path. +pub(crate) fn purpose(path: &str) -> Option { + let wire = to_wire(path, true).ok()?; + // Strip the hardened bit before comparing against 44 / 49 / 84 / 86. + wire.first().map(|element| element & 0x7fff_ffff) +} diff --git a/src/modules/jade/pinserver.rs b/src/modules/jade/pinserver.rs new file mode 100644 index 0000000..f5b9103 --- /dev/null +++ b/src/modules/jade/pinserver.rs @@ -0,0 +1,471 @@ +//! The blind pinserver exchange that unlocks a PIN protected Jade. +//! +//! `auth_user` either returns `true`, meaning the device is already usable, or +//! it returns an `http_request` describing a call the host must make on the +//! device's behalf. The host performs it and feeds the response back through the +//! method named in `on-reply`, which is `pin`. The exchange is end to end +//! encrypted between device and pinserver, so the host never sees the PIN; its +//! role is purely to carry bytes. +//! +//! Two details here are load bearing and easy to get wrong: +//! +//! - The response body must be JSON decoded into a CBOR **map**. Firmware +//! requires `params` to be a map with a text `data` member and rejects +//! anything else, so forwarding raw HTTP bytes fails every unlock. +//! - An HTTP failure must still send a `pin` message, with no `params`. The +//! device is blocked indefinitely waiting for one; abandoning the loop leaves +//! it consuming the next unrelated request as the awaited reply, which puts +//! every subsequent call one message out of step. + +use std::net::IpAddr; +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; + +use super::errors::JadeError; +use super::transport::JadeConnection; +use super::types::JadeNetwork; + +/// How long the whole unlock may take, including user PIN entry on the device. +const UNLOCK_TIMEOUT: Duration = Duration::from_secs(300); + +/// How long a single pinserver call may take. +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Upper bound on a pinserver response body. +const MAX_BODY_BYTES: u64 = 64 * 1024; + +/// Round trips the device may ask for before the host gives up. +const MAX_ROUND_TRIPS: usize = 4; + +/// The pinserver Blockstream operates, and the only host expected in practice. +const DEFAULT_PINSERVER_HOST: &str = "jadepin.blockstream.com"; + +/// The one method the device is allowed to name in `on-reply`. +const EXPECTED_ON_REPLY: &str = "pin"; + +/// Performs the pinserver call. +/// +/// A trait so tests can drive the whole unlock with no network access. It is +/// deliberately internal: there is no FFI seam for swapping the implementation. +#[async_trait] +pub(crate) trait PinServerHttp: Send + Sync { + /// POST or GET `body` to the chosen URL and return the response bytes. + async fn request( + &self, + url: &str, + method: &str, + body: Option, + ) -> Result, JadeError>; +} + +/// The real implementation, over `reqwest`. +pub(crate) struct ReqwestPinServer; + +#[async_trait] +impl PinServerHttp for ReqwestPinServer { + async fn request( + &self, + url: &str, + method: &str, + body: Option, + ) -> Result, JadeError> { + let parsed = validate_url(url)?; + let address = resolve_and_validate(&parsed).await?; + + let host = parsed.host_str().unwrap_or_default().to_string(); + let client = reqwest::Client::builder() + // The URL list comes from the device. Following a redirect would let + // a tampered unit bounce the host somewhere the checks above already + // rejected. + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(HTTP_TIMEOUT) + .timeout(HTTP_TIMEOUT) + // Pin the socket to the address that was validated, so a second DNS + // lookup cannot return a different one. + .resolve_to_addrs(&host, &[address]) + .build() + .map_err(|error| JadeError::PinServerError { + error_details: format!("could not build the http client: {error}"), + })?; + + let request = match method { + "POST" => { + let builder = client.post(parsed.clone()); + match body { + Some(body) => builder + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body), + None => builder, + } + } + "GET" => client.get(parsed.clone()), + other => { + return Err(JadeError::PinServerError { + error_details: format!("unsupported http method {other}"), + }) + } + }; + + // reqwest embeds the full URL in its Display output, so it is stripped + // before the error reaches a log or the application. + let response = request.send().await.map_err(|error| { + let error = error.without_url(); + JadeError::PinServerError { + error_details: format!("pin server request failed: {error}"), + } + })?; + + if !response.status().is_success() { + return Err(JadeError::PinServerError { + error_details: format!("pin server returned status {}", response.status()), + }); + } + + if let Some(length) = response.content_length() { + if length > MAX_BODY_BYTES { + return Err(JadeError::PinServerError { + error_details: format!( + "pin server response of {length} bytes exceeds the {MAX_BODY_BYTES} byte limit" + ), + }); + } + } + + let bytes = response.bytes().await.map_err(|error| { + let error = error.without_url(); + JadeError::PinServerError { + error_details: format!("could not read the pin server response: {error}"), + } + })?; + + // Re-check after reading, because a response without Content-Length + // slips past the check above. + if bytes.len() as u64 > MAX_BODY_BYTES { + return Err(JadeError::PinServerError { + error_details: format!( + "pin server response exceeds the {MAX_BODY_BYTES} byte limit" + ), + }); + } + + Ok(bytes.to_vec()) + } +} + +/// Reject any URL this host should not be making a request to. +fn validate_url(url: &str) -> Result { + let reject = |reason: &str| JadeError::PinServerError { + error_details: format!("refusing pin server url: {reason}"), + }; + + let parsed = url::Url::parse(url).map_err(|error| reject(&format!("unparsable ({error})")))?; + + if parsed.scheme() != "https" { + return Err(reject("only https is supported")); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(reject("credentials are not allowed")); + } + if let Some(port) = parsed.port() { + if port != 443 { + return Err(reject("only port 443 is allowed")); + } + } + let Some(host) = parsed.host_str() else { + return Err(reject("no host")); + }; + if host.ends_with(".onion") { + return Err(reject("onion services are not supported")); + } + if !host.eq_ignore_ascii_case(DEFAULT_PINSERVER_HOST) { + // A second-hand or tampered unit can carry a pinserver its previous + // owner configured, so this is worth surfacing even though a custom + // pinserver is a legitimate configuration. + log::warn!("[jade] using a non-default pin server host"); + } + Ok(parsed) +} + +/// Resolve the host and reject addresses that should never be reachable here. +async fn resolve_and_validate(url: &url::Url) -> Result { + let host = url.host_str().unwrap_or_default().to_string(); + let port = url.port().unwrap_or(443); + let target = format!("{host}:{port}"); + + let addresses = tokio::task::spawn_blocking(move || { + use std::net::ToSocketAddrs; + target + .to_socket_addrs() + .map(|iter| iter.collect::>()) + }) + .await + .map_err(|error| JadeError::PinServerError { + error_details: format!("dns task failed: {error}"), + })? + .map_err(|error| JadeError::PinServerError { + error_details: format!("could not resolve the pin server host: {error}"), + })?; + + addresses + .into_iter() + .find(|address| is_public(address.ip())) + .ok_or_else(|| JadeError::PinServerError { + error_details: "pin server host resolved to no usable public address".to_string(), + }) +} + +/// Whether an address is one this host should send a device-directed request to. +fn is_public(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let octets = v4.octets(); + // 100.64.0.0/10, carrier grade NAT. There is no stable std helper. + let is_cgnat = octets[0] == 100 && (64..128).contains(&octets[1]); + !(v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_unspecified() + || v4.is_multicast() + || is_cgnat) + } + IpAddr::V6(v6) => { + let segments = v6.segments(); + // fc00::/7 unique local, fe80::/10 link local. + let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; + let is_link_local = (segments[0] & 0xffc0) == 0xfe80; + !(v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + || v6.to_ipv4_mapped().is_some() + || is_unique_local + || is_link_local) + } + } +} + +// ============================================================================ +// Wire shapes +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct HttpRequestEnvelope { + http_request: HttpRequest, +} + +#[derive(Debug, Deserialize)] +struct HttpRequest { + params: HttpRequestParams, + #[serde(rename = "on-reply")] + on_reply: String, +} + +#[derive(Debug, Deserialize)] +struct HttpRequestParams { + urls: Vec, + method: String, + #[serde(default)] + accept: Option, + #[serde(default)] + data: Option, +} + +#[derive(serde::Serialize)] +struct AuthUserParams<'a> { + network: &'a str, + epoch: u64, +} + +/// Run `auth_user` and, if the device asks, the pinserver exchange. +pub(crate) async fn run_unlock( + connection: &mut JadeConnection, + network: JadeNetwork, + http: &dyn PinServerHttp, + epoch: u64, +) -> Result<(), JadeError> { + let params = AuthUserParams { + network: network.wire_name(), + epoch, + }; + let reply = connection + .exchange("auth_user", Some(params), UNLOCK_TIMEOUT) + .await?; + let mut result = reply.into_result(super::types::MIN_JADE_FIRMWARE)?; + + for _ in 0..MAX_ROUND_TRIPS { + // A boolean result ends the exchange either way. + if let Some(unlocked) = result.as_bool() { + return if unlocked { + Ok(()) + } else { + Err(JadeError::InvalidPin) + }; + } + + let envelope: HttpRequestEnvelope = result + .deserialized() + .map_err(|error| JadeError::protocol(format!("unexpected auth_user reply: {error}")))?; + let request = envelope.http_request; + + // The method name is supplied by the device. Dispatching on it blindly + // would let a device make the host invoke any RPC with chosen params. + if request.on_reply != EXPECTED_ON_REPLY { + return Err(JadeError::protocol(format!( + "device asked the host to call '{}', expected '{EXPECTED_ON_REPLY}'", + request.on_reply + ))); + } + + let body = perform(http, &request.params).await; + result = send_pin(connection, body).await?; + } + + Err(JadeError::PinServerError { + error_details: format!("unlock did not finish within {MAX_ROUND_TRIPS} round trips"), + }) +} + +/// Make the call the device asked for, returning the params for the follow-up. +/// +/// A failure yields `None`, which becomes a `pin` message with no params. That +/// is what the device expects, and it is what keeps the two sides in step. +async fn perform(http: &dyn PinServerHttp, params: &HttpRequestParams) -> Option { + let use_json = matches!( + params.accept.as_deref(), + Some("json") | Some("application/json") + ); + + let url = params + .urls + .iter() + .find(|candidate| !is_onion(candidate)) + .or_else(|| params.urls.first())?; + + // Firmware wraps the payload in an extra layer when it wants JSON, so + // `data` is a CBOR map that has to be rendered as a JSON document. + let body = match (¶ms.data, use_json) { + (Some(data), true) => match cbor_to_json(data) { + Ok(json) => Some(json.to_string()), + Err(error) => { + log::warn!("[jade] could not render pin server payload: {error}"); + return None; + } + }, + (Some(ciborium::Value::Text(text)), false) => Some(text.clone()), + _ => None, + }; + + let response = match http.request(url, ¶ms.method, body).await { + Ok(response) => response, + Err(error) => { + log::warn!("[jade] pin server call failed: {error}"); + return None; + } + }; + + if !use_json { + return Some(ciborium::Value::Bytes(response)); + } + + match serde_json::from_slice::(&response) { + Ok(json) if json.is_object() => json_to_cbor(&json).ok(), + Ok(_) => { + log::warn!("[jade] pin server returned a non-object json body"); + None + } + Err(error) => { + log::warn!("[jade] pin server returned invalid json: {error}"); + None + } + } +} + +/// Send the follow-up `pin` message. +async fn send_pin( + connection: &mut JadeConnection, + params: Option, +) -> Result { + let reply = connection.exchange("pin", params, UNLOCK_TIMEOUT).await?; + reply.into_result(super::types::MIN_JADE_FIRMWARE) +} + +/// Whether a URL's host is an onion service. +/// +/// A suffix test on the whole URL does not work: firmware sends +/// `http://<...>.onion/get_pin`, so the string ends with the document name. +fn is_onion(url: &str) -> bool { + url::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| host.ends_with(".onion"))) + .unwrap_or(false) +} + +/// Render a CBOR value as JSON for the pinserver request body. +pub(crate) fn cbor_to_json(value: &ciborium::Value) -> Result { + let unsupported = |what: &str| JadeError::protocol(format!("cannot render {what} as json")); + + Ok(match value { + ciborium::Value::Null => serde_json::Value::Null, + ciborium::Value::Bool(inner) => serde_json::Value::Bool(*inner), + ciborium::Value::Text(inner) => serde_json::Value::String(inner.clone()), + ciborium::Value::Integer(inner) => { + let as_i128: i128 = (*inner).into(); + let number = i64::try_from(as_i128).map_err(|_| unsupported("an oversized integer"))?; + serde_json::Value::Number(number.into()) + } + ciborium::Value::Array(items) => serde_json::Value::Array( + items + .iter() + .map(cbor_to_json) + .collect::, _>>()?, + ), + ciborium::Value::Map(entries) => { + let mut map = serde_json::Map::with_capacity(entries.len()); + for (key, value) in entries { + let key = key + .as_text() + .ok_or_else(|| unsupported("a map with a non-text key"))?; + map.insert(key.to_string(), cbor_to_json(value)?); + } + serde_json::Value::Object(map) + } + // The pinserver protocol carries binary as hex or base64 text, so a raw + // byte string here means the device sent something unexpected. + ciborium::Value::Bytes(_) => return Err(unsupported("a byte string")), + ciborium::Value::Float(_) => return Err(unsupported("a float")), + _ => return Err(unsupported("an unrecognised cbor value")), + }) +} + +/// Convert the pinserver's JSON reply into the CBOR map the device expects. +pub(crate) fn json_to_cbor(value: &serde_json::Value) -> Result { + Ok(match value { + serde_json::Value::Null => ciborium::Value::Null, + serde_json::Value::Bool(inner) => ciborium::Value::Bool(*inner), + serde_json::Value::String(inner) => ciborium::Value::Text(inner.clone()), + serde_json::Value::Number(number) => { + if let Some(inner) = number.as_i64() { + ciborium::Value::Integer(inner.into()) + } else { + return Err(JadeError::protocol("cannot represent a json float in cbor")); + } + } + serde_json::Value::Array(items) => ciborium::Value::Array( + items + .iter() + .map(json_to_cbor) + .collect::, _>>()?, + ), + serde_json::Value::Object(entries) => ciborium::Value::Map( + entries + .iter() + .map(|(key, value)| { + json_to_cbor(value).map(|value| (ciborium::Value::Text(key.clone()), value)) + }) + .collect::, _>>()?, + ), + }) +} diff --git a/src/modules/jade/protocol.rs b/src/modules/jade/protocol.rs new file mode 100644 index 0000000..673d72d --- /dev/null +++ b/src/modules/jade/protocol.rs @@ -0,0 +1,222 @@ +//! Jade wire protocol: CBOR framing, request and reply envelopes, id correlation. +//! +//! Jade speaks a JSON-RPC shaped protocol encoded as CBOR. There is no length +//! prefix and no framing bytes: messages are self-delimiting CBOR maps written +//! back to back on the stream. A reader therefore has to buffer whatever the +//! transport hands it and attempt an incremental decode after each read until +//! one complete item is present. +//! +//! This module is pure. It performs no I/O and holds no state beyond a request +//! id counter, which makes the framing and correlation rules directly testable. + +use serde::{Deserialize, Serialize}; + +use super::errors::JadeError; + +/// Upper bound on a single buffered frame. +/// +/// Jade's own `MAX_OUTPUT_MSG_SIZE` is 3 KiB, so this is generous. The cap +/// exists because a corrupt length header (for example `0x5b` followed by eight +/// `0xff` bytes) decodes as a byte string of nearly 2^64 bytes. Without a cap, +/// `skip()` would report "need more input" forever while the read buffer grew +/// without bound. +pub(crate) const MAX_FRAME_BYTES: usize = 64 * 1024; + +/// Take the first complete CBOR item out of `buf`, if one has arrived. +/// +/// Returns `Ok(None)` when the buffer holds a valid but truncated item and the +/// caller should read more. Returns `Err` when the buffer cannot be a valid +/// frame, having cleared the buffer, because there is no way to find the next +/// frame boundary in a corrupt stream. +pub(crate) fn try_take_frame(buf: &mut Vec) -> Result>, JadeError> { + if buf.is_empty() { + return Ok(None); + } + + // A fresh decoder per attempt. Reusing one across reads would carry its + // position forward and silently shift every subsequent frame boundary. + let mut decoder = minicbor::Decoder::new(buf); + match decoder.skip() { + Ok(()) => { + let length = decoder.position(); + Ok(Some(buf.drain(..length).collect())) + } + Err(error) if error.is_end_of_input() => { + if buf.len() > MAX_FRAME_BYTES { + buf.clear(); + return Err(JadeError::protocol(format!( + "incomplete frame exceeded {MAX_FRAME_BYTES} bytes" + ))); + } + Ok(None) + } + Err(error) => { + buf.clear(); + Err(JadeError::protocol(format!( + "malformed CBOR frame: {error}" + ))) + } + } +} + +/// Generates request ids for one connection. +/// +/// Jade caps ids at 16 characters (`MAXLEN_ID`), and `jadepy` asserts strictly +/// fewer than 16, so the counter wraps well before a `u64` would overflow the +/// limit. The counter is per connection rather than process wide: that keeps +/// ids deterministic inside a single test, and stops an id from encoding how +/// many operations the process has performed. +#[derive(Debug, Default)] +pub(crate) struct RequestIds { + next: u64, +} + +impl RequestIds { + pub(crate) fn new() -> Self { + Self { next: 0 } + } + + pub(crate) fn next_id(&mut self) -> String { + // Wrap at 15 digits so the rendered id always fits Jade's 16 character + // limit, and start at 1 so an id is never the empty string. + self.next = (self.next % 999_999_999_999_999) + 1; + self.next.to_string() + } +} + +/// A request being sent to the device. +/// +/// `params` is skipped entirely when absent rather than encoded as CBOR null. +/// Jade reads parameters with typed getters that treat a null as a missing +/// value but then fail with `BAD_PARAMETERS`, so an explicit null is worse than +/// no key at all. The blind pinserver flow also depends on being able to send +/// `pin` with no params, which is how the host reports an HTTP failure. +#[derive(Debug, Serialize)] +pub(crate) struct JadeRequest<'a, P: Serialize> { + pub id: &'a str, + pub method: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option

, +} + +/// Encode a request to CBOR. +pub(crate) fn encode_request( + id: &str, + method: &str, + params: Option

, +) -> Result, JadeError> { + let request = JadeRequest { id, method, params }; + let mut encoded = Vec::new(); + ciborium::into_writer(&request, &mut encoded) + .map_err(|error| JadeError::protocol(format!("failed to encode {method}: {error}")))?; + Ok(encoded) +} + +/// The error member of a reply. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub(crate) struct JadeRpcError { + pub code: i64, + #[serde(default)] + pub message: String, + /// Jade writes this with `cbor_encode_byte_string`, so it must be read as a + /// byte string rather than through the generic `Vec` deserializer. + #[serde(default)] + pub data: Option, +} + +/// A decoded reply frame. +/// +/// Every field is optional because the device also emits unsolicited `{"log": +/// ...}` frames on the same stream. Those carry no `id`, and a required `id` +/// field would make a single device log line fail the whole decode. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct JadeReply { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub result: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub seqnum: Option, + #[serde(default)] + pub seqlen: Option, +} + +/// Decode one complete frame. +pub(crate) fn decode_reply(frame: &[u8]) -> Result { + ciborium::from_reader(frame) + .map_err(|error| JadeError::protocol(format!("failed to decode reply: {error}"))) +} + +/// The id Jade uses when it cannot recover the id of the request it is +/// rejecting, for example when the request was never parsed as valid CBOR or +/// exceeded the device's input buffer. +pub(crate) const UNATTRIBUTED_ID: &str = "00"; + +/// What to do with a decoded reply, given the request currently outstanding. +#[derive(Debug)] +pub(crate) enum ReplyMatch { + /// The reply for the outstanding request. + Matched(JadeReply), + /// A terminal error the device could not attribute to a request id. Jade + /// sends these with id "00" when it rejects a message before recovering its + /// id. Treating them as unmatched and ignoring them would turn every such + /// rejection into a full length timeout. + Unattributed(JadeRpcError), + /// Not for us. A device log frame, or a late reply to a request that has + /// already timed out. Discard and keep reading rather than failing, so one + /// stale reply does not poison the next operation. + Ignore, +} + +/// Classify a reply against the outstanding request id. +pub(crate) fn classify(reply: JadeReply, outstanding_id: &str) -> ReplyMatch { + match reply.id.as_deref() { + Some(id) if id == outstanding_id => ReplyMatch::Matched(reply), + Some(UNATTRIBUTED_ID) => match reply.error { + Some(error) => ReplyMatch::Unattributed(error), + None => ReplyMatch::Ignore, + }, + _ => ReplyMatch::Ignore, + } +} + +impl JadeReply { + /// Take the result, converting an error member into a typed error. + pub(crate) fn into_result(self, min_firmware: &str) -> Result { + if let Some(error) = self.error { + return Err(JadeError::from_rpc(error.code, error.message, min_firmware)); + } + self.result + .ok_or_else(|| JadeError::protocol("reply carried neither result nor error")) + } +} + +/// Read a binary result. +/// +/// Binary values must come off the `ciborium::Value` as bytes rather than +/// through `Value::deserialized::>()`. The `Value` deserializer maps +/// `deserialize_seq` onto `Value::Array` only, so a CBOR byte string would be +/// rejected as a type mismatch. +pub(crate) fn result_bytes(value: &ciborium::Value) -> Result, JadeError> { + value + .as_bytes() + .map(|bytes| bytes.to_vec()) + .ok_or_else(|| JadeError::protocol("expected a byte string result")) +} + +/// Read a text result. +pub(crate) fn result_text(value: &ciborium::Value) -> Result { + value + .as_text() + .map(str::to_string) + .ok_or_else(|| JadeError::protocol("expected a text result")) +} + +/// Read a boolean result. +pub(crate) fn result_bool(value: &ciborium::Value) -> Result { + value + .as_bool() + .ok_or_else(|| JadeError::protocol("expected a boolean result")) +} diff --git a/src/modules/jade/serial.rs b/src/modules/jade/serial.rs new file mode 100644 index 0000000..3bfd52e --- /dev/null +++ b/src/modules/jade/serial.rs @@ -0,0 +1,172 @@ +//! USB CDC serial transport, for desktop and the Python bindings. +//! +//! Not built for iOS, which has no USB serial, or for Android, where the +//! application drives USB through the transport callback. Nothing in this file +//! is exported over FFI: the bindings are generated from the host library, so a +//! platform gated export would appear in the generated Swift and Kotlin while +//! being absent from the device library. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use serialport::SerialPort; + +use super::callbacks::JadeNativeDevice; +use super::errors::JadeError; +use super::transport::JadeTransport; +use super::types::JadeTransportKind; + +/// Jade's serial link speed. +const BAUD_RATE: u32 = 115_200; + +/// Serial has no MTU, but chunking keeps writes off the stack and matches the +/// Bluetooth path closely enough that both exercise the same code. +const CHUNK_BYTES: usize = 509; + +/// USB vendor and product pairs seen on Jade and Jade Plus units, including the +/// bridge chips used by DIY builds. +const KNOWN_USB_IDS: &[(u16, u16)] = &[ + (0x10c4, 0xea60), // Silicon Labs CP210x, Jade v1 + (0x1a86, 0x55d4), // WCH CH9102 + (0x0403, 0x6001), // FTDI FT232 + (0x1a86, 0x7523), // WCH CH340 + (0x303a, 0x4001), // Espressif native USB, Jade Plus + (0x303a, 0x1001), // Espressif USB serial/JTAG +]; + +/// Discover attached Jade units. +/// +/// Only ports whose USB descriptor matches a known Jade bridge are returned, so +/// a modem or GPS receiver on the same machine is not offered as a Jade. +pub(crate) fn enumerate_devices() -> Vec { + // serialport's Linux path without libudev reads /sys/class/tty and panics + // outright if it is missing. A wallet library must not carry that risk. + #[cfg(target_os = "linux")] + if !std::path::Path::new("/sys/class/tty").exists() { + log::warn!("[jade] /sys/class/tty is missing, skipping serial enumeration"); + return Vec::new(); + } + + let ports = match serialport::available_ports() { + Ok(ports) => ports, + Err(error) => { + log::warn!("[jade] could not enumerate serial ports: {error}"); + return Vec::new(); + } + }; + + ports + .into_iter() + .filter_map(|port| { + let serialport::SerialPortType::UsbPort(info) = port.port_type else { + return None; + }; + if !KNOWN_USB_IDS.contains(&(info.vid, info.pid)) { + return None; + } + Some(JadeNativeDevice { + path: port.port_name, + transport: JadeTransportKind::Serial, + name: info.product.clone(), + serial_number: info.serial_number.clone(), + }) + }) + .collect() +} + +/// A serial link to a device. +pub(crate) struct SerialTransport { + /// A std mutex rather than a tokio one: the guard is taken inside + /// `spawn_blocking`, where a tokio guard could not be held. + port: Arc>>, +} + +impl SerialTransport { + pub(crate) fn open(path: &str) -> Result { + let mut port = serialport::new(path, BAUD_RATE) + .timeout(Duration::from_millis(250)) + // Asserting DTR or RTS resets the ESP32 on several of the bridge + // chips above, so the line has to come up with both clear. + .dtr_on_open(false) + .open() + .map_err(|error| JadeError::ConnectionError { + error_details: format!("could not open {path}: {error}"), + })?; + + if let Err(error) = port.write_request_to_send(false) { + log::warn!("[jade] could not clear RTS on {path}: {error}"); + } + + Ok(Self { + port: Arc::new(Mutex::new(port)), + }) + } +} + +#[async_trait] +impl JadeTransport for SerialTransport { + async fn write_all(&self, data: Vec) -> Result<(), JadeError> { + let port = Arc::clone(&self.port); + tokio::task::spawn_blocking(move || { + let mut port = port + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for chunk in data.chunks(CHUNK_BYTES) { + std::io::Write::write_all(&mut *port, chunk).map_err(|error| { + JadeError::transport(format!("serial write failed: {error}")) + })?; + } + std::io::Write::flush(&mut *port) + .map_err(|error| JadeError::transport(format!("serial flush failed: {error}"))) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("serial write task failed: {error}"), + })? + } + + async fn read_some(&self, timeout: Duration) -> Result, JadeError> { + let port = Arc::clone(&self.port); + tokio::task::spawn_blocking(move || { + let mut port = port + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Err(error) = port.set_timeout(timeout) { + log::debug!("[jade] could not set the serial timeout: {error}"); + } + + let mut buffer = vec![0u8; 4096]; + match std::io::Read::read(&mut *port, &mut buffer) { + Ok(read) => { + buffer.truncate(read); + Ok(buffer) + } + // A timeout means nothing arrived, which is the normal state + // while the user is deciding on the device. + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => Ok(Vec::new()), + Err(error) => Err(JadeError::transport(format!("serial read failed: {error}"))), + } + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("serial read task failed: {error}"), + })? + } + + async fn close(&self) -> Result<(), JadeError> { + let port = Arc::clone(&self.port); + tokio::task::spawn_blocking(move || { + let mut port = port + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Leaving DTR or RTS asserted on close resets the device. + let _ = port.write_data_terminal_ready(false); + let _ = port.write_request_to_send(false); + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("serial close task failed: {error}"), + }) + } +} diff --git a/src/modules/jade/tests.rs b/src/modules/jade/tests.rs new file mode 100644 index 0000000..951170f --- /dev/null +++ b/src/modules/jade/tests.rs @@ -0,0 +1,1267 @@ +use super::errors::{rpc_code, JadeError}; +use super::protocol::{ + classify, decode_reply, encode_request, result_bool, result_bytes, result_text, try_take_frame, + JadeReply, ReplyMatch, RequestIds, MAX_FRAME_BYTES, +}; +use serde::Serialize; + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Encode a CBOR map from `(key, value)` pairs. +fn cbor_map(entries: Vec<(&str, ciborium::Value)>) -> Vec { + let value = ciborium::Value::Map( + entries + .into_iter() + .map(|(key, value)| (ciborium::Value::Text(key.to_string()), value)) + .collect(), + ); + let mut encoded = Vec::new(); + ciborium::into_writer(&value, &mut encoded).unwrap(); + encoded +} + +fn text(value: &str) -> ciborium::Value { + ciborium::Value::Text(value.to_string()) +} + +fn int(value: i64) -> ciborium::Value { + ciborium::Value::Integer(value.into()) +} + +fn reply_frame(id: &str, result: ciborium::Value) -> Vec { + cbor_map(vec![("id", text(id)), ("result", result)]) +} + +fn error_frame(id: &str, code: i64, message: &str) -> Vec { + cbor_map(vec![ + ("id", text(id)), + ( + "error", + ciborium::Value::Map(vec![ + (text("code"), int(code)), + (text("message"), text(message)), + ]), + ), + ]) +} + +// ============================================================================ +// Byte string encoding +// +// This is the single easiest thing to get silently wrong. serde encodes a plain +// `Vec` as a CBOR array of integers, but Jade reads `psbt` and `entropy` +// with `rpc_get_bytes_ptr`, which requires major type 2. Without +// `#[serde(with = "serde_bytes")]` the device rejects every sign_psbt and +// add_entropy with BAD_PARAMETERS, and nothing catches it until real hardware. +// ============================================================================ + +#[derive(Serialize)] +struct BytesParams { + #[serde(with = "serde_bytes")] + psbt: Vec, +} + +#[derive(Serialize)] +struct NaiveBytesParams { + psbt: Vec, +} + +/// Locate the CBOR header byte immediately following the text key `psbt`. +fn byte_after_psbt_key(encoded: &[u8]) -> u8 { + // "psbt" as a CBOR text string of length 4 is 0x64 followed by the ASCII. + let key = [0x64, b'p', b's', b'b', b't']; + let position = encoded + .windows(key.len()) + .position(|window| window == key) + .expect("psbt key not found in encoding"); + encoded[position + key.len()] +} + +#[test] +fn binary_params_encode_as_cbor_byte_strings() { + let params = BytesParams { + psbt: vec![0x70, 0x73, 0x62, 0x74, 0xff], + }; + let encoded = encode_request("1", "sign_psbt", Some(params)).unwrap(); + + // Major type 2 (byte string) occupies 0x40..=0x5f. + let header = byte_after_psbt_key(&encoded); + assert!( + (0x40..=0x5f).contains(&header), + "expected a byte string header, got {header:#04x}" + ); +} + +#[test] +fn binary_params_without_serde_bytes_would_encode_as_an_array() { + // Guards the reason the annotation exists. If ciborium ever started writing + // byte strings for a plain Vec, this test would fail and the annotation + // could be revisited. + let params = NaiveBytesParams { + psbt: vec![0x70, 0x73, 0x62, 0x74, 0xff], + }; + let encoded = encode_request("1", "sign_psbt", Some(params)).unwrap(); + + // Major type 4 (array) occupies 0x80..=0x9f. + let header = byte_after_psbt_key(&encoded); + assert!( + (0x80..=0x9f).contains(&header), + "expected an array header, got {header:#04x}" + ); +} + +#[test] +fn absent_params_are_omitted_rather_than_encoded_as_null() { + // Jade's typed getters treat a CBOR null as a missing value and then fail + // with BAD_PARAMETERS, so the key must not be present at all. + let encoded = encode_request("7", "ping", Option::<()>::None).unwrap(); + assert!( + !encoded + .windows(6) + .any(|w| w == [0x66, b'p', b'a', b'r', b'a', b'm']), + "params key should be absent" + ); + let reply: JadeReply = decode_reply(&encoded).unwrap(); + assert_eq!(reply.id.as_deref(), Some("7")); +} + +// ============================================================================ +// Framing +// ============================================================================ + +#[test] +fn a_frame_split_across_reads_reassembles() { + let frame = reply_frame("1", text("xpub")); + let mut buf = Vec::new(); + + for chunk in frame.chunks(3) { + // Every partial state must report "need more bytes", never a frame. + if buf.len() + chunk.len() < frame.len() { + buf.extend_from_slice(chunk); + assert!(try_take_frame(&mut buf).unwrap().is_none()); + } else { + buf.extend_from_slice(chunk); + } + } + + let taken = try_take_frame(&mut buf).unwrap().expect("frame"); + assert_eq!(taken, frame); + assert!(buf.is_empty()); +} + +#[test] +fn two_frames_in_one_read_are_decoded_separately() { + let first = reply_frame("1", text("one")); + let second = reply_frame("2", text("two")); + let mut buf = [first.clone(), second.clone()].concat(); + + assert_eq!(try_take_frame(&mut buf).unwrap().unwrap(), first); + assert_eq!(try_take_frame(&mut buf).unwrap().unwrap(), second); + assert!(try_take_frame(&mut buf).unwrap().is_none()); +} + +#[test] +fn an_empty_buffer_needs_more_bytes() { + let mut buf = Vec::new(); + assert!(try_take_frame(&mut buf).unwrap().is_none()); +} + +#[test] +fn a_corrupt_length_header_is_capped_rather_than_buffered_forever() { + // 0x5b announces a byte string whose length is the next eight bytes, here + // nearly 2^64. skip() will report end of input on every call, so without a + // cap the read buffer would grow without bound. + let mut buf = vec![0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; + assert!(try_take_frame(&mut buf).unwrap().is_none()); + + buf.extend(std::iter::repeat_n(0u8, MAX_FRAME_BYTES + 1)); + let error = try_take_frame(&mut buf).unwrap_err(); + assert!(matches!(error, JadeError::ProtocolError { .. })); + assert!( + buf.is_empty(), + "buffer must be cleared on a framing failure" + ); +} + +#[test] +fn malformed_cbor_errors_rather_than_returning_a_truncated_frame() { + // 0x1f is a reserved additional-information value for major type 0. + let mut buf = vec![0x1f, 0x00, 0x00]; + let error = try_take_frame(&mut buf).unwrap_err(); + assert!(matches!(error, JadeError::ProtocolError { .. })); + assert!(buf.is_empty()); +} + +// ============================================================================ +// Reply correlation +// ============================================================================ + +#[test] +fn a_log_frame_carrying_no_id_is_ignored() { + // The device emits these unsolicited on the same stream. + let frame = cbor_map(vec![( + "log", + ciborium::Value::Bytes(b"I (123) main: boot".to_vec()), + )]); + let reply = decode_reply(&frame).unwrap(); + assert!(reply.id.is_none()); + assert!(matches!(classify(reply, "1"), ReplyMatch::Ignore)); +} + +#[test] +fn a_stale_reply_is_ignored_rather_than_failing_the_next_request() { + let reply = decode_reply(&reply_frame("1", text("old"))).unwrap(); + assert!(matches!(classify(reply, "2"), ReplyMatch::Ignore)); +} + +#[test] +fn a_matching_reply_is_delivered() { + let reply = decode_reply(&reply_frame("2", text("xpub"))).unwrap(); + match classify(reply, "2") { + ReplyMatch::Matched(reply) => { + assert_eq!(result_text(&reply.result.unwrap()).unwrap(), "xpub"); + } + other => panic!("expected a match, got {other:?}"), + } +} + +#[test] +fn an_unattributed_error_resolves_the_outstanding_request() { + // Jade replies with id "00" when it rejects a message before it can recover + // the request id, for example an oversize or malformed request. Ignoring + // these would turn every such rejection into a full length timeout. + let frame = error_frame( + super::protocol::UNATTRIBUTED_ID, + rpc_code::INVALID_REQUEST, + "Invalid RPC Request message", + ); + let reply = decode_reply(&frame).unwrap(); + match classify(reply, "7") { + ReplyMatch::Unattributed(error) => { + assert_eq!(error.code, rpc_code::INVALID_REQUEST); + } + other => panic!("expected an unattributed error, got {other:?}"), + } +} + +#[test] +fn an_unattributed_frame_without_an_error_member_is_ignored() { + let reply = decode_reply(&reply_frame(super::protocol::UNATTRIBUTED_ID, text("x"))).unwrap(); + assert!(matches!(classify(reply, "7"), ReplyMatch::Ignore)); +} + +// ============================================================================ +// Error mapping +// ============================================================================ + +#[test] +fn rpc_error_codes_map_to_typed_errors() { + let cases = [ + (rpc_code::USER_CANCELLED, JadeError::UserCancelled), + (rpc_code::HW_LOCKED, JadeError::DeviceLocked), + ]; + for (code, expected) in cases { + let reply = decode_reply(&error_frame("1", code, "denied")).unwrap(); + assert_eq!(reply.into_result("1.0.0").unwrap_err(), expected); + } + + let reply = decode_reply(&error_frame("1", rpc_code::NETWORK_MISMATCH, "wrong net")).unwrap(); + assert!(matches!( + reply.into_result("1.0.0").unwrap_err(), + JadeError::NetworkMismatch { .. } + )); + + // An old device answering UNKNOWN_METHOD is reporting its age, not a host + // bug, so it must not surface as a generic protocol error. + let reply = decode_reply(&error_frame("1", rpc_code::UNKNOWN_METHOD, "nope")).unwrap(); + assert!(matches!( + reply.into_result("1.0.30").unwrap_err(), + JadeError::UnsupportedFirmware { .. } + )); + + let reply = decode_reply(&error_frame("1", rpc_code::INVALID_REQUEST, "bad")).unwrap(); + assert!(matches!( + reply.into_result("1.0.0").unwrap_err(), + JadeError::ProtocolError { .. } + )); + + let reply = decode_reply(&error_frame("1", -32099, "novel")).unwrap(); + assert!(matches!( + reply.into_result("1.0.0").unwrap_err(), + JadeError::DeviceError { .. } + )); +} + +#[test] +fn a_reply_with_neither_result_nor_error_is_a_protocol_error() { + let reply = decode_reply(&cbor_map(vec![("id", text("1"))])).unwrap(); + assert!(matches!( + reply.into_result("1.0.0").unwrap_err(), + JadeError::ProtocolError { .. } + )); +} + +// ============================================================================ +// Result readers +// ============================================================================ + +#[test] +fn byte_string_results_are_read_as_bytes() { + // sign_psbt returns a CBOR byte string. Going through + // Value::deserialized::>() would fail here, because the Value + // deserializer maps deserialize_seq onto Value::Array only. + let frame = reply_frame("1", ciborium::Value::Bytes(vec![0x70, 0x73, 0x62, 0x74])); + let reply = decode_reply(&frame).unwrap(); + let value = reply.into_result("1.0.0").unwrap(); + assert_eq!(result_bytes(&value).unwrap(), vec![0x70, 0x73, 0x62, 0x74]); +} + +#[test] +fn boolean_and_text_results_are_read() { + let reply = decode_reply(&reply_frame("1", ciborium::Value::Bool(true))).unwrap(); + assert!(result_bool(&reply.into_result("1.0.0").unwrap()).unwrap()); + + let reply = decode_reply(&reply_frame("1", text("tpub..."))).unwrap(); + assert_eq!( + result_text(&reply.into_result("1.0.0").unwrap()).unwrap(), + "tpub..." + ); +} + +#[test] +fn a_wrongly_typed_result_is_a_protocol_error() { + let reply = decode_reply(&reply_frame("1", int(5))).unwrap(); + let value = reply.into_result("1.0.0").unwrap(); + assert!(matches!( + result_text(&value).unwrap_err(), + JadeError::ProtocolError { .. } + )); +} + +#[test] +fn sequenced_replies_expose_seqnum_and_seqlen() { + let frame = cbor_map(vec![ + ("id", text("1")), + ("result", ciborium::Value::Bytes(vec![1, 2, 3])), + ("seqnum", int(1)), + ("seqlen", int(3)), + ]); + let reply = decode_reply(&frame).unwrap(); + assert_eq!(reply.seqnum, Some(1)); + assert_eq!(reply.seqlen, Some(3)); +} + +// ============================================================================ +// Request ids +// ============================================================================ + +#[test] +fn request_ids_are_sequential_per_connection_and_fit_the_device_limit() { + let mut ids = RequestIds::new(); + assert_eq!(ids.next_id(), "1"); + assert_eq!(ids.next_id(), "2"); + assert_eq!(ids.next_id(), "3"); + + // Two connections do not share a counter, so ids stay deterministic in a + // test process that runs many of them concurrently. + let mut other = RequestIds::new(); + assert_eq!(other.next_id(), "1"); +} + +#[test] +fn request_ids_never_exceed_the_sixteen_character_limit() { + let mut ids = RequestIds::new(); + for _ in 0..1000 { + let id = ids.next_id(); + assert!(!id.is_empty()); + assert!(id.len() < 16, "id {id} is too long for Jade"); + } +} + +// ============================================================================ +// Path validation +// +// DerivationPath::from_str alone accepts "" as the master path and accepts a +// path with no "m/" prefix, either of which would be a silent footgun. +// ============================================================================ + +mod paths { + use super::super::errors::JadeError; + use super::super::path; + + #[test] + fn a_valid_path_lowers_to_the_wire_representation() { + assert_eq!( + path::to_wire("m/84'/0'/0'/0/0", false).unwrap(), + vec![2147483732, 2147483648, 2147483648, 0, 0] + ); + // The 'h' hardened notation is equivalent to an apostrophe. + assert_eq!( + path::to_wire("m/84h/1h/0h", false).unwrap(), + vec![2147483732, 2147483649, 2147483648] + ); + } + + #[test] + fn the_empty_path_is_rejected_unless_explicitly_allowed() { + // Signing with the master key because a caller passed "" is exactly the + // outcome this guards against. + assert!(matches!( + path::validate("", false).unwrap_err(), + JadeError::InvalidPath { .. } + )); + assert!(matches!( + path::validate("m", false).unwrap_err(), + JadeError::InvalidPath { .. } + )); + assert!(matches!( + path::validate("m/", false).unwrap_err(), + JadeError::InvalidPath { .. } + )); + + // The master-fingerprint lookup opts in deliberately. + assert!(path::validate("m", true).is_ok()); + assert_eq!(path::to_wire("m", true).unwrap(), Vec::::new()); + } + + #[test] + fn a_path_without_the_m_prefix_is_rejected() { + assert!(matches!( + path::validate("84'/0'/0'", false).unwrap_err(), + JadeError::InvalidPath { .. } + )); + } + + #[test] + fn malformed_and_overdeep_paths_are_rejected() { + for bad in [ + "m/84'/x/0'", + "m/84'//0'", + "m/84'/0'/", + "n/84'/0'", + "m/84'/0'/0'/0/0/0/0/0/0", + ] { + assert!( + matches!( + path::validate(bad, false), + Err(JadeError::InvalidPath { .. }) + ), + "{bad} should have been rejected" + ); + } + } + + #[test] + fn the_purpose_element_is_readable_for_variant_cross_checks() { + assert_eq!(path::purpose("m/84'/0'/0'/0/0"), Some(84)); + assert_eq!(path::purpose("m/44'/0'/0'"), Some(44)); + assert_eq!(path::purpose("m"), None); + } +} + +// ============================================================================ +// Types +// ============================================================================ + +mod device_types { + use super::super::types::*; + use crate::onchain::AccountType; + + #[test] + fn networks_use_jades_own_names() { + assert_eq!(JadeNetwork::Mainnet.wire_name(), "mainnet"); + assert_eq!(JadeNetwork::Testnet.wire_name(), "testnet"); + // Jade calls regtest "localtest". + assert_eq!(JadeNetwork::Regtest.wire_name(), "localtest"); + } + + #[test] + fn account_types_map_to_descriptor_variants() { + let cases = [ + (AccountType::Legacy, JadeAddressVariant::Pkh, 44), + (AccountType::WrappedSegwit, JadeAddressVariant::ShWpkh, 49), + (AccountType::NativeSegwit, JadeAddressVariant::Wpkh, 84), + (AccountType::Taproot, JadeAddressVariant::Tr, 86), + ]; + for (account_type, expected, purpose) in cases { + let variant = JadeAddressVariant::from(account_type); + assert_eq!(variant, expected); + assert_eq!(variant.purpose(), purpose); + } + assert_eq!(JadeAddressVariant::Wpkh.wire_name(), "wpkh(k)"); + assert_eq!(JadeAddressVariant::ShWpkh.wire_name(), "sh(wpkh(k))"); + assert_eq!(JadeAddressVariant::Tr.wire_name(), "tr(k)"); + } + + #[test] + fn device_ids_carry_the_transport_so_paths_cannot_collide() { + // An Android USB host path and a Rust enumerated serial path can be the + // same string; without the prefix, connect could pick the wrong one. + let ble = JadeDeviceInfo::build_id(JadeTransportKind::Bluetooth, "AA:BB:CC"); + let serial = JadeDeviceInfo::build_id(JadeTransportKind::Serial, "AA:BB:CC"); + assert_ne!(ble, serial); + + assert_eq!( + JadeDeviceInfo::parse_id(&ble), + Some((JadeTransportKind::Bluetooth, "AA:BB:CC")) + ); + assert_eq!( + JadeDeviceInfo::parse_id("serial:/dev/tty.usbserial-1"), + Some((JadeTransportKind::Serial, "/dev/tty.usbserial-1")) + ); + assert_eq!(JadeDeviceInfo::parse_id("nonsense"), None); + assert_eq!(JadeDeviceInfo::parse_id("carrier:/dev/x"), None); + } + + #[test] + fn version_info_maps_from_the_screaming_snake_wire_shape() { + // Deriving Deserialize straight onto the FFI record would yield None for + // every field, since the wire uses JADE_VERSION rather than jade_version. + let wire = cbor_version_info("1.0.34", "LOCKED"); + let parsed: WireVersionInfo = ciborium::from_reader(wire.as_slice()).unwrap(); + let info = JadeVersionInfo::from(parsed); + + assert_eq!(info.jade_version, "1.0.34"); + assert_eq!(info.jade_state, JadeState::Locked); + assert_eq!(info.jade_networks.as_deref(), Some("TEST")); + assert_eq!(info.jade_has_pin, Some(true)); + assert_eq!(info.battery_status, Some(4)); + } + + #[test] + fn an_unrecognised_state_string_does_not_fail_the_decode() { + let wire = cbor_version_info("9.9.9", "SOMETHING_NEW"); + let parsed: WireVersionInfo = ciborium::from_reader(wire.as_slice()).unwrap(); + assert_eq!(JadeVersionInfo::from(parsed).jade_state, JadeState::Unknown); + } + + #[test] + fn every_documented_state_string_maps() { + for (wire, expected) in [ + ("UNINIT", JadeState::Uninit), + ("UNSAVED", JadeState::Unsaved), + ("LOCKED", JadeState::Locked), + ("READY", JadeState::Ready), + ("TEMP", JadeState::Temp), + ] { + let encoded = cbor_version_info("1.0.34", wire); + let parsed: WireVersionInfo = ciborium::from_reader(encoded.as_slice()).unwrap(); + assert_eq!(JadeVersionInfo::from(parsed).jade_state, expected); + } + } + + #[test] + fn ping_status_maps_from_the_wire_integer() { + assert_eq!(JadePingStatus::from_wire(0), JadePingStatus::Idle); + assert_eq!(JadePingStatus::from_wire(1), JadePingStatus::Busy); + assert_eq!( + JadePingStatus::from_wire(2), + JadePingStatus::AwaitingUserInput + ); + } + + fn cbor_version_info(version: &str, state: &str) -> Vec { + let text = |v: &str| ciborium::Value::Text(v.to_string()); + let value = ciborium::Value::Map(vec![ + (text("JADE_VERSION"), text(version)), + (text("JADE_STATE"), text(state)), + (text("JADE_NETWORKS"), text("TEST")), + (text("JADE_HAS_PIN"), ciborium::Value::Bool(true)), + (text("BOARD_TYPE"), text("JADE_V2")), + (text("BATTERY_STATUS"), ciborium::Value::Integer(4.into())), + ]); + let mut encoded = Vec::new(); + ciborium::into_writer(&value, &mut encoded).unwrap(); + encoded + } +} + +// ============================================================================ +// Transport and the request/reply loop +// +// Driven by a scripted mock device, so framing, correlation, fragment +// reassembly, cancellation and poisoning are all covered without hardware. +// ============================================================================ + +mod connection { + use super::super::errors::JadeError; + use super::super::transport::{JadeConnection, JadeTransport}; + use super::cbor_map; + use async_trait::async_trait; + use serde::Deserialize; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + pub(super) fn text(value: &str) -> ciborium::Value { + ciborium::Value::Text(value.to_string()) + } + + pub(super) fn int(value: i64) -> ciborium::Value { + ciborium::Value::Integer(value.into()) + } + + /// The fields of a request the mock needs in order to answer it. + #[derive(Debug, Deserialize)] + pub(super) struct SeenRequest { + pub(super) id: String, + pub(super) method: String, + } + + pub(super) type Responder = Box Vec + Send + Sync>; + + /// A scripted device. + /// + /// Each write consumes one responder, which builds the reply bytes from the + /// request that triggered it. Replies are queued and handed out by + /// `read_some` in whatever chunk sizes the test asked for, so a frame split + /// across reads is exercised end to end. + pub(super) struct MockTransport { + responders: Mutex>, + pending: Mutex>>, + writes: Mutex>>, + read_chunk: usize, + fail_next_read: AtomicBool, + closed: AtomicBool, + } + + impl MockTransport { + pub(super) fn new(responders: Vec) -> Arc { + Arc::new(Self { + responders: Mutex::new(responders.into()), + pending: Mutex::new(VecDeque::new()), + writes: Mutex::new(Vec::new()), + read_chunk: usize::MAX, + fail_next_read: AtomicBool::new(false), + closed: AtomicBool::new(false), + }) + } + + fn with_read_chunk(responders: Vec, read_chunk: usize) -> Arc { + let mut mock = Self { + responders: Mutex::new(responders.into()), + pending: Mutex::new(VecDeque::new()), + writes: Mutex::new(Vec::new()), + read_chunk, + fail_next_read: AtomicBool::new(false), + closed: AtomicBool::new(false), + }; + mock.read_chunk = read_chunk; + Arc::new(mock) + } + + pub(super) fn write_count(&self) -> usize { + self.writes.lock().unwrap().len() + } + + /// The raw bytes of the nth request, for byte level assertions. + pub(super) fn writes_for_test(&self, index: usize) -> Vec { + self.writes.lock().unwrap()[index].clone() + } + + pub(super) fn seen(&self, index: usize) -> SeenRequest { + let writes = self.writes.lock().unwrap(); + ciborium::from_reader(writes[index].as_slice()).unwrap() + } + } + + #[async_trait] + impl JadeTransport for MockTransport { + async fn write_all(&self, data: Vec) -> Result<(), JadeError> { + let request: SeenRequest = ciborium::from_reader(data.as_slice()) + .map_err(|error| JadeError::protocol(format!("mock: {error}")))?; + self.writes.lock().unwrap().push(data); + + if let Some(responder) = self.responders.lock().unwrap().pop_front() { + let reply = responder(&request); + if !reply.is_empty() { + self.pending.lock().unwrap().push_back(reply); + } + } + Ok(()) + } + + async fn read_some(&self, _timeout: Duration) -> Result, JadeError> { + if self.fail_next_read.swap(false, Ordering::SeqCst) { + return Err(JadeError::DeviceDisconnected); + } + let mut pending = self.pending.lock().unwrap(); + let Some(mut next) = pending.pop_front() else { + return Ok(Vec::new()); + }; + if self.read_chunk < next.len() { + let rest = next.split_off(self.read_chunk); + pending.push_front(rest); + } + Ok(next) + } + + async fn close(&self) -> Result<(), JadeError> { + self.closed.store(true, Ordering::SeqCst); + Ok(()) + } + } + + fn ok_reply(result: ciborium::Value) -> Responder { + Box::new(move |request: &SeenRequest| { + cbor_map(vec![("id", text(&request.id)), ("result", result.clone())]) + }) + } + + pub(super) fn connect(mock: Arc) -> (JadeConnection, Arc) { + let aborted = Arc::new(AtomicBool::new(false)); + let connection = JadeConnection::new(mock, Arc::clone(&aborted)); + (connection, aborted) + } + + #[tokio::test] + async fn a_request_gets_its_reply() { + let mock = MockTransport::new(vec![ok_reply(text("tpubDC"))]); + let (mut connection, _) = connect(Arc::clone(&mock)); + + let reply = connection + .exchange("get_xpub", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!( + super::super::protocol::result_text(&reply.into_result("1.0.0").unwrap()).unwrap(), + "tpubDC" + ); + assert_eq!(mock.seen(0).method, "get_xpub"); + } + + #[tokio::test] + async fn a_reply_arriving_one_byte_at_a_time_still_decodes() { + let mock = MockTransport::with_read_chunk(vec![ok_reply(text("tpubDC"))], 1); + let (mut connection, _) = connect(mock); + + let reply = connection + .exchange("get_xpub", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!( + super::super::protocol::result_text(&reply.into_result("1.0.0").unwrap()).unwrap(), + "tpubDC" + ); + } + + #[tokio::test] + async fn an_unsolicited_log_frame_is_skipped() { + // The device interleaves log frames with replies on the same stream. + let responder: Responder = Box::new(|request: &SeenRequest| { + let log = cbor_map(vec![( + "log", + ciborium::Value::Bytes(b"I (1) main: hello".to_vec()), + )]); + let reply = cbor_map(vec![("id", text(&request.id)), ("result", text("ok"))]); + [log, reply].concat() + }); + let mock = MockTransport::new(vec![responder]); + let (mut connection, _) = connect(mock); + + let reply = connection + .exchange("ping", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap(); + assert!(reply.result.is_some()); + } + + #[tokio::test] + async fn an_unattributed_error_resolves_the_request_instead_of_timing_out() { + // Jade answers with id "00" when it rejects a message before recovering + // its id. Discarding that would strand the caller until the deadline. + let responder: Responder = Box::new(|_: &SeenRequest| { + cbor_map(vec![ + ("id", text("00")), + ( + "error", + ciborium::Value::Map(vec![ + (text("code"), int(-32600)), + (text("message"), text("Invalid RPC Request message")), + ]), + ), + ]) + }); + let mock = MockTransport::new(vec![responder]); + let (mut connection, _) = connect(mock); + + let error = connection + .exchange("sign_psbt", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(matches!(error, JadeError::ProtocolError { .. })); + } + + #[tokio::test] + async fn a_multi_fragment_reply_is_reassembled_in_order() { + // sign_psbt splits long replies across get_extended_data calls. Each of + // those carries a fresh id while origid names the original request, so + // the id being matched changes every round. + let fragment = |bytes: Vec, seqnum: i64, seqlen: i64| -> Responder { + Box::new(move |request: &SeenRequest| { + cbor_map(vec![ + ("id", text(&request.id)), + ("result", ciborium::Value::Bytes(bytes.clone())), + ("seqnum", int(seqnum)), + ("seqlen", int(seqlen)), + ]) + }) + }; + let mock = MockTransport::new(vec![ + fragment(vec![1, 2, 3], 1, 3), + fragment(vec![4, 5, 6], 2, 3), + fragment(vec![7, 8], 3, 3), + ]); + let (mut connection, _) = connect(Arc::clone(&mock)); + + let payload = connection + .exchange_reassembled("sign_psbt", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap(); + + assert_eq!(payload, vec![1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(mock.write_count(), 3); + + // The follow-ups are get_extended_data, and each has its own id rather + // than reusing the original. + let original = mock.seen(0); + let second = mock.seen(1); + assert_eq!(second.method, "get_extended_data"); + assert_ne!(second.id, original.id); + assert_ne!(mock.seen(2).id, second.id); + } + + #[tokio::test] + async fn a_single_fragment_reply_needs_no_follow_up() { + let responder: Responder = Box::new(|request: &SeenRequest| { + cbor_map(vec![ + ("id", text(&request.id)), + ("result", ciborium::Value::Bytes(vec![9, 9])), + ("seqnum", int(1)), + ("seqlen", int(1)), + ]) + }); + let mock = MockTransport::new(vec![responder]); + let (mut connection, _) = connect(Arc::clone(&mock)); + + let payload = connection + .exchange_reassembled("sign_psbt", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(payload, vec![9, 9]); + assert_eq!(mock.write_count(), 1); + } + + #[tokio::test] + async fn a_fragment_with_the_wrong_sequence_number_is_rejected() { + let mock = MockTransport::new(vec![ + Box::new(|request: &SeenRequest| { + cbor_map(vec![ + ("id", text(&request.id)), + ("result", ciborium::Value::Bytes(vec![1])), + ("seqnum", int(1)), + ("seqlen", int(3)), + ]) + }), + Box::new(|request: &SeenRequest| { + cbor_map(vec![ + ("id", text(&request.id)), + ("result", ciborium::Value::Bytes(vec![2])), + ("seqnum", int(3)), // should be 2 + ("seqlen", int(3)), + ]) + }), + ]); + let (mut connection, _) = connect(mock); + + let error = connection + .exchange_reassembled("sign_psbt", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(matches!(error, JadeError::ProtocolError { .. })); + } + + #[tokio::test] + async fn a_transport_error_poisons_the_connection() { + // A failure mid frame leaves no way to find the next boundary, so the + // connection must refuse further work rather than desynchronise. + let mock = MockTransport::new(vec![ok_reply(text("never read"))]); + mock.fail_next_read.store(true, Ordering::SeqCst); + let (mut connection, _) = connect(Arc::clone(&mock)); + + let error = connection + .exchange("ping", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap_err(); + assert_eq!(error, JadeError::DeviceDisconnected); + + let next = connection + .exchange("ping", Option::<()>::None, Duration::from_secs(5)) + .await + .unwrap_err(); + assert_eq!(next, JadeError::DeviceDisconnected); + } + + #[tokio::test] + async fn cancelling_returns_promptly_rather_than_waiting_out_the_deadline() { + // Jade has no cancel RPC, so aborting is how the application implements + // a cancel button on a signing screen. A ten minute deadline must not + // mean a ten minute wait. + let mock = MockTransport::new(vec![Box::new(|_: &SeenRequest| Vec::new())]); + let (mut connection, aborted) = connect(mock); + aborted.store(true, Ordering::SeqCst); + + let started = std::time::Instant::now(); + let error = connection + .exchange("sign_psbt", Option::<()>::None, Duration::from_secs(600)) + .await + .unwrap_err(); + + assert_eq!(error, JadeError::UserCancelled); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[tokio::test] + async fn a_silent_device_times_out_without_spinning() { + let mock = MockTransport::new(vec![Box::new(|_: &SeenRequest| Vec::new())]); + let (mut connection, _) = connect(mock); + + let error = connection + .exchange("ping", Option::<()>::None, Duration::from_millis(200)) + .await + .unwrap_err(); + assert_eq!(error, JadeError::Timeout); + } + + #[tokio::test] + async fn a_stale_reply_does_not_satisfy_the_next_request() { + let responder: Responder = Box::new(|_: &SeenRequest| { + cbor_map(vec![("id", text("999")), ("result", text("stale"))]) + }); + let mock = MockTransport::new(vec![responder]); + let (mut connection, _) = connect(mock); + + let error = connection + .exchange("ping", Option::<()>::None, Duration::from_millis(200)) + .await + .unwrap_err(); + assert_eq!(error, JadeError::Timeout); + } +} + +// ============================================================================ +// Pinserver unlock +// ============================================================================ + +mod unlock { + use super::super::errors::JadeError; + use super::super::pinserver::{self, PinServerHttp}; + use super::super::types::JadeNetwork; + use super::cbor_map; + use super::connection::{connect, int, text, MockTransport, Responder, SeenRequest}; + use async_trait::async_trait; + use std::sync::{Arc, Mutex}; + + /// A pinserver that never touches the network. + struct FakePinServer { + response: Mutex, JadeError>>>, + calls: Mutex)>>, + } + + impl FakePinServer { + fn returning(body: &str) -> Arc { + Arc::new(Self { + response: Mutex::new(Some(Ok(body.as_bytes().to_vec()))), + calls: Mutex::new(Vec::new()), + }) + } + + fn failing() -> Arc { + Arc::new(Self { + response: Mutex::new(Some(Err(JadeError::PinServerError { + error_details: "network down".to_string(), + }))), + calls: Mutex::new(Vec::new()), + }) + } + } + + #[async_trait] + impl PinServerHttp for FakePinServer { + async fn request( + &self, + url: &str, + method: &str, + body: Option, + ) -> Result, JadeError> { + self.calls + .lock() + .unwrap() + .push((url.to_string(), method.to_string(), body)); + self.response + .lock() + .unwrap() + .take() + .unwrap_or(Ok(b"{}".to_vec())) + } + } + + /// An auth_user reply asking the host to call the pinserver. + fn http_request_reply(urls: Vec<&str>, on_reply: &str) -> Responder { + let urls: Vec = urls.into_iter().map(str::to_string).collect(); + let on_reply = on_reply.to_string(); + Box::new(move |request: &SeenRequest| { + let url_values = + ciborium::Value::Array(urls.iter().map(|url| text(url)).collect::>()); + let params = ciborium::Value::Map(vec![ + (text("urls"), url_values), + (text("method"), text("POST")), + (text("accept"), text("json")), + ( + text("data"), + ciborium::Value::Map(vec![(text("data"), text("cGF5bG9hZA=="))]), + ), + ]); + let http_request = ciborium::Value::Map(vec![ + (text("params"), params), + (text("on-reply"), text(&on_reply)), + ]); + cbor_map(vec![ + ("id", text(&request.id)), + ( + "result", + ciborium::Value::Map(vec![(text("http_request"), http_request)]), + ), + ]) + }) + } + + fn bool_reply(value: bool) -> Responder { + Box::new(move |request: &SeenRequest| { + cbor_map(vec![ + ("id", text(&request.id)), + ("result", ciborium::Value::Bool(value)), + ]) + }) + } + + #[tokio::test] + async fn an_already_unlocked_device_needs_no_pinserver_call() { + let mock = MockTransport::new(vec![bool_reply(true)]); + let (mut connection, _) = connect(Arc::clone(&mock)); + let http = FakePinServer::returning("{}"); + + pinserver::run_unlock( + &mut connection, + JadeNetwork::Testnet, + http.as_ref(), + 1_700_000_000, + ) + .await + .unwrap(); + + assert_eq!(mock.write_count(), 1); + assert_eq!(mock.seen(0).method, "auth_user"); + assert!(http.calls.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn a_locked_device_completes_the_pinserver_round_trip() { + let mock = MockTransport::new(vec![ + http_request_reply(vec!["https://jadepin.blockstream.com/get_pin"], "pin"), + bool_reply(true), + ]); + let (mut connection, _) = connect(Arc::clone(&mock)); + let http = FakePinServer::returning(r#"{"data":"YWJj"}"#); + + pinserver::run_unlock( + &mut connection, + JadeNetwork::Mainnet, + http.as_ref(), + 1_700_000_000, + ) + .await + .unwrap(); + + // The device saw auth_user then pin. + assert_eq!(mock.write_count(), 2); + assert_eq!(mock.seen(1).method, "pin"); + + // The payload was rendered as a JSON document, not forwarded as CBOR. + let calls = http.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "POST"); + assert_eq!(calls[0].2.as_deref(), Some(r#"{"data":"cGF5bG9hZA=="}"#)); + } + + #[tokio::test] + async fn a_wrong_pin_is_reported_as_such() { + let mock = MockTransport::new(vec![ + http_request_reply(vec!["https://jadepin.blockstream.com/get_pin"], "pin"), + bool_reply(false), + ]); + let (mut connection, _) = connect(mock); + let http = FakePinServer::returning(r#"{"data":"YWJj"}"#); + + let error = pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) + .await + .unwrap_err(); + assert_eq!(error, JadeError::InvalidPin); + } + + #[tokio::test] + async fn an_http_failure_still_sends_pin_so_the_device_stays_in_step() { + // The device blocks indefinitely waiting for a pin message. Abandoning + // the exchange would leave it consuming the next unrelated request as + // the awaited reply, putting every later call one message out of step. + let mock = MockTransport::new(vec![ + http_request_reply(vec!["https://jadepin.blockstream.com/get_pin"], "pin"), + bool_reply(false), + ]); + let (mut connection, _) = connect(Arc::clone(&mock)); + let http = FakePinServer::failing(); + + let error = pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) + .await + .unwrap_err(); + assert_eq!(error, JadeError::InvalidPin); + + assert_eq!(mock.write_count(), 2); + let sent = mock.seen(1); + assert_eq!(sent.method, "pin"); + + // And it carried no params at all, which is what signals the failure. + let writes_have_params = { + let raw: ciborium::Value = { + let writes = mock.writes_for_test(1); + ciborium::from_reader(writes.as_slice()).unwrap() + }; + match raw { + ciborium::Value::Map(entries) => entries + .iter() + .any(|(key, _)| key.as_text() == Some("params")), + _ => panic!("request was not a map"), + } + }; + assert!(!writes_have_params, "pin must be sent with no params"); + } + + #[tokio::test] + async fn a_device_naming_a_method_other_than_pin_is_rejected() { + // on-reply is device supplied. Dispatching on it blindly would let a + // device make the host invoke any RPC with params of its choosing. + let mock = MockTransport::new(vec![http_request_reply( + vec!["https://jadepin.blockstream.com/get_pin"], + "sign_psbt", + )]); + let (mut connection, _) = connect(mock); + let http = FakePinServer::returning("{}"); + + let error = pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) + .await + .unwrap_err(); + assert!(matches!(error, JadeError::ProtocolError { .. })); + } + + #[tokio::test] + async fn an_onion_url_is_skipped_in_favour_of_the_clearnet_one() { + // Firmware sends http://<...>.onion/get_pin, so a suffix test on the + // whole URL would not spot it. + let mock = MockTransport::new(vec![ + http_request_reply( + vec![ + "https://jadepin.blockstream.com/get_pin", + "http://abcdefghij.onion/get_pin", + ], + "pin", + ), + bool_reply(true), + ]); + let (mut connection, _) = connect(mock); + let http = FakePinServer::returning(r#"{"data":"YWJj"}"#); + + pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) + .await + .unwrap(); + + let calls = http.calls.lock().unwrap(); + assert_eq!(calls[0].0, "https://jadepin.blockstream.com/get_pin"); + } + + // ------------------------------------------------------------------ + // Value conversion + // ------------------------------------------------------------------ + + #[test] + fn cbor_and_json_round_trip_for_the_shapes_the_pinserver_uses() { + let cbor = ciborium::Value::Map(vec![ + (text("data"), text("cGF5bG9hZA==")), + (text("count"), int(3)), + (text("ok"), ciborium::Value::Bool(true)), + ]); + let json = pinserver::cbor_to_json(&cbor).unwrap(); + assert_eq!(json["data"], "cGF5bG9hZA=="); + assert_eq!(json["count"], 3); + assert_eq!(json["ok"], true); + + // Compare as sets of entries: serde_json sorts object keys while CBOR + // preserves insertion order, and Jade's docs state that named field + // order is unimportant. + let entries = |value: &ciborium::Value| -> Vec<(String, ciborium::Value)> { + let ciborium::Value::Map(entries) = value else { + panic!("expected a map"); + }; + let mut entries: Vec<(String, ciborium::Value)> = entries + .iter() + .map(|(key, value)| (key.as_text().unwrap().to_string(), value.clone())) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries + }; + let back = pinserver::json_to_cbor(&json).unwrap(); + assert_eq!(entries(&back), entries(&cbor)); + } + + #[test] + fn a_byte_string_is_not_silently_rendered_as_json() { + // The pinserver protocol carries binary as base64 text, so a raw byte + // string means the device sent something unexpected. + let cbor = ciborium::Value::Map(vec![(text("data"), ciborium::Value::Bytes(vec![1, 2]))]); + assert!(pinserver::cbor_to_json(&cbor).is_err()); + } +} + +// ============================================================================ +// Firmware version comparison +// ============================================================================ + +mod firmware { + use super::super::types::{version_at_least, MIN_JADE_FIRMWARE_TAPROOT}; + + #[test] + fn versions_compare_by_component_not_lexically() { + assert!(version_at_least("1.0.34", "1.0.34")); + assert!(version_at_least("1.0.41", "1.0.34")); + assert!(version_at_least("1.1.0", "1.0.34")); + assert!(!version_at_least("1.0.33", "1.0.34")); + assert!(!version_at_least("0.1.48", "1.0.34")); + // Lexically "1.0.9" sorts after "1.0.34", numerically it does not. + assert!(!version_at_least("1.0.9", "1.0.34")); + } + + #[test] + fn a_build_suffix_does_not_defeat_the_comparison() { + assert!(version_at_least("1.0.34-dirty", MIN_JADE_FIRMWARE_TAPROOT)); + assert!(version_at_least("1.0.35+ble", MIN_JADE_FIRMWARE_TAPROOT)); + } + + #[test] + fn an_unparsable_version_never_blocks_the_operation_by_itself() { + // The device stays the authority; it rejects what it cannot do. + assert!(!version_at_least("", "1.0.34")); + assert!(!version_at_least("nonsense", "1.0.34")); + } +} diff --git a/src/modules/jade/transport.rs b/src/modules/jade/transport.rs new file mode 100644 index 0000000..53ea594 --- /dev/null +++ b/src/modules/jade/transport.rs @@ -0,0 +1,382 @@ +//! Byte transport and the request/reply loop. +//! +//! `JadeTransport` is the internal seam: `CallbackTransport` drives the native +//! implementation, `SerialTransport` drives a serial port directly, and tests +//! substitute a scripted double. `JadeConnection` sits above it and owns the +//! read buffer, the request id counter and the correlation rules. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use serde::Serialize; + +use super::callbacks::{JadeTransportCallback, JadeTransportErrorCode}; +use super::errors::JadeError; +use super::protocol::{ + classify, decode_reply, encode_request, try_take_frame, JadeReply, ReplyMatch, RequestIds, +}; + +/// Bluetooth writes are capped here regardless of the reported MTU. +pub(crate) const MAX_CHUNK_BYTES: u32 = 509; + +/// How long a single `read_chunk` may block. +/// +/// Deliberately short. The long per-operation deadline is enforced by the loop +/// in `exchange`, so a user taking two minutes to confirm on the device does not +/// sit inside one uninterruptible native call. +const READ_CHUNK_TIMEOUT_MS: u32 = 250; + +/// Floor on the polling interval when a read returns nothing. +/// +/// A native implementation that returns immediately with no data would +/// otherwise turn the read loop into a busy spin that pins a blocking thread. +const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// A byte pipe to a device. +#[async_trait] +pub(crate) trait JadeTransport: Send + Sync { + /// Write a complete request. Implementations chunk as the transport needs. + /// + /// Takes ownership because callback and serial implementations both hand the + /// buffer to a blocking task, which needs a `'static` payload. + async fn write_all(&self, data: Vec) -> Result<(), JadeError>; + + /// Read whatever has arrived, waiting at most `timeout`. + /// + /// An empty vector means nothing arrived, which is not an error. + async fn read_some(&self, timeout: Duration) -> Result, JadeError>; + + /// Release the device. Safe to call more than once. + async fn close(&self) -> Result<(), JadeError>; +} + +fn code_to_error(code: Option, message: String) -> JadeError { + match code { + Some(JadeTransportErrorCode::DeviceBusy) => JadeError::DeviceBusy, + Some(JadeTransportErrorCode::NotConnected) => JadeError::NotConnected, + Some(JadeTransportErrorCode::Disconnected) => JadeError::DeviceDisconnected, + Some(JadeTransportErrorCode::Timeout) => JadeError::Timeout, + Some(JadeTransportErrorCode::PermissionDenied) | None => JadeError::TransportError { + error_details: message, + }, + } +} + +/// Transport backed by the native application. +pub(crate) struct CallbackTransport { + callback: Arc, + path: String, + chunk_size: usize, +} + +impl CallbackTransport { + pub(crate) fn new(callback: Arc, path: String) -> Self { + // Clamp whatever the native layer reports. A zero would make the write + // loop fail to advance, and anything above the Bluetooth cap would be + // rejected by the link layer. + let reported = callback.get_chunk_size(path.clone()); + let chunk_size = reported.clamp(1, MAX_CHUNK_BYTES) as usize; + Self { + callback, + path, + chunk_size, + } + } +} + +#[async_trait] +impl JadeTransport for CallbackTransport { + async fn write_all(&self, data: Vec) -> Result<(), JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + let chunk_size = self.chunk_size; + + // Foreign callbacks are synchronous and can block. Running them on a + // worker thread would park it for the duration; the blocking pool is + // sized for exactly this. + tokio::task::spawn_blocking(move || { + for chunk in data.chunks(chunk_size) { + let result = callback.write_chunk(path.clone(), chunk.to_vec()); + if !result.success { + return Err(code_to_error(result.error_code, result.error)); + } + } + Ok(()) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("write task failed: {error}"), + })? + } + + async fn read_some(&self, timeout: Duration) -> Result, JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + let timeout_ms = timeout.as_millis().min(u128::from(u32::MAX)) as u32; + + tokio::task::spawn_blocking(move || { + let result = callback.read_chunk(path, timeout_ms); + if !result.success { + return Err(code_to_error(result.error_code, result.error)); + } + Ok(result.data) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("read task failed: {error}"), + })? + } + + async fn close(&self) -> Result<(), JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + tokio::task::spawn_blocking(move || { + let result = callback.close_device(path); + if !result.success { + return Err(code_to_error(result.error_code, result.error)); + } + Ok(()) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("close task failed: {error}"), + })? + } +} + +/// A request/reply session over one transport. +pub(crate) struct JadeConnection { + transport: Arc, + buffer: Vec, + ids: RequestIds, + /// Set when the stream can no longer be trusted. A framing failure or a + /// transport error leaves no way to find the next frame boundary, so the + /// connection refuses further work rather than returning confusing errors + /// far from the real cause. + poisoned: bool, + aborted: Arc, + min_firmware: String, +} + +impl JadeConnection { + pub(crate) fn new(transport: Arc, aborted: Arc) -> Self { + Self { + transport, + buffer: Vec::new(), + ids: RequestIds::new(), + poisoned: false, + aborted, + min_firmware: super::types::MIN_JADE_FIRMWARE.to_string(), + } + } + + fn check_usable(&self) -> Result<(), JadeError> { + if self.poisoned { + return Err(JadeError::DeviceDisconnected); + } + if self.aborted.load(Ordering::SeqCst) { + return Err(JadeError::UserCancelled); + } + Ok(()) + } + + /// Mark the stream unusable and drop anything half read. + fn poison(&mut self) { + self.poisoned = true; + self.buffer.clear(); + } + + /// Send a request and wait for its reply. + pub(crate) async fn exchange( + &mut self, + method: &str, + params: Option

, + timeout: Duration, + ) -> Result { + self.check_usable()?; + + let id = self.ids.next_id(); + let request = encode_request(&id, method, params)?; + log::debug!("[jade] -> {method} id={id} ({} bytes)", request.len()); + + if let Err(error) = self.transport.write_all(request).await { + self.poison(); + return Err(error); + } + + self.await_reply(&id, method, timeout).await + } + + /// Wait for the reply to `id`, discarding log frames and stale replies. + async fn await_reply( + &mut self, + id: &str, + method: &str, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + + loop { + // Drain everything already buffered before reading again, so two + // frames arriving in one read are both seen. + loop { + let frame = match try_take_frame(&mut self.buffer) { + Ok(Some(frame)) => frame, + Ok(None) => break, + Err(error) => { + self.poison(); + return Err(error); + } + }; + + let reply = match decode_reply(&frame) { + Ok(reply) => reply, + Err(error) => { + self.poison(); + return Err(error); + } + }; + + match classify(reply, id) { + ReplyMatch::Matched(reply) => { + log::debug!("[jade] <- {method} id={id}"); + return Ok(reply); + } + ReplyMatch::Unattributed(error) => { + // The device rejected the message before it could + // recover the id. This is terminal for the request in + // flight; ignoring it would strand the caller until the + // deadline. + log::debug!("[jade] <- {method} unattributed error {}", error.code); + return Err(JadeError::from_rpc( + error.code, + error.message, + &self.min_firmware, + )); + } + ReplyMatch::Ignore => continue, + } + } + + if self.aborted.load(Ordering::SeqCst) { + self.poison(); + return Err(JadeError::UserCancelled); + } + let now = Instant::now(); + if now >= deadline { + self.poison(); + return Err(JadeError::Timeout); + } + + let remaining = deadline - now; + let slice = remaining.min(Duration::from_millis(u64::from(READ_CHUNK_TIMEOUT_MS))); + let chunk = match self.transport.read_some(slice).await { + Ok(chunk) => chunk, + Err(error) => { + self.poison(); + return Err(error); + } + }; + + if chunk.is_empty() { + // Nothing yet. Yield so a native implementation that returns + // immediately cannot spin a blocking thread at full tilt. + tokio::time::sleep(IDLE_POLL_INTERVAL.min(remaining)).await; + } else { + self.buffer.extend_from_slice(&chunk); + } + } + } + + /// Send a request whose reply may arrive in `seqnum`/`seqlen` fragments and + /// return the concatenated bytes. + /// + /// Fragments are fetched with `get_extended_data`. Each of those carries its + /// own fresh request id while `origid` names the original request, so the id + /// being matched changes on every round. `seqnum` must advance by exactly + /// one and `seqlen` must be echoed unchanged, or the device aborts with a + /// protocol error. + /// + /// Any failure part way through poisons the connection: the device stays + /// blocked waiting for the next fragment request, so the link has to be torn + /// down rather than reused. + pub(crate) async fn exchange_reassembled( + &mut self, + method: &str, + params: Option

, + timeout: Duration, + ) -> Result, JadeError> { + self.check_usable()?; + + let origid = self.ids.next_id(); + let request = encode_request(&origid, method, params)?; + log::debug!("[jade] -> {method} id={origid} ({} bytes)", request.len()); + if let Err(error) = self.transport.write_all(request).await { + self.poison(); + return Err(error); + } + + let reply = self.await_reply(&origid, method, timeout).await?; + let seqlen = reply.seqlen.unwrap_or(1).max(1); + let mut seqnum = reply.seqnum.unwrap_or(1); + let mut payload = super::protocol::result_bytes(&reply.into_result(&self.min_firmware)?)?; + + if seqlen > 1 { + log::debug!("[jade] {method} reply spans {seqlen} fragments"); + } + + while seqnum < seqlen { + let next = seqnum + 1; + let fragment = self + .fetch_fragment(&origid, method, next, seqlen, timeout) + .await + .inspect_err(|_| { + // Leaving the device mid-stream desynchronises it; the + // connection cannot be reused. + self.poisoned = true; + })?; + payload.extend_from_slice(&fragment); + seqnum = next; + } + + Ok(payload) + } + + async fn fetch_fragment( + &mut self, + origid: &str, + orig: &str, + seqnum: u32, + seqlen: u32, + timeout: Duration, + ) -> Result, JadeError> { + #[derive(Serialize)] + struct ExtendedDataParams<'a> { + origid: &'a str, + orig: &'a str, + seqnum: u32, + seqlen: u32, + } + + let params = ExtendedDataParams { + origid, + orig, + seqnum, + seqlen, + }; + let reply = self + .exchange("get_extended_data", Some(params), timeout) + .await?; + + if let Some(reported) = reply.seqnum { + if reported != seqnum { + return Err(JadeError::protocol(format!( + "expected fragment {seqnum}, device sent {reported}" + ))); + } + } + super::protocol::result_bytes(&reply.into_result(&self.min_firmware)?) + } +} diff --git a/src/modules/jade/types.rs b/src/modules/jade/types.rs new file mode 100644 index 0000000..e809ed6 --- /dev/null +++ b/src/modules/jade/types.rs @@ -0,0 +1,372 @@ +//! FFI-compatible types for the Jade module. +//! +//! The records here are the shapes the bindings see. Wire shapes stay private: +//! Jade's `get_version_info` reply uses SCREAMING_SNAKE keys and a string state, +//! so deriving `Deserialize` straight onto the FFI record would silently yield +//! nothing but `None`. + +use serde::Deserialize; + +use crate::onchain::AccountType; + +/// The oldest firmware this module targets. +/// +/// Single-signature `get_receive_address` and `sign_psbt` were added during the +/// 0.1.x series. The device is the authority here: an older unit answers +/// `UNKNOWN_METHOD`, which maps to `JadeError::UnsupportedFirmware`, so this +/// constant is advisory and only improves the message. +pub(crate) const MIN_JADE_FIRMWARE: &str = "0.1.48"; + +/// Taproot address support landed in 1.0.34. +pub(crate) const MIN_JADE_FIRMWARE_TAPROOT: &str = "1.0.34"; + +/// Compare two dotted version strings. +/// +/// Returns false when either side cannot be parsed, so an unrecognised version +/// string never blocks an operation the device might well support. The device +/// remains the authority: it answers `BAD_PARAMETERS` for a variant it does not +/// know, and this check only turns that into a clearer message. +pub(crate) fn version_at_least(installed: &str, required: &str) -> bool { + fn parts(version: &str) -> Option<(u32, u32, u32)> { + let trimmed = version + .trim() + .split(|c: char| !c.is_ascii_digit() && c != '.') + .next()?; + let mut fields = trimmed.split('.').map(str::parse::); + let major = fields.next()?.ok()?; + let minor = fields.next().transpose().ok()?.unwrap_or(0); + let patch = fields.next().transpose().ok()?.unwrap_or(0); + Some((major, minor, patch)) + } + + match (parts(installed), parts(required)) { + (Some(installed), Some(required)) => installed >= required, + _ => false, + } +} + +/// The Bitcoin networks Jade recognises. +/// +/// Jade has no signet, so there are exactly three. Its regtest is named +/// `localtest` on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum JadeNetwork { + Mainnet, + Testnet, + Regtest, +} + +impl JadeNetwork { + pub(crate) fn wire_name(self) -> &'static str { + match self { + JadeNetwork::Mainnet => "mainnet", + JadeNetwork::Testnet => "testnet", + JadeNetwork::Regtest => "localtest", + } + } + + /// The BIP44 coin type this network derives under. + pub(crate) fn coin_type(self) -> u32 { + match self { + JadeNetwork::Mainnet => 0, + JadeNetwork::Testnet | JadeNetwork::Regtest => 1, + } + } +} + +impl From for bitcoin::Network { + fn from(network: JadeNetwork) -> Self { + match network { + JadeNetwork::Mainnet => bitcoin::Network::Bitcoin, + JadeNetwork::Testnet => bitcoin::Network::Testnet, + JadeNetwork::Regtest => bitcoin::Network::Regtest, + } + } +} + +/// How the host reaches a particular device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum JadeTransportKind { + Bluetooth, + Serial, +} + +impl JadeTransportKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + JadeTransportKind::Bluetooth => "ble", + JadeTransportKind::Serial => "serial", + } + } + + pub(crate) fn from_str(value: &str) -> Option { + match value { + "ble" => Some(JadeTransportKind::Bluetooth), + "serial" => Some(JadeTransportKind::Serial), + _ => None, + } + } +} + +/// The single-signature descriptor variants Jade accepts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum JadeAddressVariant { + Pkh, + Wpkh, + ShWpkh, + Tr, +} + +impl JadeAddressVariant { + pub(crate) fn wire_name(self) -> &'static str { + match self { + JadeAddressVariant::Pkh => "pkh(k)", + JadeAddressVariant::Wpkh => "wpkh(k)", + JadeAddressVariant::ShWpkh => "sh(wpkh(k))", + JadeAddressVariant::Tr => "tr(k)", + } + } + + /// The BIP44 purpose this variant is derived under. + pub(crate) fn purpose(self) -> u32 { + match self { + JadeAddressVariant::Pkh => 44, + JadeAddressVariant::ShWpkh => 49, + JadeAddressVariant::Wpkh => 84, + JadeAddressVariant::Tr => 86, + } + } +} + +impl From for JadeAddressVariant { + fn from(account_type: AccountType) -> Self { + match account_type { + AccountType::Legacy => JadeAddressVariant::Pkh, + AccountType::WrappedSegwit => JadeAddressVariant::ShWpkh, + AccountType::NativeSegwit => JadeAddressVariant::Wpkh, + AccountType::Taproot => JadeAddressVariant::Tr, + } + } +} + +/// The device's wallet state, as reported by `get_version_info`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum JadeState { + /// No wallet. Setup has to be completed on the device itself. + Uninit, + /// A wallet exists but has not been persisted with a PIN. + Unsaved, + /// A wallet exists and is PIN locked. Call `jade_unlock`. + Locked, + /// Unlocked and usable. + Ready, + /// A temporary wallet session is active. + Temp, + /// Firmware reported a state this version does not know about. + Unknown, +} + +impl JadeState { + fn from_wire(value: &str) -> Self { + match value { + "UNINIT" => JadeState::Uninit, + "UNSAVED" => JadeState::Unsaved, + "LOCKED" => JadeState::Locked, + "READY" => JadeState::Ready, + "TEMP" => JadeState::Temp, + _ => JadeState::Unknown, + } + } +} + +/// The result of `ping`. +/// +/// Modelled as an enum rather than the raw `u8` the device sends. It documents +/// the three states, and it keeps this module clear of unsigned 8 and 16 bit +/// FFI returns, which needed a binding-generator fix to work on Android ARM32. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum JadePingStatus { + Idle, + Busy, + AwaitingUserInput, +} + +impl JadePingStatus { + pub(crate) fn from_wire(value: u64) -> Self { + match value { + 0 => JadePingStatus::Idle, + 1 => JadePingStatus::Busy, + _ => JadePingStatus::AwaitingUserInput, + } + } +} + +/// A device discovered by a scan. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct JadeDeviceInfo { + /// Stable identifier passed to `jade_connect`. + /// + /// Formed as `{transport}:{path}` so an Android USB host path and a Rust + /// enumerated serial path cannot collide and send a connect to the wrong + /// transport. + pub id: String, + pub transport: JadeTransportKind, + /// Advertised or descriptor name, for example "Jade C0FFEE". + pub name: Option, + /// Transport specific address: a BLE identifier or a serial device path. + pub path: String, + pub serial_number: Option, +} + +impl JadeDeviceInfo { + pub(crate) fn build_id(transport: JadeTransportKind, path: &str) -> String { + format!("{}:{}", transport.as_str(), path) + } + + /// Split an id back into its transport and path. + pub(crate) fn parse_id(id: &str) -> Option<(JadeTransportKind, &str)> { + let (kind, path) = id.split_once(':')?; + Some((JadeTransportKind::from_str(kind)?, path)) + } +} + +/// Device firmware and state summary. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct JadeVersionInfo { + pub jade_version: String, + pub jade_state: JadeState, + /// "ALL", "MAIN" or "TEST": which networks this unit is locked to. + pub jade_networks: Option, + pub jade_has_pin: Option, + pub board_type: Option, + pub jade_config: Option, + pub jade_features: Option, + pub idf_version: Option, + pub chip_features: Option, + pub efuse_mac: Option, + /// Battery bucket, 0 to 5. Widened from the wire's small integer so this + /// crate exposes no unsigned 8 bit types over FFI. + pub battery_status: Option, + pub jade_ota_max_chunk: Option, +} + +/// The wire shape of `get_version_info`, kept separate from the FFI record. +#[derive(Debug, Deserialize)] +pub(crate) struct WireVersionInfo { + #[serde(rename = "JADE_VERSION")] + pub jade_version: Option, + #[serde(rename = "JADE_STATE")] + pub jade_state: Option, + #[serde(rename = "JADE_NETWORKS")] + pub jade_networks: Option, + #[serde(rename = "JADE_HAS_PIN")] + pub jade_has_pin: Option, + #[serde(rename = "BOARD_TYPE")] + pub board_type: Option, + #[serde(rename = "JADE_CONFIG")] + pub jade_config: Option, + #[serde(rename = "JADE_FEATURES")] + pub jade_features: Option, + #[serde(rename = "IDF_VERSION")] + pub idf_version: Option, + #[serde(rename = "CHIP_FEATURES")] + pub chip_features: Option, + #[serde(rename = "EFUSEMAC")] + pub efuse_mac: Option, + #[serde(rename = "BATTERY_STATUS")] + pub battery_status: Option, + #[serde(rename = "JADE_OTA_MAX_CHUNK")] + pub jade_ota_max_chunk: Option, +} + +impl From for JadeVersionInfo { + fn from(wire: WireVersionInfo) -> Self { + JadeVersionInfo { + jade_version: wire.jade_version.unwrap_or_default(), + jade_state: wire + .jade_state + .as_deref() + .map(JadeState::from_wire) + .unwrap_or(JadeState::Unknown), + jade_networks: wire.jade_networks, + jade_has_pin: wire.jade_has_pin, + board_type: wire.board_type, + jade_config: wire.jade_config, + jade_features: wire.jade_features, + idf_version: wire.idf_version, + chip_features: wire.chip_features, + efuse_mac: wire.efuse_mac, + battery_status: wire.battery_status, + jade_ota_max_chunk: wire.jade_ota_max_chunk, + } + } +} + +/// An extended public key, echoed back with the request it answers. +/// +/// The path and fingerprint travel with the key so the caller can confirm the +/// device answered the question that was asked. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct JadeXpubResponse { + pub xpub: String, + pub derivation_path: String, + /// Master fingerprint, eight lowercase hex characters. + pub master_fingerprint: String, +} + +/// One account within an export. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct JadeAccount { + pub account_type: AccountType, + pub xpub: String, + pub derivation_path: String, +} + +/// A multi-account export, shaped like `PassportAccountExport` so applications +/// have one import path for both signers. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct JadeAccountExport { + pub master_fingerprint: String, + pub account_index: u32, + pub accounts: Vec, +} + +/// A signed message, with the address that verifies it. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct JadeSignedMessage { + /// Base64 encoded recoverable signature. + pub signature: String, + /// Address derived from the signing path, for verification. + pub address: String, + pub derivation_path: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeGetXpubParams { + pub network: JadeNetwork, + pub derivation_path: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeVerifyAddressParams { + pub network: JadeNetwork, + pub variant: JadeAddressVariant, + pub derivation_path: String, + /// The address the application is about to display. The device is asked to + /// show its own derivation, and the two are compared. + pub expected_address: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeSignMessageParams { + pub derivation_path: String, + pub message: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeSignPsbtParams { + pub network: JadeNetwork, + /// Base64 encoded PSBT. The signed PSBT comes back base64 encoded too, so + /// it feeds straight into `finalize_psbt`. + pub psbt: String, +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 3872dc2..33dc643 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -2,6 +2,7 @@ pub mod activity; pub mod blocktank; pub mod boltz; pub mod hardware_wallet; +pub mod jade; pub mod lnurl; pub mod onchain; pub mod pubky; From 15fb3781b3ea7cc1caaa65f2b330640706391296 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 3 Sep 2026 13:43:56 -0400 Subject: [PATCH 2/3] refactor(jade): move the protocol into the jade-client-rs crate 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. --- CHANGELOG.md | 2 +- Cargo.lock | 27 +- Cargo.toml | 16 +- src/lib.rs | 69 +- src/modules/jade/README.md | 254 ++---- src/modules/jade/callbacks.rs | 136 ++- src/modules/jade/errors.rs | 165 ---- src/modules/jade/implementation.rs | 818 ++++------------- src/modules/jade/mod.rs | 36 +- src/modules/jade/path.rs | 100 -- src/modules/jade/pinserver.rs | 471 ---------- src/modules/jade/protocol.rs | 222 ----- src/modules/jade/serial.rs | 172 ---- src/modules/jade/tests.rs | 1355 +++------------------------- src/modules/jade/transport.rs | 382 -------- src/modules/jade/types.rs | 358 ++------ 16 files changed, 664 insertions(+), 3919 deletions(-) delete mode 100644 src/modules/jade/errors.rs delete mode 100644 src/modules/jade/path.rs delete mode 100644 src/modules/jade/pinserver.rs delete mode 100644 src/modules/jade/protocol.rs delete mode 100644 src/modules/jade/serial.rs delete mode 100644 src/modules/jade/transport.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c7e68..1fe1dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add Blockstream Jade hardware wallet support: device discovery, connect, PIN unlock via the blind pinserver, extended public key and account export, on-device address verification, message signing, and PSBT signing, over Bluetooth on every platform and USB CDC serial on desktop and Python. Signed PSBTs feed the existing `finalize_psbt` path. +- Add Blockstream Jade hardware wallet support: device discovery, connect, PIN unlock via the blind pinserver, extended public key and account export, on-device address verification, message signing, and PSBT signing, over Bluetooth on every platform and USB CDC serial on desktop and Python. Signed PSBTs feed the existing `finalize_psbt` path. The protocol lives in the `jade-client-rs` crate; this repo carries the UniFFI adapter. - Add `HardwareWalletVendor.Blockstream` and catalog entries for Jade and Jade Plus. Note that adding an enum case makes exhaustive Kotlin `when` and Swift `switch` statements over `HardwareWalletVendor` non-exhaustive, which is source breaking for consumers. ## 0.5.14 - 2026-09-02 diff --git a/Cargo.lock b/Cargo.lock index 167d285..2ee4659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,8 +601,8 @@ dependencies = [ "boltz-client", "btleplug", "chrono", - "ciborium", "hex", + "jade-client-rs", "jni", "lazy-regex", "lightning-invoice 0.32.0", @@ -623,10 +623,8 @@ dependencies = [ "rust-blocktank-client", "rust_decimal", "serde", - "serde_bytes", "serde_json", "serial_test", - "serialport", "tempfile", "test-case", "thiserror 2.0.18", @@ -2546,6 +2544,29 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jade-client-rs" +version = "0.1.0" +source = "git+https://github.com/coreyphillips/jade-client-rs?rev=ea260cb#ea260cb62594bc7d11b74211dbb55b179ecf365a" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bitcoin 0.32.8", + "ciborium", + "log", + "minicbor", + "rand 0.8.5", + "reqwest", + "serde", + "serde_bytes", + "serde_json", + "serialport", + "thiserror 2.0.18", + "tokio", + "url", + "zeroize", +] + [[package]] name = "jiff" version = "0.2.29" diff --git a/Cargo.toml b/Cargo.toml index 183f7b9..201dc1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,11 +40,6 @@ bdk = { version = "0.30.2", features = ["all-keys"] } boltz-client = { version = "0.4.1", default-features = false, features = ["electrum", "ws"] } base64 = "0.22" minicbor = { version = "2", features = ["alloc"] } -# Jade speaks CBOR with string-keyed, dynamically shaped maps. ciborium is serde-backed; -# minicbor is index-keyed and is used here only for incremental frame detection. -ciborium = "0.2" -# Encodes Vec fields as CBOR byte strings rather than arrays of integers, which Jade requires. -serde_bytes = "0.11" ur = "0.5.2" log = "0.4" pubky = "0.6.0" @@ -67,10 +62,15 @@ trezor-connect-rs = { version = "0.4.0", default-features = false, features = [" jni = "0.19" android_logger = "0.14" -# Jade USB CDC serial, desktop and Python only. -# default-features = false drops the libudev C dependency that CI does not install. +# Jade hardware wallet protocol. Bluetooth is driven by the native application +# through JadeTransportCallback, so the crate's own serial transport is only +# wanted where a Rust side serial port makes sense. +[target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies] +jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "ea260cb", default-features = false, features = ["reqwest-pinserver"] } + +# Desktop and Python additionally get the crate's serial transport. [target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] -serialport = { version = "4.10", default-features = false } +jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "ea260cb", features = ["reqwest-pinserver", "serial"] } [dev-dependencies] tokio = { version = "1.40.0", features = ["full"] } diff --git a/src/lib.rs b/src/lib.rs index c1911ec..e3ef2b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,10 +49,9 @@ pub use crate::modules::hardware_wallet::{ use crate::modules::jade::JadeManager; pub use crate::modules::jade::{ jade_set_transport_callback, JadeAccount, JadeAccountExport, JadeAddressVariant, - JadeDeviceInfo, JadeError, JadeGetXpubParams, JadeNativeDevice, JadeNetwork, JadePingStatus, - JadeSignMessageParams, JadeSignPsbtParams, JadeSignedMessage, JadeState, JadeTransportCallback, - JadeTransportErrorCode, JadeTransportKind, JadeTransportReadResult, JadeTransportResult, - JadeVerifyAddressParams, JadeVersionInfo, JadeXpubResponse, + JadeDeviceInfo, JadeError, JadeNativeDevice, JadeNetwork, JadePingStatus, JadeSignedMessage, + JadeState, JadeTransportCallback, JadeTransportErrorCode, JadeTransportKind, + JadeTransportReadResult, JadeTransportResult, JadeVersionInfo, JadeXpubResponse, }; use crate::modules::pubky::{PubkyAuthDetails, PubkyAuthKind, PubkyError, PubkyProfile}; use crate::modules::trezor::account_type_to_script_type; @@ -2649,9 +2648,12 @@ pub async fn jade_list_devices() -> Vec { /// `Ready` means the device is already usable, and `Uninit` means the user must /// create or restore a wallet on the device itself. #[uniffi::export] -pub async fn jade_connect(device_id: String) -> Result { +pub async fn jade_connect( + transport: JadeTransportKind, + path: String, +) -> Result { let rt = ensure_runtime(); - rt.spawn(async move { get_jade_manager().connect(&device_id).await }) + rt.spawn(async move { get_jade_manager().connect(transport, &path).await }) .await .unwrap_or_else(|e| { Err(JadeError::IoError { @@ -2782,9 +2784,12 @@ pub async fn jade_logout() -> Result<(), JadeError> { /// Fetch an extended public key, echoed back with the path and fingerprint. #[uniffi::export] -pub async fn jade_get_xpub(params: JadeGetXpubParams) -> Result { +pub async fn jade_get_xpub( + network: JadeNetwork, + derivation_path: String, +) -> Result { let rt = ensure_runtime(); - rt.spawn(async move { get_jade_manager().get_xpub(params).await }) + rt.spawn(async move { get_jade_manager().get_xpub(network, derivation_path).await }) .await .unwrap_or_else(|e| { Err(JadeError::IoError { @@ -2840,31 +2845,45 @@ pub async fn jade_get_account_export( /// rather than a way to fetch an address. Returns `AddressMismatch` when the /// device disagrees with `expected_address`. #[uniffi::export] -pub async fn jade_verify_address(params: JadeVerifyAddressParams) -> Result<(), JadeError> { +pub async fn jade_verify_address( + network: JadeNetwork, + variant: JadeAddressVariant, + derivation_path: String, + expected_address: String, +) -> Result<(), JadeError> { let rt = ensure_runtime(); - rt.spawn(async move { get_jade_manager().verify_address(params).await }) - .await - .unwrap_or_else(|e| { - Err(JadeError::IoError { - error_details: format!("Runtime error: {}", e), - }) + rt.spawn(async move { + get_jade_manager() + .verify_address(network, variant, derivation_path, expected_address) + .await + }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), }) + }) } /// Sign a message, returning the signature with the address that verifies it. #[uniffi::export] pub async fn jade_sign_message( - params: JadeSignMessageParams, network: JadeNetwork, + derivation_path: String, + message: String, ) -> Result { let rt = ensure_runtime(); - rt.spawn(async move { get_jade_manager().sign_message(params, network).await }) - .await - .unwrap_or_else(|e| { - Err(JadeError::IoError { - error_details: format!("Runtime error: {}", e), - }) + rt.spawn(async move { + get_jade_manager() + .sign_message(network, derivation_path, message) + .await + }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), }) + }) } /// Sign a PSBT, returning the signed PSBT base64 encoded. @@ -2873,9 +2892,9 @@ pub async fn jade_sign_message( /// result to `finalize_psbt` with the original PSBT, then broadcast with /// `onchain_broadcast_raw_tx`. #[uniffi::export] -pub async fn jade_sign_psbt(params: JadeSignPsbtParams) -> Result { +pub async fn jade_sign_psbt(network: JadeNetwork, psbt: String) -> Result { let rt = ensure_runtime(); - rt.spawn(async move { get_jade_manager().sign_psbt(params).await }) + rt.spawn(async move { get_jade_manager().sign_psbt(network, psbt).await }) .await .unwrap_or_else(|e| { Err(JadeError::IoError { @@ -2887,7 +2906,7 @@ pub async fn jade_sign_psbt(params: JadeSignPsbtParams) -> Result JadeAddressVariant { - JadeAddressVariant::from(account_type) + crate::modules::jade::account_type_to_variant(account_type) } // ============================================================================ diff --git a/src/modules/jade/README.md b/src/modules/jade/README.md index 7e28fe6..4ab201c 100644 --- a/src/modules/jade/README.md +++ b/src/modules/jade/README.md @@ -3,143 +3,81 @@ Blockstream Jade support for bitkit-core, over Bluetooth (all platforms) and USB CDC serial (desktop and Python). Bitcoin single signature only. -Unlike the `trezor` module, which adapts the external `trezor-connect-rs` crate, -there is no Rust crate for Jade, so the protocol is implemented here. +The protocol itself lives in +[`jade-client-rs`](https://github.com/coreyphillips/jade-client-rs). This module +is the FFI adapter. For the wire format, the pinserver exchange, PSBT checks and +the transport contract, read that crate's documentation; what follows is only +what is specific to bitkit-core. ## Architecture ``` ┌──────────────────────────────────────────────────────────────────────┐ │ bitkit-android / bitkit-ios │ -│ JadeTransport.kt / JadeTransport.swift │ -│ implements JadeTransportCallback: BLE, and USB host on Android │ +│ implements JadeTransportCallback: BLE, and USB host on Android │ └───────────────────────────────┬──────────────────────────────────────┘ │ UniFFI ┌───────────────────────────────▼──────────────────────────────────────┐ │ bitkit-core │ -│ lib.rs jade_* exports over a global JadeManager │ -│ implementation.rs session state, one connection, abort handling │ -│ pinserver.rs auth_user -> http_request -> pin, over reqwest │ -│ transport.rs JadeConnection: framing, correlation, reassembly │ -│ protocol.rs pure CBOR framing and envelopes │ -│ serial.rs Rust serial transport (desktop and Python only) │ +│ lib.rs jade_* exports over a global JadeManager │ +│ implementation.rs session lock, device list, abort handle │ +│ callbacks.rs JadeTransportCallback + bridge to JadeTransport │ +│ types.rs #[uniffi::remote] scaffolding for crate types │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ +┌───────────────────────────────▼──────────────────────────────────────┐ +│ jade-client-rs │ +│ CBOR protocol, pinserver, PSBT checks, serial transport │ └──────────────────────────────────────────────────────────────────────┘ ``` -## Wire protocol - -CBOR maps written back to back with no length prefix and no framing bytes. -Requests are `{"id", "method", "params"}`; replies are `{"id", "result"}` or -`{"id", "error": {"code", "message"}}`. The device also emits unsolicited -`{"log": ...}` frames with no `id`, which are skipped. - -Because CBOR is self delimiting, the reader buffers bytes and attempts an -incremental decode after each read. `protocol::try_take_frame` uses -`minicbor::Decoder::skip()` for that, because it reports an exact consumed byte -count; `ciborium` then deserializes the complete frame. - -Three rules that are easy to get wrong: - -- **Binary fields must be CBOR byte strings.** serde encodes a plain `Vec` as - an array of integers, and Jade reads `psbt` and `entropy` with - `rpc_get_bytes_ptr`, which requires major type 2. Every binary field carries - `#[serde(with = "serde_bytes")]`. A test asserts the encoded header byte. -- **Absent params are omitted, not encoded as null.** Jade's typed getters treat - a null as missing and then fail with `BAD_PARAMETERS`. -- **Replies with id `"00"` are terminal errors, not stray frames.** Jade uses that - id when it rejects a message before recovering the real one, for example an - oversize or malformed request. Discarding them would turn every such rejection - into a full length timeout. - -## Native transport contract - -Jade advertises the Nordic UART Service: - -| Role | UUID | -|---|---| -| Service | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | -| Write (host to Jade) | `6e400002-b5a3-f393-e0a9-e50e24dcca9e` | -| Notify (Jade to host) | `6e400003-b5a3-f393-e0a9-e50e24dcca9e` | - -Devices advertise as `Jade` or `Jade `. - -Requirements on the native implementation: - -1. **Write with response.** Write-without-response silently drops chunks on the - ESP32 GATT stack. -2. **Do not pause between chunks of one request.** Firmware discards a partially - received message after two seconds of silence, three on Jade v1, and answers - with an unattributed error. A 30 KB PSBT is roughly 60 writes, so a UI thread - stall mid send breaks signing. -3. **`get_chunk_size` returns `min(negotiated_mtu - 3, 509)`.** Rust clamps the - answer to `1..=509`, so an unnegotiated `0` is not fatal. -4. **`read_chunk` returns promptly**, honouring the short `timeout_ms` it is - given. Returning success with an empty vector means "nothing yet" and is the - normal state while the user is deciding. The long per-operation deadline is - enforced in Rust so the user can cancel. - -Every callback invocation runs on the tokio blocking pool, so a slow -implementation costs a blocking thread rather than a runtime worker. - -## Serial - -115200 baud. Ports are matched on the USB descriptors Jade and its DIY bridge -chips present: - -| VID:PID | Chip | -|---|---| -| `10c4:ea60` | Silicon Labs CP210x, Jade v1 | -| `1a86:55d4` | WCH CH9102 | -| `0403:6001` | FTDI FT232 | -| `1a86:7523` | WCH CH340 | -| `303a:4001` | Espressif native USB, Jade Plus | -| `303a:1001` | Espressif USB serial/JTAG | - -DTR and RTS are cleared on open and close; leaving either asserted resets the -ESP32 on several of these bridges. - -`serialport` is declared with `default-features = false` because its default -`libudev` feature links a C library that CI does not install, and -`build_android.sh` performs a host build. - -## Connection flow - -1. `jade_scan` collects devices from the transport callback and, on desktop, from - serial enumeration. It returns `DeviceBusy` while a connection is open, - because starting a Bluetooth scan during an active link drops it on Android. -2. `jade_connect` closes anything already open, opens the transport, reads - `get_version_info`, and contributes 32 bytes of host entropy via `add_entropy`. -3. The returned `jade_state` decides what happens next: `Locked` means call - `jade_unlock`, `Ready` means the device is usable, `Uninit` means the user has - to create or restore a wallet on the device itself, which the host cannot - drive. -4. `jade_unlock` sends `auth_user`. If the device answers with an `http_request`, - the host performs it and feeds the reply back as the `pin` method's params. - -## Unlock and the pinserver - -Jade's PIN protection is backed by a blind pinserver. The exchange is end to end -encrypted between device and server, so the host never learns the PIN; it only -carries bytes. Two details matter: - -- The HTTP response is JSON and must be decoded into a CBOR **map**. Firmware - requires `params` to be a map with a text `data` member, so forwarding raw - bytes fails every unlock. -- An HTTP failure must still send a `pin` message, 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, putting every later - call one message out of step. - -Because the URL list comes from the device, requests are constrained to https, -port 443, no credentials, no redirects, no onion hosts, a resolved address that -is not loopback, private, link local, CGNAT or unique local, and a 64 KiB body -cap. A non-default pinserver host is logged as a warning: a second hand or -tampered unit can carry a pinserver a previous owner configured. +## Why the types are declared with `#[uniffi::remote]` + +The crate's types carry no binding framework. `types.rs` attaches UniFFI +scaffolding to them from here, which generates the same code a +`#[derive(uniffi::…)]` would without a mirrored set of structs. + +This is the main way this module differs from `trezor`, which predates the +technique: that module maintains roughly 900 lines of parallel types and +hand-written `From` conversions in both directions against +`trezor-connect-rs`. The declarations here have to match upstream field for +field, and the compiler enforces it. + +One consequence worth knowing: `#[uniffi::remote(Error)]` needs to match every +variant, so `jade_client_rs::JadeError` deliberately is not `#[non_exhaustive]`. + +## Session state + +`jade_client_rs::Jade` takes `&mut self` per operation, so the one request at a +time rule is a compile time property there. A free-function FFI surface needs a +process global, so `JadeManager` supplies the lock that implies. + +The abort handle is kept outside that lock on purpose. Sharing one lock would +make `jade_disconnect` and every status read queue behind a five minute +confirmation, and UniFFI async exports are detached onto the runtime, so a +cancelled Swift or Kotlin task does not cancel the Rust future by itself. +`jade_cancel` and `jade_disconnect` therefore close the transport through a +`CancelHandle` without taking the session lock. + +## Transport bridge + +`JadeTransportCallback` is the `#[uniffi::export(with_foreign)]` trait the +application implements; `CallbackTransport` adapts it onto the crate's +`JadeTransport`. Every callback invocation runs on the tokio blocking pool, so a +slow implementation costs a blocking thread rather than a runtime worker. + +The full Bluetooth contract, including the two second inter-chunk deadline and +the write-with-response requirement, is documented on the trait and in the +crate's README. Read it before writing a native implementation; each of those +rules fails only against real hardware. + +Errors cross the boundary as a typed `JadeTransportErrorCode` rather than an +error string. 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. ## Signing -Jade returns a **signed PSBT**, so it follows the Passport path rather than the -Trezor one: +Jade returns a signed PSBT, so it follows the Passport path: ``` onchain_compose_transaction -> psbt (base64) @@ -148,69 +86,33 @@ finalize_psbt(original, signed) -> CompletedTransaction onchain_broadcast_raw_tx ``` -`jade_sign_psbt` checks the reply against what was sent before returning, so the -guarantee holds here even for a caller that does not go on to use -`finalize_psbt`: same unsigned transaction, same input and output counts, -unchanged previous output metadata, and at least one new signature. - -Before the round trip it also rejects a PSBT larger than the device's input -buffer, an unsupported sighash type, and a PSBT whose BIP32 origins carry no -input for the connected device's master fingerprint. That last one is the most -likely integration failure: `WalletParams.fingerprint` must be set to the value -from `jade_get_master_fingerprint`, or `compose_transaction` produces a PSBT with -no key origins and the device signs nothing. - -## Addresses - -`jade_verify_address` takes the address the application is about to display and -asks the device to show its own derivation for the same path, failing with -`AddressMismatch` if they disagree. Jade always prompts on screen for this call, -so it is a verification step rather than a way to fetch an address. It catches -corruption and firmware bugs; a wholly malicious device is still caught by the -user reading the device screen. - -## Cancellation - -Jade has no cancel message, so `jade_cancel` and `jade_disconnect` close the -link. Both set an abort flag and close the transport **without** taking the I/O -lock, so a request blocked on a five minute confirmation returns -`UserCancelled` promptly instead of running out its deadline. UniFFI async -exports are detached onto the runtime, so a cancelled Swift or Kotlin task does -not cancel the Rust future by itself; this is the mechanism that does. - -## Common issues - -| Symptom | Cause | -|---|---| -| Every `sign_psbt` fails with `DeviceError` | Binary field encoded as a CBOR array rather than a byte string | -| Signing fails partway through a large PSBT | A pause longer than two seconds between chunks, or write-without-response | -| `FingerprintMismatch` | `WalletParams.fingerprint` was not set when composing | -| `UnsupportedFirmware` on a taproot address | Taproot addresses need firmware 1.0.34 or newer | -| `NetworkMismatch` | The device was unlocked for a different network | -| Unlock hangs, later calls report protocol errors | The `pin` follow-up was skipped after an HTTP failure | -| `DeviceUninitialized` | The wallet must be created or restored on the device itself | +`WalletParams.fingerprint` must be set to the value from +`jade_get_master_fingerprint`, or the composed PSBT carries no BIP32 key origins +and the device signs nothing. The crate rejects that case before the round trip +with `FingerprintMismatch`. ## Constraints -- No `#[uniffi::export]` item in this module may be `cfg` gated. All three build - scripts generate bindings from the **host** library, so a host only export - would appear in the generated Swift and Kotlin while being absent from the - device library: a link failure on iOS and a checksum mismatch on Android. +- No `#[uniffi::export]` item here may be `cfg` gated. All three build scripts + generate bindings from the host library, so a host only export would appear in + the generated Swift and Kotlin while being absent from the device library. - No `u8` or `u16` in the FFI surface. `ping` returns `JadePingStatus` and - `battery_status` is `u32`, keeping this module clear of the unsigned narrow + `battery_status` is `u32`, keeping this module clear of the narrow unsigned return path that needed a binding generator fix for Android ARM32. -- Registering a transport callback twice replaces the first. This is deliberate, - so an Android activity restart can re-register; the replacement is logged. +- Registering a transport callback twice replaces the first, so an Android + activity restart can re-register. The replacement is logged. + +## Dependency + +Pinned by git revision until the crate is published to crates.io, so bitkit-core +never depends on an unreleased version. Bumping it means updating both target +tables in `Cargo.toml`. ## Testing ```bash -cargo test modules::jade +cargo test modules::jade # adapter only ``` -Everything runs against a scripted mock device and a fake pinserver, so no -hardware or network access is needed. Covered: byte string encoding, frame -reassembly across reads, two frames in one read, log frame skipping, stale and -unattributed replies, error code mapping, multi fragment `sign_psbt` reassembly, -cancellation, connection poisoning, path validation, and the unlock exchange -including the HTTP failure path. +Protocol level tests live in the crate and run with `cargo test` there, against +a scripted mock device and a fake pinserver. diff --git a/src/modules/jade/callbacks.rs b/src/modules/jade/callbacks.rs index 0504efb..d05f08e 100644 --- a/src/modules/jade/callbacks.rs +++ b/src/modules/jade/callbacks.rs @@ -1,33 +1,21 @@ -//! The transport contract the native application implements. +//! The transport contract the native application implements, and the bridge +//! from it to the protocol crate's transport trait. //! //! Rust owns the Jade protocol; the application owns the bytes. On iOS that //! means CoreBluetooth against the Nordic UART Service, and on Android the -//! Bluetooth API plus, optionally, the USB Host API for CDC serial. Desktop and -//! Python builds can skip this entirely and use the Rust serial transport. +//! Bluetooth API plus, optionally, the USB Host API for CDC serial. //! -//! Methods are synchronous. UniFFI can express async foreign callbacks, but the -//! trezor module established the synchronous shape here and the transport layer -//! runs every one of these on the blocking pool, so there is nothing to gain by -//! diverging. +//! Methods are synchronous because that is the shape the trezor module already +//! established here. Every one of them is invoked on the blocking pool, so a +//! slow implementation costs a blocking thread rather than a runtime worker. use std::sync::{Arc, RwLock}; +use std::time::Duration; -use super::types::JadeTransportKind; +use async_trait::async_trait; +use jade_client_rs::{JadeError, JadeTransport, JadeTransportErrorCode, MAX_CHUNK_BYTES}; -/// A failure the native layer can report in a way Rust can act on. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -pub enum JadeTransportErrorCode { - /// Another operation holds the device. - DeviceBusy, - /// The device is not currently open. - NotConnected, - /// The link dropped. - Disconnected, - /// The operation exceeded its deadline. - Timeout, - /// The OS refused access, typically a missing Bluetooth or USB permission. - PermissionDenied, -} +use super::types::JadeTransportKind; /// A device the native layer discovered. #[derive(Debug, Clone, uniffi::Record)] @@ -78,12 +66,12 @@ pub struct JadeTransportReadResult { /// 1. **Write with response.** Write-without-response silently drops chunks on /// the ESP32 GATT stack. /// 2. **Do not pause between chunks.** Firmware discards a partially received -/// message after two seconds of silence (three on Jade v1) and answers with +/// message after two seconds of silence, three on Jade v1, and answers with /// an unattributed error. A 30 KB PSBT is roughly 60 writes, so any UI thread /// stall in the middle of a send breaks the operation. /// 3. **`read_chunk` must return promptly.** Honour `timeout_ms`, which this /// crate keeps short. The long per-operation deadline is enforced in Rust so -/// the user can cancel; blocking here for minutes would defeat that. +/// the user can cancel. #[uniffi::export(with_foreign)] pub trait JadeTransportCallback: Send + Sync { /// Discover devices, blocking up to `timeout_ms`. @@ -105,8 +93,8 @@ pub trait JadeTransportCallback: Send + Sync { /// Maximum bytes per write. /// - /// For Bluetooth this is `min(negotiated_mtu - 3, 509)`. Rust clamps the - /// answer into a usable range, so an unnegotiated `0` is not fatal. + /// For Bluetooth this is `min(negotiated_mtu - 3, 509)`. The value is + /// clamped into a usable range, so an unnegotiated `0` is not fatal. fn get_chunk_size(&self, path: String) -> u32; } @@ -122,7 +110,6 @@ static TRANSPORT_CALLBACK: RwLock>> = RwLo /// /// Returns `true` when this replaced a previously registered callback, which /// lets the application tell a fresh registration from a re-registration. -/// Any live connection is invalidated by the caller before this takes effect. #[uniffi::export] pub fn jade_set_transport_callback(callback: Arc) -> bool { #[cfg(target_os = "android")] @@ -146,3 +133,98 @@ pub(crate) fn transport_callback() -> Option> { .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } + +fn to_error(code: Option, message: String) -> JadeError { + match code { + Some(code) => JadeError::from(code), + None => JadeError::TransportError { + error_details: message, + }, + } +} + +/// Bridges the foreign callback onto the protocol crate's transport trait. +/// +/// The error code travels as a typed value the whole way, so nothing has to be +/// encoded into an error string and parsed back out. The trezor adapter in this +/// repo does exactly that, because its upstream crate offers no typed channel. +pub(crate) struct CallbackTransport { + callback: Arc, + path: String, + chunk_size: usize, +} + +impl CallbackTransport { + pub(crate) fn new(callback: Arc, path: String) -> Self { + // Clamp whatever the native layer reports. A zero would make the write + // loop fail to advance, and anything above the Bluetooth cap would be + // rejected by the link layer. + let reported = callback.get_chunk_size(path.clone()); + let chunk_size = reported.clamp(1, MAX_CHUNK_BYTES) as usize; + Self { + callback, + path, + chunk_size, + } + } +} + +#[async_trait] +impl JadeTransport for CallbackTransport { + async fn write_all(&self, data: Vec) -> Result<(), JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + let chunk_size = self.chunk_size; + + // Foreign callbacks are synchronous and can block. Running them on a + // worker thread would park it for the duration; the blocking pool is + // sized for exactly this. + tokio::task::spawn_blocking(move || { + for chunk in data.chunks(chunk_size) { + let result = callback.write_chunk(path.clone(), chunk.to_vec()); + if !result.success { + return Err(to_error(result.error_code, result.error)); + } + } + Ok(()) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("write task failed: {error}"), + })? + } + + async fn read_some(&self, timeout: Duration) -> Result, JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + let timeout_ms = timeout.as_millis().min(u128::from(u32::MAX)) as u32; + + tokio::task::spawn_blocking(move || { + let result = callback.read_chunk(path, timeout_ms); + if !result.success { + return Err(to_error(result.error_code, result.error)); + } + Ok(result.data) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("read task failed: {error}"), + })? + } + + async fn close(&self) -> Result<(), JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + tokio::task::spawn_blocking(move || { + let result = callback.close_device(path); + if !result.success { + return Err(to_error(result.error_code, result.error)); + } + Ok(()) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("close task failed: {error}"), + })? + } +} diff --git a/src/modules/jade/errors.rs b/src/modules/jade/errors.rs deleted file mode 100644 index 3d89e31..0000000 --- a/src/modules/jade/errors.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Error types for the Jade module. - -use thiserror::Error; - -/// Error codes defined by Jade firmware in `main/utils/cbor_rpc.h`. -/// -/// The standard JSON-RPC codes occupy -32600 to -32603; Jade's own codes occupy -/// -32000 to -32099. -pub(crate) mod rpc_code { - pub const INVALID_REQUEST: i64 = -32600; - pub const UNKNOWN_METHOD: i64 = -32601; - pub const BAD_PARAMETERS: i64 = -32602; - pub const INTERNAL_ERROR: i64 = -32603; - pub const USER_CANCELLED: i64 = -32000; - pub const PROTOCOL_ERROR: i64 = -32001; - pub const HW_LOCKED: i64 = -32002; - pub const NETWORK_MISMATCH: i64 = -32003; -} - -/// Jade-related errors exposed via FFI. -#[derive(uniffi::Error, Debug, Error, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum JadeError { - /// Transport layer error (Bluetooth or serial communication). - #[error("Transport error: {error_details}")] - TransportError { error_details: String }, - - /// No Jade device matched the requested identifier. - #[error("No Jade device found")] - DeviceNotFound, - - /// The device went away during an operation. - #[error("Device disconnected during operation")] - DeviceDisconnected, - - /// Another operation holds the device; back off and retry. - #[error("Device is busy")] - DeviceBusy, - - /// No connection is open. Call `jade_connect` first. - #[error("Not connected to a Jade device")] - NotConnected, - - /// No transport callback has been registered. - #[error("Jade transport callback has not been set")] - NotInitialized, - - /// Failed to open or establish a connection. - #[error("Connection error: {error_details}")] - ConnectionError { error_details: String }, - - /// The device sent something that does not conform to the wire protocol. - #[error("Protocol error: {error_details}")] - ProtocolError { error_details: String }, - - /// The operation exceeded its deadline. - #[error("Operation timed out")] - Timeout, - - /// The user declined on the device, or the host aborted the operation. - #[error("Operation cancelled")] - UserCancelled, - - /// The device has a PIN set and is locked. Call `jade_unlock`. - #[error("Device is locked")] - DeviceLocked, - - /// The device has no wallet. Setup must be completed on the device itself. - #[error("Device has no wallet configured")] - DeviceUninitialized, - - /// The PIN entered on the device was rejected by the pinserver. - #[error("Incorrect PIN")] - InvalidPin, - - /// The requested network does not match what the device is configured for. - #[error("Network mismatch: {error_details}")] - NetworkMismatch { error_details: String }, - - /// The device firmware predates a feature this module requires. - #[error("Jade firmware {installed} is too old, {required} or newer is required")] - UnsupportedFirmware { installed: String, required: String }, - - /// A BIP32 derivation path was malformed or not permitted here. - #[error("Invalid derivation path: {error_details}")] - InvalidPath { error_details: String }, - - /// A PSBT failed to parse, or the device returned one that does not match. - #[error("Invalid PSBT: {error_details}")] - InvalidPsbt { error_details: String }, - - /// The PSBT exceeds what the device can receive in one message. - #[error("PSBT is {size} bytes, which exceeds the {max} byte limit")] - PsbtTooLarge { size: u64, max: u64 }, - - /// No PSBT input carries the connected device's master fingerprint, so the - /// device would sign nothing. - #[error("PSBT is for master fingerprint {psbt}, but the connected device is {device}")] - FingerprintMismatch { device: String, psbt: String }, - - /// The device returned a PSBT with no new signatures. - #[error("Device did not add any signatures")] - NothingSigned, - - /// The device returned an address that does not match the host-derived one. - #[error("Address mismatch: expected {expected}, device returned {returned}")] - AddressMismatch { expected: String, returned: String }, - - /// The blind pinserver exchange failed. - #[error("Pin server error: {error_details}")] - PinServerError { error_details: String }, - - /// The device reported an error that has no more specific mapping. - #[error("Device error: {error_details}")] - DeviceError { error_details: String }, - - /// An internal or runtime failure on the host side. - #[error("IO error: {error_details}")] - IoError { error_details: String }, -} - -impl JadeError { - /// Build a `ProtocolError` from anything displayable. - pub(crate) fn protocol(details: impl std::fmt::Display) -> Self { - JadeError::ProtocolError { - error_details: details.to_string(), - } - } - - /// Build a `TransportError` from anything displayable. - pub(crate) fn transport(details: impl std::fmt::Display) -> Self { - JadeError::TransportError { - error_details: details.to_string(), - } - } - - /// Map an error reply from the device onto a typed error. - /// - /// `UNKNOWN_METHOD` maps to `UnsupportedFirmware` rather than `ProtocolError`: - /// the single-signature `get_receive_address` and `sign_psbt` calls this module - /// relies on were added in later firmware, so an older unit answering -32601 is - /// reporting its age, not a host bug. - pub(crate) fn from_rpc(code: i64, message: String, min_firmware: &str) -> Self { - match code { - rpc_code::USER_CANCELLED => JadeError::UserCancelled, - rpc_code::HW_LOCKED => JadeError::DeviceLocked, - rpc_code::NETWORK_MISMATCH => JadeError::NetworkMismatch { - error_details: message, - }, - rpc_code::UNKNOWN_METHOD => JadeError::UnsupportedFirmware { - installed: "unknown".to_string(), - required: min_firmware.to_string(), - }, - rpc_code::PROTOCOL_ERROR | rpc_code::INVALID_REQUEST => JadeError::ProtocolError { - error_details: message, - }, - rpc_code::BAD_PARAMETERS | rpc_code::INTERNAL_ERROR => JadeError::DeviceError { - error_details: message, - }, - other => JadeError::DeviceError { - error_details: format!("device error {other}: {message}"), - }, - } - } -} diff --git a/src/modules/jade/implementation.rs b/src/modules/jade/implementation.rs index 38ff95b..35d3fa6 100644 --- a/src/modules/jade/implementation.rs +++ b/src/modules/jade/implementation.rs @@ -1,69 +1,43 @@ -//! Session state and the operations exposed over FFI. +//! Session state for the FFI surface. //! -//! State is deliberately split from the I/O lock. A single mutex guarding every -//! operation would make `jade_disconnect` and every status read queue behind a -//! five minute `sign_psbt`, and UniFFI async exports are detached onto the +//! `jade_client_rs::Jade` takes `&mut self` for every operation, which makes the +//! one-request-at-a-time rule a compile time property. The FFI surface here is a +//! set of free functions over a process global, so this adds the lock that shape +//! implies, plus the device list and the abort handle. +//! +//! The abort handle is deliberately kept outside the session lock. Holding one +//! lock for both would make `jade_disconnect` and every status read queue behind +//! a five minute confirmation, and UniFFI async exports are detached onto the //! runtime, so a cancelled Swift or Kotlin task does not cancel the Rust future -//! either. The abort path therefore never waits on the I/O lock. +//! by itself. -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use bitcoin::bip32::{DerivationPath, Xpub}; use bitcoin::psbt::Psbt; -use bitcoin::secp256k1::Secp256k1; -use rand::RngCore; -use serde::Serialize; +use jade_client_rs::{CancelHandle, Jade, JadeTransport}; use tokio::sync::{Mutex, RwLock}; -use zeroize::Zeroizing; - -use super::callbacks::{transport_callback, JadeNativeDevice}; -use super::errors::JadeError; -use super::path; -use super::pinserver::{self, PinServerHttp, ReqwestPinServer}; -use super::protocol::{result_bool, result_text}; -use super::transport::{CallbackTransport, JadeConnection, JadeTransport}; + +use super::callbacks::{transport_callback, CallbackTransport}; use super::types::*; use crate::onchain::AccountType; -/// Timeout for calls the device answers on its own. -const QUICK_TIMEOUT: Duration = Duration::from_secs(60); - -/// Timeout for calls that wait on a physical button press. -const CONFIRM_TIMEOUT: Duration = Duration::from_secs(300); - -/// Largest PSBT this module will send. -/// -/// Jade's input buffer is 17 KiB without SPIRAM. Composed PSBTs carry full -/// previous transactions, so this is worth checking before a long transfer that -/// the device would reject at the end. -const MAX_PSBT_BYTES: u64 = 16 * 1024; - -/// What is known about the open session. +/// A device seen by the last scan. #[derive(Debug, Clone)] -struct SessionState { - device: JadeDeviceInfo, - version: JadeVersionInfo, - /// The network `auth_user` unlocked, once it has succeeded. Later calls are - /// checked against it so a mismatch is reported here rather than surfacing - /// as an opaque device error. - unlocked_network: Option, +struct CachedDevice { + info: JadeDeviceInfo, } pub struct JadeManager { - device_list: Mutex>, - state: RwLock>, - /// Held for exactly one round trip. - io: Mutex>, - /// Cloned out by the abort path, which must not wait on `io`. - transport: RwLock>>, - aborted: Arc, - /// Cheap status reads that never touch a lock held across I/O. + device_list: Mutex>, + /// Held for exactly one operation. + session: Mutex>, + /// Cloned out by the abort path, which must not wait on `session`. + cancel: RwLock>, + /// Cheap status reads that never touch a lock held across device I/O. connected: AtomicBool, - pinserver: Arc, + connected_device: RwLock>, } impl Default for JadeManager { @@ -74,19 +48,12 @@ impl Default for JadeManager { impl JadeManager { pub fn new() -> Self { - Self::with_pinserver(Arc::new(ReqwestPinServer)) - } - - /// Build a manager with a specific pinserver implementation. - pub(crate) fn with_pinserver(pinserver: Arc) -> Self { Self { device_list: Mutex::new(Vec::new()), - state: RwLock::new(None), - io: Mutex::new(None), - transport: RwLock::new(None), - aborted: Arc::new(AtomicBool::new(false)), + session: Mutex::new(None), + cancel: RwLock::new(None), connected: AtomicBool::new(false), - pinserver, + connected_device: RwLock::new(None), } } @@ -102,7 +69,7 @@ impl JadeManager { return Err(JadeError::DeviceBusy); } - let mut discovered = Vec::new(); + let mut discovered: Vec = Vec::new(); if let Some(callback) = transport_callback() { let found = tokio::task::spawn_blocking(move || callback.scan_devices(timeout_ms)) @@ -110,25 +77,23 @@ impl JadeManager { .map_err(|error| JadeError::IoError { error_details: format!("scan task failed: {error}"), })?; - discovered.extend(found); + discovered.extend(found.into_iter().map(|device| JadeDeviceInfo { + path: device.path, + transport: device.transport, + name: device.name, + serial_number: device.serial_number, + })); } #[cfg(not(any(target_os = "ios", target_os = "android")))] - discovered.extend(super::serial::enumerate_devices()); + discovered.extend(jade_client_rs::serial::enumerate_devices()); - let infos: Vec = discovered + *self.device_list.lock().await = discovered .iter() - .map(|device| JadeDeviceInfo { - id: JadeDeviceInfo::build_id(device.transport, &device.path), - transport: device.transport, - name: device.name.clone(), - path: device.path.clone(), - serial_number: device.serial_number.clone(), - }) + .cloned() + .map(|info| CachedDevice { info }) .collect(); - - *self.device_list.lock().await = discovered; - Ok(infos) + Ok(discovered) } /// The devices found by the last scan. @@ -137,13 +102,7 @@ impl JadeManager { .lock() .await .iter() - .map(|device| JadeDeviceInfo { - id: JadeDeviceInfo::build_id(device.transport, &device.path), - transport: device.transport, - name: device.name.clone(), - path: device.path.clone(), - serial_number: device.serial_number.clone(), - }) + .map(|device| device.info.clone()) .collect() } @@ -152,156 +111,96 @@ impl JadeManager { // ------------------------------------------------------------------ /// Open a device and read its version summary. - pub async fn connect(&self, device_id: &str) -> Result { - let (kind, path) = JadeDeviceInfo::parse_id(device_id).ok_or(JadeError::DeviceNotFound)?; - let path = path.to_string(); - + pub async fn connect( + &self, + transport_kind: JadeTransportKind, + path: &str, + ) -> Result { let device = { let devices = self.device_list.lock().await; devices .iter() - .find(|candidate| candidate.transport == kind && candidate.path == path) - .cloned() + .find(|candidate| { + candidate.info.transport == transport_kind && candidate.info.path == path + }) + .map(|candidate| candidate.info.clone()) .ok_or(JadeError::DeviceNotFound)? }; - // Close anything already open first. Overwriting the connection would + // Close anything already open first. Overwriting the session would // strand the native handle with no path left to close it. self.disconnect().await?; - self.aborted.store(false, Ordering::SeqCst); - - let transport: Arc = match kind { - JadeTransportKind::Bluetooth => { - let callback = transport_callback().ok_or(JadeError::NotInitialized)?; - let open_path = path.clone(); - let opener = Arc::clone(&callback); - let result = tokio::task::spawn_blocking(move || opener.open_device(open_path)) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("open task failed: {error}"), - })?; - if !result.success { - return Err(JadeError::ConnectionError { - error_details: result.error, - }); - } - Arc::new(CallbackTransport::new(callback, path.clone())) - } - JadeTransportKind::Serial => { - #[cfg(not(any(target_os = "ios", target_os = "android")))] - { - Arc::new(super::serial::SerialTransport::open(&path)?) - } - // On mobile a serial device can only have come from the native - // layer, so it is driven through the callback like Bluetooth. - #[cfg(any(target_os = "ios", target_os = "android"))] - { - let callback = transport_callback().ok_or(JadeError::NotInitialized)?; - let open_path = path.clone(); - let opener = Arc::clone(&callback); - let result = tokio::task::spawn_blocking(move || opener.open_device(open_path)) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("open task failed: {error}"), - })?; - if !result.success { - return Err(JadeError::ConnectionError { - error_details: result.error, - }); - } - Arc::new(CallbackTransport::new(callback, path.clone())) - } - } - }; - - *self.transport.write().await = Some(Arc::clone(&transport)); - let mut connection = JadeConnection::new(transport, Arc::clone(&self.aborted)); - - let version = Self::read_version(&mut connection).await?; - // Contribute host entropy to the device's pool. The buffer is zeroized - // on drop rather than left in a freed allocation. - Self::add_entropy(&mut connection).await?; - - let info = JadeDeviceInfo { - id: device_id.to_string(), - transport: kind, - name: device.name.clone(), - path: device.path.clone(), - serial_number: device.serial_number.clone(), - }; + let transport = self.build_transport(transport_kind, path).await?; + let session = Jade::connect(transport).await?; + let version = session.version_info().clone(); - *self.io.lock().await = Some(connection); - *self.state.write().await = Some(SessionState { - device: info, - version: version.clone(), - unlocked_network: None, - }); + *self.cancel.write().await = Some(session.cancel_handle()); + *self.connected_device.write().await = Some(device); + *self.session.lock().await = Some(session); self.connected.store(true, Ordering::SeqCst); Ok(version) } - async fn read_version(connection: &mut JadeConnection) -> Result { - let reply = connection - .exchange("get_version_info", Option::<()>::None, QUICK_TIMEOUT) - .await?; - let value = reply.into_result(MIN_JADE_FIRMWARE)?; - let wire: WireVersionInfo = value.deserialized().map_err(|error| { - JadeError::protocol(format!("unexpected get_version_info reply: {error}")) - })?; - Ok(JadeVersionInfo::from(wire)) - } - - async fn add_entropy(connection: &mut JadeConnection) -> Result<(), JadeError> { - #[derive(Serialize)] - struct AddEntropyParams { - #[serde(with = "serde_bytes")] - entropy: Vec, + async fn build_transport( + &self, + transport_kind: JadeTransportKind, + path: &str, + ) -> Result, JadeError> { + // A serial device found by the crate's own enumeration is driven + // directly; anything the native layer reported goes back through it. + #[cfg(not(any(target_os = "ios", target_os = "android")))] + if transport_kind == JadeTransportKind::Serial + && jade_client_rs::serial::enumerate_devices() + .iter() + .any(|device| device.path == path) + { + return Ok(Arc::new(jade_client_rs::SerialTransport::open(path)?)); } - let mut entropy = Zeroizing::new(vec![0u8; 32]); - rand::rngs::OsRng.fill_bytes(&mut entropy); - let params = AddEntropyParams { - entropy: entropy.to_vec(), - }; - let reply = connection - .exchange("add_entropy", Some(params), QUICK_TIMEOUT) - .await?; - result_bool(&reply.into_result(MIN_JADE_FIRMWARE)?)?; - Ok(()) + let callback = transport_callback().ok_or(JadeError::NotInitialized)?; + let open_path = path.to_string(); + let opener = Arc::clone(&callback); + let result = tokio::task::spawn_blocking(move || opener.open_device(open_path)) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("open task failed: {error}"), + })?; + if !result.success { + return Err(JadeError::ConnectionError { + error_details: result.error, + }); + } + Ok(Arc::new(CallbackTransport::new(callback, path.to_string()))) } /// Close the device and clear session state. /// - /// Safe to call while an operation is in flight: the abort flag is set and - /// the transport closed without taking the I/O lock, so a blocked request + /// Safe to call while an operation is in flight: the cancel handle closes + /// the transport without taking the session lock, so a blocked request /// returns promptly instead of running out its deadline. pub async fn disconnect(&self) -> Result<(), JadeError> { - self.aborted.store(true, Ordering::SeqCst); self.connected.store(false, Ordering::SeqCst); - let transport = self.transport.write().await.take(); - if let Some(transport) = transport { - if let Err(error) = transport.close().await { + if let Some(cancel) = self.cancel.write().await.take() { + if let Err(error) = cancel.cancel().await { log::debug!("[jade] error closing the transport: {error}"); } } - - *self.state.write().await = None; - *self.io.lock().await = None; + *self.connected_device.write().await = None; + *self.session.lock().await = None; Ok(()) } /// Abort the operation in flight without tearing down session state. /// - /// Jade has no cancel message, so closing the link is the only way to stop - /// a pending confirmation. The application is expected to reconnect. + /// Jade has no cancel message, so closing the link is the only way to stop a + /// pending confirmation. The application is expected to reconnect. pub async fn cancel(&self) -> Result<(), JadeError> { - self.aborted.store(true, Ordering::SeqCst); - let transport = self.transport.read().await.clone(); - if let Some(transport) = transport { - let _ = transport.close().await; + let handle = self.cancel.read().await.clone(); + if let Some(handle) = handle { + handle.cancel().await?; } Ok(()) } @@ -309,11 +208,11 @@ impl JadeManager { /// Record a disconnect the native layer noticed while nothing was in flight. pub async fn notify_disconnected(&self, path: &str) { let matches = self - .state + .connected_device .read() .await .as_ref() - .map(|state| state.device.path == path) + .map(|device| device.path == path) .unwrap_or(false); if matches { log::debug!("[jade] native layer reported a disconnect"); @@ -326,36 +225,23 @@ impl JadeManager { } pub async fn connected_device(&self) -> Option { - self.state - .read() - .await - .as_ref() - .map(|state| state.device.clone()) + self.connected_device.read().await.clone() } /// The version summary read at connect, or refreshed since. pub async fn version_info(&self) -> Option { - self.state - .read() + self.session + .lock() .await .as_ref() - .map(|state| state.version.clone()) + .map(|session| session.version_info().clone()) } /// Re-read the version summary from the device. pub async fn refresh_version_info(&self) -> Result { - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - let version = Self::read_version(connection).await?; - drop(guard); - self.store_version(version.clone()).await; - Ok(version) - } - - async fn store_version(&self, version: JadeVersionInfo) { - if let Some(state) = self.state.write().await.as_mut() { - state.version = version; - } + let mut guard = self.session.lock().await; + let session = guard.as_mut().ok_or(JadeError::NotConnected)?; + session.refresh_version_info().await.cloned() } // ------------------------------------------------------------------ @@ -363,460 +249,118 @@ impl JadeManager { // ------------------------------------------------------------------ pub async fn ping(&self) -> Result { - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - let reply = connection - .exchange("ping", Option::<()>::None, QUICK_TIMEOUT) - .await?; - let value = reply.into_result(MIN_JADE_FIRMWARE)?; - let raw = value - .as_integer() - .and_then(|integer| u64::try_from(integer).ok()) - .ok_or_else(|| JadeError::protocol("expected an integer ping result"))?; - Ok(JadePingStatus::from_wire(raw)) + let mut guard = self.session.lock().await; + guard.as_mut().ok_or(JadeError::NotConnected)?.ping().await } - /// Unlock the device, running the blind pinserver exchange if it asks. pub async fn unlock(&self, network: JadeNetwork) -> Result<(), JadeError> { - // A device with no wallet starts an on-device setup flow that can take - // minutes and cannot be driven from here. - if let Some(state) = self.state.read().await.as_ref() { - if state.version.jade_state == JadeState::Uninit { - return Err(JadeError::DeviceUninitialized); - } - } - - let epoch = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or(0); - - { - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - pinserver::run_unlock(connection, network, self.pinserver.as_ref(), epoch).await?; - } - - if let Some(state) = self.state.write().await.as_mut() { - state.unlocked_network = Some(network); - } - // The cached state still says LOCKED until this is refreshed, and the - // whole point of exposing it is telling the app whether to prompt. - let _ = self.refresh_version_info().await; - Ok(()) + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .unlock(network) + .await } pub async fn logout(&self) -> Result<(), JadeError> { - { - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - let reply = connection - .exchange("logout", Option::<()>::None, QUICK_TIMEOUT) - .await?; - result_bool(&reply.into_result(MIN_JADE_FIRMWARE)?)?; - } - if let Some(state) = self.state.write().await.as_mut() { - state.unlocked_network = None; - } - let _ = self.refresh_version_info().await; - Ok(()) + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .logout() + .await } - /// Check the requested network against the one that was unlocked. - async fn check_network(&self, network: JadeNetwork) -> Result<(), JadeError> { - let unlocked = self - .state - .read() + pub async fn master_fingerprint(&self, network: JadeNetwork) -> Result { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .master_fingerprint(network) .await - .as_ref() - .and_then(|state| state.unlocked_network); - match unlocked { - Some(unlocked) if unlocked != network => Err(JadeError::NetworkMismatch { - error_details: format!( - "the device was unlocked for {} but the request is for {}", - unlocked.wire_name(), - network.wire_name() - ), - }), - _ => Ok(()), - } } - async fn raw_xpub( + pub async fn get_xpub( &self, network: JadeNetwork, - derivation_path: &str, - allow_master: bool, - ) -> Result { - #[derive(Serialize)] - struct GetXpubParams<'a> { - network: &'a str, - path: Vec, - } - - let wire_path = path::to_wire(derivation_path, allow_master)?; - let params = GetXpubParams { - network: network.wire_name(), - path: wire_path, - }; - - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - let reply = connection - .exchange("get_xpub", Some(params), QUICK_TIMEOUT) - .await?; - result_text(&reply.into_result(MIN_JADE_FIRMWARE)?) - } - - /// The device's master fingerprint, eight lowercase hex characters. - /// - /// Derived from `m/0'`'s parent fingerprint rather than by asking for the - /// master xpub directly, which is how HWI does it and which avoids relying - /// on the device accepting an empty path. - pub async fn master_fingerprint(&self, network: JadeNetwork) -> Result { - self.check_network(network).await?; - let xpub = self.raw_xpub(network, "m/0'", false).await?; - let parsed = Xpub::from_str(&xpub).map_err(|error| { - JadeError::protocol(format!("device returned an unparsable xpub: {error}")) - })?; - Ok(format!("{:08x}", parsed.parent_fingerprint)) - } - - /// Fetch an extended public key, echoed back with the request it answers. - pub async fn get_xpub(&self, params: JadeGetXpubParams) -> Result { - self.check_network(params.network).await?; - let fingerprint = self.master_fingerprint(params.network).await?; - let xpub = self - .raw_xpub(params.network, ¶ms.derivation_path, false) - .await?; - self.verify_xpub(&xpub, ¶ms.derivation_path)?; - - Ok(JadeXpubResponse { - xpub, - derivation_path: params.derivation_path, - master_fingerprint: fingerprint, - }) - } - - /// Confirm the device answered the question that was asked. - fn verify_xpub(&self, xpub: &str, derivation_path: &str) -> Result<(), JadeError> { - let parsed = Xpub::from_str(xpub).map_err(|error| { - JadeError::protocol(format!("device returned an unparsable xpub: {error}")) - })?; - let expected = DerivationPath::from_str(derivation_path.trim()).map_err(|error| { - JadeError::InvalidPath { - error_details: error.to_string(), - } - })?; - let depth = expected.len(); - if usize::from(parsed.depth) != depth { - return Err(JadeError::protocol(format!( - "device returned a key at depth {} for a path of depth {depth}", - parsed.depth - ))); - } - Ok(()) + derivation_path: String, + ) -> Result { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .get_xpub(network, &derivation_path) + .await } - /// Fetch the account xpubs an import needs in one round of I/O. pub async fn account_export( &self, network: JadeNetwork, account_index: u32, account_types: Vec, ) -> Result { - self.check_network(network).await?; - let fingerprint = self.master_fingerprint(network).await?; - - let mut accounts = Vec::with_capacity(account_types.len()); - for account_type in account_types { - let purpose = JadeAddressVariant::from(account_type).purpose(); - let derivation_path = format!("m/{purpose}'/{}'/{account_index}'", network.coin_type()); - let xpub = self.raw_xpub(network, &derivation_path, false).await?; - self.verify_xpub(&xpub, &derivation_path)?; - accounts.push(JadeAccount { - account_type, - xpub, - derivation_path, - }); - } - - Ok(JadeAccountExport { - master_fingerprint: fingerprint, - account_index, - accounts, - }) + let variants: Vec = account_types + .into_iter() + .map(account_type_to_variant) + .collect(); + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .account_export(network, account_index, &variants) + .await } - /// Ask the device to display an address, and check it against the expected one. - /// - /// This call always prompts on the device screen, so it is a verification - /// step rather than a fetch: the application already knows the address from - /// the account xpub. Comparing the two catches corruption and firmware bugs. - /// A wholly malicious device is still the user's job to catch by reading the - /// device screen. - pub async fn verify_address(&self, params: JadeVerifyAddressParams) -> Result<(), JadeError> { - #[derive(Serialize)] - struct GetReceiveAddressParams<'a> { - network: &'a str, - variant: &'a str, - path: Vec, - } - - self.check_network(params.network).await?; - - // A legacy variant under an m/84' path is a caller bug worth catching - // before the device is asked to display something misleading. - if let Some(purpose) = path::purpose(¶ms.derivation_path) { - if purpose != params.variant.purpose() { - return Err(JadeError::InvalidPath { - error_details: format!( - "path purpose {purpose} does not match the {} variant", - params.variant.wire_name() - ), - }); - } - } - - // Taproot addresses arrived in 1.0.34. Older firmware answers - // BAD_PARAMETERS, which says nothing useful to the user. - if params.variant == JadeAddressVariant::Tr { - if let Some(state) = self.state.read().await.as_ref() { - let installed = &state.version.jade_version; - if !version_at_least(installed, MIN_JADE_FIRMWARE_TAPROOT) { - return Err(JadeError::UnsupportedFirmware { - installed: installed.clone(), - required: MIN_JADE_FIRMWARE_TAPROOT.to_string(), - }); - } - } - } - - let wire_path = path::to_wire(¶ms.derivation_path, false)?; - let request = GetReceiveAddressParams { - network: params.network.wire_name(), - variant: params.variant.wire_name(), - path: wire_path, - }; - - let returned = { - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - let reply = connection - .exchange("get_receive_address", Some(request), CONFIRM_TIMEOUT) - .await?; - result_text(&reply.into_result(MIN_JADE_FIRMWARE)?)? - }; - - if returned != params.expected_address { - return Err(JadeError::AddressMismatch { - expected: params.expected_address, - returned, - }); - } - Ok(()) + pub async fn verify_address( + &self, + network: JadeNetwork, + variant: JadeAddressVariant, + derivation_path: String, + expected_address: String, + ) -> Result<(), JadeError> { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .verify_address(network, variant, &derivation_path, &expected_address) + .await } - /// Sign a message, returning the signature with the address that verifies it. pub async fn sign_message( &self, - params: JadeSignMessageParams, network: JadeNetwork, + derivation_path: String, + message: String, ) -> Result { - #[derive(Serialize)] - struct SignMessageParams<'a> { - message: &'a str, - path: Vec, - } - - self.check_network(network).await?; - let wire_path = path::to_wire(¶ms.derivation_path, false)?; - - // Derive the address host side so the caller can verify without a - // second round trip. - let xpub = self - .raw_xpub(network, ¶ms.derivation_path, false) - .await?; - let parsed = Xpub::from_str(&xpub).map_err(|error| { - JadeError::protocol(format!("device returned an unparsable xpub: {error}")) - })?; - let address = bitcoin::Address::p2wpkh( - &bitcoin::CompressedPublicKey(parsed.public_key), - bitcoin::Network::from(network), - ) - .to_string(); - - let request = SignMessageParams { - message: ¶ms.message, - path: wire_path, - }; - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - let reply = connection - .exchange("sign_message", Some(request), CONFIRM_TIMEOUT) - .await?; - let signature = result_text(&reply.into_result(MIN_JADE_FIRMWARE)?)?; - - Ok(JadeSignedMessage { - signature, - address, - derivation_path: params.derivation_path, - }) + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .sign_message(network, &derivation_path, &message) + .await } - /// Sign a PSBT. + /// Sign a base64 PSBT and return the signed PSBT, base64 encoded. /// - /// The reply is checked against what was sent before it is returned, so the - /// guarantee holds at this boundary even for a caller that does not go on to - /// use `finalize_psbt`. - pub async fn sign_psbt(&self, params: JadeSignPsbtParams) -> Result { - #[derive(Serialize)] - struct SignPsbtParams<'a> { - network: &'a str, - #[serde(with = "serde_bytes")] - psbt: Vec, - } - - self.check_network(params.network).await?; - - let bytes = - STANDARD - .decode(params.psbt.trim()) - .map_err(|error| JadeError::InvalidPsbt { - error_details: format!("base64 decoding failed: {error}"), - })?; - let sent = Psbt::deserialize(&bytes).map_err(|error| JadeError::InvalidPsbt { + /// The FFI surface speaks base64 because that is what `compose_transaction` + /// emits and what `finalize_psbt` expects; the protocol crate works in typed + /// PSBTs, so the encoding boundary lives here. + pub async fn sign_psbt(&self, network: JadeNetwork, psbt: String) -> Result { + let bytes = STANDARD + .decode(psbt.trim()) + .map_err(|error| JadeError::InvalidPsbt { + error_details: format!("base64 decoding failed: {error}"), + })?; + let parsed = Psbt::deserialize(&bytes).map_err(|error| JadeError::InvalidPsbt { error_details: format!("parsing failed: {error}"), })?; - if bytes.len() as u64 > MAX_PSBT_BYTES { - return Err(JadeError::PsbtTooLarge { - size: bytes.len() as u64, - max: MAX_PSBT_BYTES, - }); - } - - self.check_signable(&sent, params.network).await?; - - let request = SignPsbtParams { - network: params.network.wire_name(), - psbt: bytes, - }; - - let signed_bytes = { - let mut guard = self.io.lock().await; - let connection = guard.as_mut().ok_or(JadeError::NotConnected)?; - connection - .exchange_reassembled("sign_psbt", Some(request), CONFIRM_TIMEOUT) - .await? - }; - - let signed = Psbt::deserialize(&signed_bytes).map_err(|error| JadeError::InvalidPsbt { - error_details: format!("device returned an unparsable PSBT: {error}"), - })?; - verify_signed_psbt(&sent, &signed)?; - - Ok(STANDARD.encode(&signed_bytes)) - } - - /// Reject a PSBT the device would refuse or silently not sign. - async fn check_signable(&self, psbt: &Psbt, network: JadeNetwork) -> Result<(), JadeError> { - // Only SIGHASH_ALL and the taproot default are expected here. Anything - // else arriving over FFI is worth refusing rather than signing blindly. - for (index, input) in psbt.inputs.iter().enumerate() { - if let Some(sighash) = input.sighash_type { - let is_all = sighash - .ecdsa_hash_ty() - .map(|ty| ty == bitcoin::sighash::EcdsaSighashType::All) - .unwrap_or(false); - let is_default = sighash - .taproot_hash_ty() - .map(|ty| ty == bitcoin::sighash::TapSighashType::Default) - .unwrap_or(false); - if !is_all && !is_default { - return Err(JadeError::InvalidPsbt { - error_details: format!( - "input {index} requests an unsupported sighash type" - ), - }); - } - } - } - - // Without a matching fingerprint the device signs nothing and the - // failure only shows up as an opaque finalization error much later. - let Ok(device_fingerprint) = self.master_fingerprint(network).await else { - return Ok(()); - }; - let mut seen = Vec::new(); - let mut matched = false; - for input in &psbt.inputs { - for (fingerprint, _) in input.bip32_derivation.values() { - let rendered = format!("{fingerprint:08x}"); - if rendered == device_fingerprint { - matched = true; - } - seen.push(rendered); - } - for (_, (fingerprint, _)) in input.tap_key_origins.values() { - let rendered = format!("{fingerprint:08x}"); - if rendered == device_fingerprint { - matched = true; - } - seen.push(rendered); - } - } - if !seen.is_empty() && !matched { - seen.sort(); - seen.dedup(); - return Err(JadeError::FingerprintMismatch { - device: device_fingerprint, - psbt: seen.join(", "), - }); - } - Ok(()) - } -} - -/// Check what came back against what was sent. -fn verify_signed_psbt(sent: &Psbt, signed: &Psbt) -> Result<(), JadeError> { - if sent.unsigned_tx != signed.unsigned_tx { - return Err(JadeError::InvalidPsbt { - error_details: "device returned a different unsigned transaction".to_string(), - }); - } - if sent.inputs.len() != signed.inputs.len() || sent.outputs.len() != signed.outputs.len() { - return Err(JadeError::InvalidPsbt { - error_details: "device changed the number of inputs or outputs".to_string(), - }); - } - - for (index, (before, after)) in sent.inputs.iter().zip(signed.inputs.iter()).enumerate() { - if before.witness_utxo != after.witness_utxo { - return Err(JadeError::InvalidPsbt { - error_details: format!("device altered the witness UTXO of input {index}"), - }); - } - if before.non_witness_utxo != after.non_witness_utxo { - return Err(JadeError::InvalidPsbt { - error_details: format!("device altered the previous transaction of input {index}"), - }); - } - } - - let gained_signature = signed.inputs.iter().enumerate().any(|(index, input)| { - let before = &sent.inputs[index]; - input.partial_sigs.len() > before.partial_sigs.len() - || (input.final_script_witness.is_some() && before.final_script_witness.is_none()) - || (input.final_script_sig.is_some() && before.final_script_sig.is_none()) - || (input.tap_key_sig.is_some() && before.tap_key_sig.is_none()) - }); - if !gained_signature { - return Err(JadeError::NothingSigned); + let mut guard = self.session.lock().await; + let signed = guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .sign_psbt(network, &parsed) + .await?; + Ok(STANDARD.encode(signed.serialize())) } - - // Keep the secp context construction close to the other PSBT handling in - // this crate; verification only, no signing key material here. - let _ = Secp256k1::verification_only(); - Ok(()) } diff --git a/src/modules/jade/mod.rs b/src/modules/jade/mod.rs index feef285..ce2800c 100644 --- a/src/modules/jade/mod.rs +++ b/src/modules/jade/mod.rs @@ -1,18 +1,9 @@ //! Blockstream Jade hardware wallet integration. //! -//! Jade speaks a JSON-RPC shaped protocol encoded as CBOR over either Bluetooth -//! (the Nordic UART Service) or USB CDC serial. Unlike the `trezor` module, -//! which adapts an external crate, the protocol is implemented here. -//! -//! Layering, outermost first: -//! -//! - `src/lib.rs` exports thin `jade_*` async wrappers over a global manager. -//! - `implementation.rs` owns session state and the single active connection. -//! - `pinserver.rs` runs the blind pinserver exchange that unlocks the device. -//! - `transport.rs` frames requests onto a byte stream and correlates replies. -//! - `protocol.rs` is pure CBOR framing, envelopes and id correlation. -//! - `callbacks.rs` is the trait the native app implements to do Bluetooth I/O. -//! - `serial.rs` is a Rust side serial transport for desktop and Python. +//! The protocol lives in the `jade-client-rs` crate. This module is the FFI +//! adapter: it attaches UniFFI scaffolding to that crate's types, exposes the +//! transport contract the native application implements, and owns the session +//! state a free-function FFI surface implies. //! //! One hard rule for anything added here: no `#[uniffi::export]` item may be //! `cfg` gated. All three build scripts generate bindings from the host library @@ -20,26 +11,19 @@ //! generated Swift and Kotlin while being absent from the device library. mod callbacks; -mod errors; mod implementation; -mod path; -mod pinserver; -mod protocol; -#[cfg(not(any(target_os = "ios", target_os = "android")))] -mod serial; #[cfg(test)] mod tests; -mod transport; mod types; pub use callbacks::{ - jade_set_transport_callback, JadeNativeDevice, JadeTransportCallback, JadeTransportErrorCode, - JadeTransportReadResult, JadeTransportResult, + jade_set_transport_callback, JadeNativeDevice, JadeTransportCallback, JadeTransportReadResult, + JadeTransportResult, }; -pub use errors::JadeError; pub use implementation::JadeManager; +pub(crate) use types::account_type_to_variant; pub use types::{ - JadeAccount, JadeAccountExport, JadeAddressVariant, JadeDeviceInfo, JadeGetXpubParams, - JadeNetwork, JadePingStatus, JadeSignMessageParams, JadeSignPsbtParams, JadeSignedMessage, - JadeState, JadeTransportKind, JadeVerifyAddressParams, JadeVersionInfo, JadeXpubResponse, + JadeAccount, JadeAccountExport, JadeAddressVariant, JadeDeviceInfo, JadeError, JadeNetwork, + JadePingStatus, JadeSignedMessage, JadeState, JadeTransportErrorCode, JadeTransportKind, + JadeVersionInfo, JadeXpubResponse, }; diff --git a/src/modules/jade/path.rs b/src/modules/jade/path.rs deleted file mode 100644 index c196392..0000000 --- a/src/modules/jade/path.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! BIP32 path validation and lowering to Jade's wire representation. -//! -//! Paths cross the FFI as strings such as `m/84'/0'/0'/0/0`, matching every -//! other signer in this repo. Jade wants an array of `u32` with the hardened -//! bit already set. - -use std::str::FromStr; - -use bitcoin::bip32::DerivationPath; - -use super::errors::JadeError; - -/// Jade rejects paths deeper than this. -const MAX_DEPTH: usize = 8; - -/// Validate a derivation path string. -/// -/// `DerivationPath::from_str` alone is not enough. It accepts `""`, `"m"` and -/// `"m/"` as the master path, and it strips an optional `m/` prefix rather than -/// requiring one, so `"84'/0'/0'"` also parses. Either would be a silent -/// footgun here: an empty path handed to `sign_message` would sign with the -/// master key, and a prefix-less path means the app and this crate disagree -/// about what a path string is. -/// -/// `allow_master` opts in to the empty path, which is only wanted by the -/// deliberate master-fingerprint lookup. -pub(crate) fn validate(path: &str, allow_master: bool) -> Result<(), JadeError> { - let invalid = |reason: String| JadeError::InvalidPath { - error_details: reason, - }; - - let trimmed = path.trim(); - if trimmed.is_empty() { - return Err(invalid("path is empty".to_string())); - } - - let remainder = if trimmed == "m" { - "" - } else if let Some(rest) = trimmed.strip_prefix("m/") { - rest - } else { - return Err(invalid(format!("path must start with 'm/': {path}"))); - }; - - if remainder.is_empty() { - if allow_master { - return Ok(()); - } - return Err(invalid( - "the master path is not valid for this operation".to_string(), - )); - } - - let components: Vec<&str> = remainder.split('/').collect(); - if components.len() > MAX_DEPTH { - return Err(invalid(format!( - "path depth {} exceeds the maximum of {MAX_DEPTH}", - components.len() - ))); - } - - for (index, component) in components.iter().enumerate() { - if component.is_empty() { - return Err(invalid(format!("empty path component at index {index}"))); - } - let digits = component - .strip_suffix('\'') - .or_else(|| component.strip_suffix('h')) - .unwrap_or(component); - if digits.is_empty() || digits.parse::().is_err() { - return Err(invalid(format!( - "invalid path component '{component}' at index {index}" - ))); - } - } - - // Delegate the range check (index below 2^31) to the typed parser. - DerivationPath::from_str(trimmed) - .map(|_| ()) - .map_err(|error| invalid(format!("{path}: {error}"))) -} - -/// Validate and lower a path to the `u32` array Jade expects. -pub(crate) fn to_wire(path: &str, allow_master: bool) -> Result, JadeError> { - validate(path, allow_master)?; - let parsed = DerivationPath::from_str(path.trim()).map_err(|error| JadeError::InvalidPath { - error_details: format!("{path}: {error}"), - })?; - Ok(parsed.into_iter().map(|child| u32::from(*child)).collect()) -} - -/// The BIP44 purpose element of a path, if it has one. -/// -/// Used to check that the requested address variant agrees with the path, so a -/// caller cannot ask for a legacy address under an `m/84'` path. -pub(crate) fn purpose(path: &str) -> Option { - let wire = to_wire(path, true).ok()?; - // Strip the hardened bit before comparing against 44 / 49 / 84 / 86. - wire.first().map(|element| element & 0x7fff_ffff) -} diff --git a/src/modules/jade/pinserver.rs b/src/modules/jade/pinserver.rs deleted file mode 100644 index f5b9103..0000000 --- a/src/modules/jade/pinserver.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! The blind pinserver exchange that unlocks a PIN protected Jade. -//! -//! `auth_user` either returns `true`, meaning the device is already usable, or -//! it returns an `http_request` describing a call the host must make on the -//! device's behalf. The host performs it and feeds the response back through the -//! method named in `on-reply`, which is `pin`. The exchange is end to end -//! encrypted between device and pinserver, so the host never sees the PIN; its -//! role is purely to carry bytes. -//! -//! Two details here are load bearing and easy to get wrong: -//! -//! - The response body must be JSON decoded into a CBOR **map**. Firmware -//! requires `params` to be a map with a text `data` member and rejects -//! anything else, so forwarding raw HTTP bytes fails every unlock. -//! - An HTTP failure must still send a `pin` message, with no `params`. The -//! device is blocked indefinitely waiting for one; abandoning the loop leaves -//! it consuming the next unrelated request as the awaited reply, which puts -//! every subsequent call one message out of step. - -use std::net::IpAddr; -use std::time::Duration; - -use async_trait::async_trait; -use serde::Deserialize; - -use super::errors::JadeError; -use super::transport::JadeConnection; -use super::types::JadeNetwork; - -/// How long the whole unlock may take, including user PIN entry on the device. -const UNLOCK_TIMEOUT: Duration = Duration::from_secs(300); - -/// How long a single pinserver call may take. -const HTTP_TIMEOUT: Duration = Duration::from_secs(30); - -/// Upper bound on a pinserver response body. -const MAX_BODY_BYTES: u64 = 64 * 1024; - -/// Round trips the device may ask for before the host gives up. -const MAX_ROUND_TRIPS: usize = 4; - -/// The pinserver Blockstream operates, and the only host expected in practice. -const DEFAULT_PINSERVER_HOST: &str = "jadepin.blockstream.com"; - -/// The one method the device is allowed to name in `on-reply`. -const EXPECTED_ON_REPLY: &str = "pin"; - -/// Performs the pinserver call. -/// -/// A trait so tests can drive the whole unlock with no network access. It is -/// deliberately internal: there is no FFI seam for swapping the implementation. -#[async_trait] -pub(crate) trait PinServerHttp: Send + Sync { - /// POST or GET `body` to the chosen URL and return the response bytes. - async fn request( - &self, - url: &str, - method: &str, - body: Option, - ) -> Result, JadeError>; -} - -/// The real implementation, over `reqwest`. -pub(crate) struct ReqwestPinServer; - -#[async_trait] -impl PinServerHttp for ReqwestPinServer { - async fn request( - &self, - url: &str, - method: &str, - body: Option, - ) -> Result, JadeError> { - let parsed = validate_url(url)?; - let address = resolve_and_validate(&parsed).await?; - - let host = parsed.host_str().unwrap_or_default().to_string(); - let client = reqwest::Client::builder() - // The URL list comes from the device. Following a redirect would let - // a tampered unit bounce the host somewhere the checks above already - // rejected. - .redirect(reqwest::redirect::Policy::none()) - .connect_timeout(HTTP_TIMEOUT) - .timeout(HTTP_TIMEOUT) - // Pin the socket to the address that was validated, so a second DNS - // lookup cannot return a different one. - .resolve_to_addrs(&host, &[address]) - .build() - .map_err(|error| JadeError::PinServerError { - error_details: format!("could not build the http client: {error}"), - })?; - - let request = match method { - "POST" => { - let builder = client.post(parsed.clone()); - match body { - Some(body) => builder - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body), - None => builder, - } - } - "GET" => client.get(parsed.clone()), - other => { - return Err(JadeError::PinServerError { - error_details: format!("unsupported http method {other}"), - }) - } - }; - - // reqwest embeds the full URL in its Display output, so it is stripped - // before the error reaches a log or the application. - let response = request.send().await.map_err(|error| { - let error = error.without_url(); - JadeError::PinServerError { - error_details: format!("pin server request failed: {error}"), - } - })?; - - if !response.status().is_success() { - return Err(JadeError::PinServerError { - error_details: format!("pin server returned status {}", response.status()), - }); - } - - if let Some(length) = response.content_length() { - if length > MAX_BODY_BYTES { - return Err(JadeError::PinServerError { - error_details: format!( - "pin server response of {length} bytes exceeds the {MAX_BODY_BYTES} byte limit" - ), - }); - } - } - - let bytes = response.bytes().await.map_err(|error| { - let error = error.without_url(); - JadeError::PinServerError { - error_details: format!("could not read the pin server response: {error}"), - } - })?; - - // Re-check after reading, because a response without Content-Length - // slips past the check above. - if bytes.len() as u64 > MAX_BODY_BYTES { - return Err(JadeError::PinServerError { - error_details: format!( - "pin server response exceeds the {MAX_BODY_BYTES} byte limit" - ), - }); - } - - Ok(bytes.to_vec()) - } -} - -/// Reject any URL this host should not be making a request to. -fn validate_url(url: &str) -> Result { - let reject = |reason: &str| JadeError::PinServerError { - error_details: format!("refusing pin server url: {reason}"), - }; - - let parsed = url::Url::parse(url).map_err(|error| reject(&format!("unparsable ({error})")))?; - - if parsed.scheme() != "https" { - return Err(reject("only https is supported")); - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(reject("credentials are not allowed")); - } - if let Some(port) = parsed.port() { - if port != 443 { - return Err(reject("only port 443 is allowed")); - } - } - let Some(host) = parsed.host_str() else { - return Err(reject("no host")); - }; - if host.ends_with(".onion") { - return Err(reject("onion services are not supported")); - } - if !host.eq_ignore_ascii_case(DEFAULT_PINSERVER_HOST) { - // A second-hand or tampered unit can carry a pinserver its previous - // owner configured, so this is worth surfacing even though a custom - // pinserver is a legitimate configuration. - log::warn!("[jade] using a non-default pin server host"); - } - Ok(parsed) -} - -/// Resolve the host and reject addresses that should never be reachable here. -async fn resolve_and_validate(url: &url::Url) -> Result { - let host = url.host_str().unwrap_or_default().to_string(); - let port = url.port().unwrap_or(443); - let target = format!("{host}:{port}"); - - let addresses = tokio::task::spawn_blocking(move || { - use std::net::ToSocketAddrs; - target - .to_socket_addrs() - .map(|iter| iter.collect::>()) - }) - .await - .map_err(|error| JadeError::PinServerError { - error_details: format!("dns task failed: {error}"), - })? - .map_err(|error| JadeError::PinServerError { - error_details: format!("could not resolve the pin server host: {error}"), - })?; - - addresses - .into_iter() - .find(|address| is_public(address.ip())) - .ok_or_else(|| JadeError::PinServerError { - error_details: "pin server host resolved to no usable public address".to_string(), - }) -} - -/// Whether an address is one this host should send a device-directed request to. -fn is_public(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - let octets = v4.octets(); - // 100.64.0.0/10, carrier grade NAT. There is no stable std helper. - let is_cgnat = octets[0] == 100 && (64..128).contains(&octets[1]); - !(v4.is_private() - || v4.is_loopback() - || v4.is_link_local() - || v4.is_broadcast() - || v4.is_documentation() - || v4.is_unspecified() - || v4.is_multicast() - || is_cgnat) - } - IpAddr::V6(v6) => { - let segments = v6.segments(); - // fc00::/7 unique local, fe80::/10 link local. - let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; - let is_link_local = (segments[0] & 0xffc0) == 0xfe80; - !(v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - || v6.to_ipv4_mapped().is_some() - || is_unique_local - || is_link_local) - } - } -} - -// ============================================================================ -// Wire shapes -// ============================================================================ - -#[derive(Debug, Deserialize)] -struct HttpRequestEnvelope { - http_request: HttpRequest, -} - -#[derive(Debug, Deserialize)] -struct HttpRequest { - params: HttpRequestParams, - #[serde(rename = "on-reply")] - on_reply: String, -} - -#[derive(Debug, Deserialize)] -struct HttpRequestParams { - urls: Vec, - method: String, - #[serde(default)] - accept: Option, - #[serde(default)] - data: Option, -} - -#[derive(serde::Serialize)] -struct AuthUserParams<'a> { - network: &'a str, - epoch: u64, -} - -/// Run `auth_user` and, if the device asks, the pinserver exchange. -pub(crate) async fn run_unlock( - connection: &mut JadeConnection, - network: JadeNetwork, - http: &dyn PinServerHttp, - epoch: u64, -) -> Result<(), JadeError> { - let params = AuthUserParams { - network: network.wire_name(), - epoch, - }; - let reply = connection - .exchange("auth_user", Some(params), UNLOCK_TIMEOUT) - .await?; - let mut result = reply.into_result(super::types::MIN_JADE_FIRMWARE)?; - - for _ in 0..MAX_ROUND_TRIPS { - // A boolean result ends the exchange either way. - if let Some(unlocked) = result.as_bool() { - return if unlocked { - Ok(()) - } else { - Err(JadeError::InvalidPin) - }; - } - - let envelope: HttpRequestEnvelope = result - .deserialized() - .map_err(|error| JadeError::protocol(format!("unexpected auth_user reply: {error}")))?; - let request = envelope.http_request; - - // The method name is supplied by the device. Dispatching on it blindly - // would let a device make the host invoke any RPC with chosen params. - if request.on_reply != EXPECTED_ON_REPLY { - return Err(JadeError::protocol(format!( - "device asked the host to call '{}', expected '{EXPECTED_ON_REPLY}'", - request.on_reply - ))); - } - - let body = perform(http, &request.params).await; - result = send_pin(connection, body).await?; - } - - Err(JadeError::PinServerError { - error_details: format!("unlock did not finish within {MAX_ROUND_TRIPS} round trips"), - }) -} - -/// Make the call the device asked for, returning the params for the follow-up. -/// -/// A failure yields `None`, which becomes a `pin` message with no params. That -/// is what the device expects, and it is what keeps the two sides in step. -async fn perform(http: &dyn PinServerHttp, params: &HttpRequestParams) -> Option { - let use_json = matches!( - params.accept.as_deref(), - Some("json") | Some("application/json") - ); - - let url = params - .urls - .iter() - .find(|candidate| !is_onion(candidate)) - .or_else(|| params.urls.first())?; - - // Firmware wraps the payload in an extra layer when it wants JSON, so - // `data` is a CBOR map that has to be rendered as a JSON document. - let body = match (¶ms.data, use_json) { - (Some(data), true) => match cbor_to_json(data) { - Ok(json) => Some(json.to_string()), - Err(error) => { - log::warn!("[jade] could not render pin server payload: {error}"); - return None; - } - }, - (Some(ciborium::Value::Text(text)), false) => Some(text.clone()), - _ => None, - }; - - let response = match http.request(url, ¶ms.method, body).await { - Ok(response) => response, - Err(error) => { - log::warn!("[jade] pin server call failed: {error}"); - return None; - } - }; - - if !use_json { - return Some(ciborium::Value::Bytes(response)); - } - - match serde_json::from_slice::(&response) { - Ok(json) if json.is_object() => json_to_cbor(&json).ok(), - Ok(_) => { - log::warn!("[jade] pin server returned a non-object json body"); - None - } - Err(error) => { - log::warn!("[jade] pin server returned invalid json: {error}"); - None - } - } -} - -/// Send the follow-up `pin` message. -async fn send_pin( - connection: &mut JadeConnection, - params: Option, -) -> Result { - let reply = connection.exchange("pin", params, UNLOCK_TIMEOUT).await?; - reply.into_result(super::types::MIN_JADE_FIRMWARE) -} - -/// Whether a URL's host is an onion service. -/// -/// A suffix test on the whole URL does not work: firmware sends -/// `http://<...>.onion/get_pin`, so the string ends with the document name. -fn is_onion(url: &str) -> bool { - url::Url::parse(url) - .ok() - .and_then(|parsed| parsed.host_str().map(|host| host.ends_with(".onion"))) - .unwrap_or(false) -} - -/// Render a CBOR value as JSON for the pinserver request body. -pub(crate) fn cbor_to_json(value: &ciborium::Value) -> Result { - let unsupported = |what: &str| JadeError::protocol(format!("cannot render {what} as json")); - - Ok(match value { - ciborium::Value::Null => serde_json::Value::Null, - ciborium::Value::Bool(inner) => serde_json::Value::Bool(*inner), - ciborium::Value::Text(inner) => serde_json::Value::String(inner.clone()), - ciborium::Value::Integer(inner) => { - let as_i128: i128 = (*inner).into(); - let number = i64::try_from(as_i128).map_err(|_| unsupported("an oversized integer"))?; - serde_json::Value::Number(number.into()) - } - ciborium::Value::Array(items) => serde_json::Value::Array( - items - .iter() - .map(cbor_to_json) - .collect::, _>>()?, - ), - ciborium::Value::Map(entries) => { - let mut map = serde_json::Map::with_capacity(entries.len()); - for (key, value) in entries { - let key = key - .as_text() - .ok_or_else(|| unsupported("a map with a non-text key"))?; - map.insert(key.to_string(), cbor_to_json(value)?); - } - serde_json::Value::Object(map) - } - // The pinserver protocol carries binary as hex or base64 text, so a raw - // byte string here means the device sent something unexpected. - ciborium::Value::Bytes(_) => return Err(unsupported("a byte string")), - ciborium::Value::Float(_) => return Err(unsupported("a float")), - _ => return Err(unsupported("an unrecognised cbor value")), - }) -} - -/// Convert the pinserver's JSON reply into the CBOR map the device expects. -pub(crate) fn json_to_cbor(value: &serde_json::Value) -> Result { - Ok(match value { - serde_json::Value::Null => ciborium::Value::Null, - serde_json::Value::Bool(inner) => ciborium::Value::Bool(*inner), - serde_json::Value::String(inner) => ciborium::Value::Text(inner.clone()), - serde_json::Value::Number(number) => { - if let Some(inner) = number.as_i64() { - ciborium::Value::Integer(inner.into()) - } else { - return Err(JadeError::protocol("cannot represent a json float in cbor")); - } - } - serde_json::Value::Array(items) => ciborium::Value::Array( - items - .iter() - .map(json_to_cbor) - .collect::, _>>()?, - ), - serde_json::Value::Object(entries) => ciborium::Value::Map( - entries - .iter() - .map(|(key, value)| { - json_to_cbor(value).map(|value| (ciborium::Value::Text(key.clone()), value)) - }) - .collect::, _>>()?, - ), - }) -} diff --git a/src/modules/jade/protocol.rs b/src/modules/jade/protocol.rs deleted file mode 100644 index 673d72d..0000000 --- a/src/modules/jade/protocol.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Jade wire protocol: CBOR framing, request and reply envelopes, id correlation. -//! -//! Jade speaks a JSON-RPC shaped protocol encoded as CBOR. There is no length -//! prefix and no framing bytes: messages are self-delimiting CBOR maps written -//! back to back on the stream. A reader therefore has to buffer whatever the -//! transport hands it and attempt an incremental decode after each read until -//! one complete item is present. -//! -//! This module is pure. It performs no I/O and holds no state beyond a request -//! id counter, which makes the framing and correlation rules directly testable. - -use serde::{Deserialize, Serialize}; - -use super::errors::JadeError; - -/// Upper bound on a single buffered frame. -/// -/// Jade's own `MAX_OUTPUT_MSG_SIZE` is 3 KiB, so this is generous. The cap -/// exists because a corrupt length header (for example `0x5b` followed by eight -/// `0xff` bytes) decodes as a byte string of nearly 2^64 bytes. Without a cap, -/// `skip()` would report "need more input" forever while the read buffer grew -/// without bound. -pub(crate) const MAX_FRAME_BYTES: usize = 64 * 1024; - -/// Take the first complete CBOR item out of `buf`, if one has arrived. -/// -/// Returns `Ok(None)` when the buffer holds a valid but truncated item and the -/// caller should read more. Returns `Err` when the buffer cannot be a valid -/// frame, having cleared the buffer, because there is no way to find the next -/// frame boundary in a corrupt stream. -pub(crate) fn try_take_frame(buf: &mut Vec) -> Result>, JadeError> { - if buf.is_empty() { - return Ok(None); - } - - // A fresh decoder per attempt. Reusing one across reads would carry its - // position forward and silently shift every subsequent frame boundary. - let mut decoder = minicbor::Decoder::new(buf); - match decoder.skip() { - Ok(()) => { - let length = decoder.position(); - Ok(Some(buf.drain(..length).collect())) - } - Err(error) if error.is_end_of_input() => { - if buf.len() > MAX_FRAME_BYTES { - buf.clear(); - return Err(JadeError::protocol(format!( - "incomplete frame exceeded {MAX_FRAME_BYTES} bytes" - ))); - } - Ok(None) - } - Err(error) => { - buf.clear(); - Err(JadeError::protocol(format!( - "malformed CBOR frame: {error}" - ))) - } - } -} - -/// Generates request ids for one connection. -/// -/// Jade caps ids at 16 characters (`MAXLEN_ID`), and `jadepy` asserts strictly -/// fewer than 16, so the counter wraps well before a `u64` would overflow the -/// limit. The counter is per connection rather than process wide: that keeps -/// ids deterministic inside a single test, and stops an id from encoding how -/// many operations the process has performed. -#[derive(Debug, Default)] -pub(crate) struct RequestIds { - next: u64, -} - -impl RequestIds { - pub(crate) fn new() -> Self { - Self { next: 0 } - } - - pub(crate) fn next_id(&mut self) -> String { - // Wrap at 15 digits so the rendered id always fits Jade's 16 character - // limit, and start at 1 so an id is never the empty string. - self.next = (self.next % 999_999_999_999_999) + 1; - self.next.to_string() - } -} - -/// A request being sent to the device. -/// -/// `params` is skipped entirely when absent rather than encoded as CBOR null. -/// Jade reads parameters with typed getters that treat a null as a missing -/// value but then fail with `BAD_PARAMETERS`, so an explicit null is worse than -/// no key at all. The blind pinserver flow also depends on being able to send -/// `pin` with no params, which is how the host reports an HTTP failure. -#[derive(Debug, Serialize)] -pub(crate) struct JadeRequest<'a, P: Serialize> { - pub id: &'a str, - pub method: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option

, -} - -/// Encode a request to CBOR. -pub(crate) fn encode_request( - id: &str, - method: &str, - params: Option

, -) -> Result, JadeError> { - let request = JadeRequest { id, method, params }; - let mut encoded = Vec::new(); - ciborium::into_writer(&request, &mut encoded) - .map_err(|error| JadeError::protocol(format!("failed to encode {method}: {error}")))?; - Ok(encoded) -} - -/// The error member of a reply. -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub(crate) struct JadeRpcError { - pub code: i64, - #[serde(default)] - pub message: String, - /// Jade writes this with `cbor_encode_byte_string`, so it must be read as a - /// byte string rather than through the generic `Vec` deserializer. - #[serde(default)] - pub data: Option, -} - -/// A decoded reply frame. -/// -/// Every field is optional because the device also emits unsolicited `{"log": -/// ...}` frames on the same stream. Those carry no `id`, and a required `id` -/// field would make a single device log line fail the whole decode. -#[derive(Debug, Clone, Deserialize)] -pub(crate) struct JadeReply { - #[serde(default)] - pub id: Option, - #[serde(default)] - pub result: Option, - #[serde(default)] - pub error: Option, - #[serde(default)] - pub seqnum: Option, - #[serde(default)] - pub seqlen: Option, -} - -/// Decode one complete frame. -pub(crate) fn decode_reply(frame: &[u8]) -> Result { - ciborium::from_reader(frame) - .map_err(|error| JadeError::protocol(format!("failed to decode reply: {error}"))) -} - -/// The id Jade uses when it cannot recover the id of the request it is -/// rejecting, for example when the request was never parsed as valid CBOR or -/// exceeded the device's input buffer. -pub(crate) const UNATTRIBUTED_ID: &str = "00"; - -/// What to do with a decoded reply, given the request currently outstanding. -#[derive(Debug)] -pub(crate) enum ReplyMatch { - /// The reply for the outstanding request. - Matched(JadeReply), - /// A terminal error the device could not attribute to a request id. Jade - /// sends these with id "00" when it rejects a message before recovering its - /// id. Treating them as unmatched and ignoring them would turn every such - /// rejection into a full length timeout. - Unattributed(JadeRpcError), - /// Not for us. A device log frame, or a late reply to a request that has - /// already timed out. Discard and keep reading rather than failing, so one - /// stale reply does not poison the next operation. - Ignore, -} - -/// Classify a reply against the outstanding request id. -pub(crate) fn classify(reply: JadeReply, outstanding_id: &str) -> ReplyMatch { - match reply.id.as_deref() { - Some(id) if id == outstanding_id => ReplyMatch::Matched(reply), - Some(UNATTRIBUTED_ID) => match reply.error { - Some(error) => ReplyMatch::Unattributed(error), - None => ReplyMatch::Ignore, - }, - _ => ReplyMatch::Ignore, - } -} - -impl JadeReply { - /// Take the result, converting an error member into a typed error. - pub(crate) fn into_result(self, min_firmware: &str) -> Result { - if let Some(error) = self.error { - return Err(JadeError::from_rpc(error.code, error.message, min_firmware)); - } - self.result - .ok_or_else(|| JadeError::protocol("reply carried neither result nor error")) - } -} - -/// Read a binary result. -/// -/// Binary values must come off the `ciborium::Value` as bytes rather than -/// through `Value::deserialized::>()`. The `Value` deserializer maps -/// `deserialize_seq` onto `Value::Array` only, so a CBOR byte string would be -/// rejected as a type mismatch. -pub(crate) fn result_bytes(value: &ciborium::Value) -> Result, JadeError> { - value - .as_bytes() - .map(|bytes| bytes.to_vec()) - .ok_or_else(|| JadeError::protocol("expected a byte string result")) -} - -/// Read a text result. -pub(crate) fn result_text(value: &ciborium::Value) -> Result { - value - .as_text() - .map(str::to_string) - .ok_or_else(|| JadeError::protocol("expected a text result")) -} - -/// Read a boolean result. -pub(crate) fn result_bool(value: &ciborium::Value) -> Result { - value - .as_bool() - .ok_or_else(|| JadeError::protocol("expected a boolean result")) -} diff --git a/src/modules/jade/serial.rs b/src/modules/jade/serial.rs deleted file mode 100644 index 3bfd52e..0000000 --- a/src/modules/jade/serial.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! USB CDC serial transport, for desktop and the Python bindings. -//! -//! Not built for iOS, which has no USB serial, or for Android, where the -//! application drives USB through the transport callback. Nothing in this file -//! is exported over FFI: the bindings are generated from the host library, so a -//! platform gated export would appear in the generated Swift and Kotlin while -//! being absent from the device library. - -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use async_trait::async_trait; -use serialport::SerialPort; - -use super::callbacks::JadeNativeDevice; -use super::errors::JadeError; -use super::transport::JadeTransport; -use super::types::JadeTransportKind; - -/// Jade's serial link speed. -const BAUD_RATE: u32 = 115_200; - -/// Serial has no MTU, but chunking keeps writes off the stack and matches the -/// Bluetooth path closely enough that both exercise the same code. -const CHUNK_BYTES: usize = 509; - -/// USB vendor and product pairs seen on Jade and Jade Plus units, including the -/// bridge chips used by DIY builds. -const KNOWN_USB_IDS: &[(u16, u16)] = &[ - (0x10c4, 0xea60), // Silicon Labs CP210x, Jade v1 - (0x1a86, 0x55d4), // WCH CH9102 - (0x0403, 0x6001), // FTDI FT232 - (0x1a86, 0x7523), // WCH CH340 - (0x303a, 0x4001), // Espressif native USB, Jade Plus - (0x303a, 0x1001), // Espressif USB serial/JTAG -]; - -/// Discover attached Jade units. -/// -/// Only ports whose USB descriptor matches a known Jade bridge are returned, so -/// a modem or GPS receiver on the same machine is not offered as a Jade. -pub(crate) fn enumerate_devices() -> Vec { - // serialport's Linux path without libudev reads /sys/class/tty and panics - // outright if it is missing. A wallet library must not carry that risk. - #[cfg(target_os = "linux")] - if !std::path::Path::new("/sys/class/tty").exists() { - log::warn!("[jade] /sys/class/tty is missing, skipping serial enumeration"); - return Vec::new(); - } - - let ports = match serialport::available_ports() { - Ok(ports) => ports, - Err(error) => { - log::warn!("[jade] could not enumerate serial ports: {error}"); - return Vec::new(); - } - }; - - ports - .into_iter() - .filter_map(|port| { - let serialport::SerialPortType::UsbPort(info) = port.port_type else { - return None; - }; - if !KNOWN_USB_IDS.contains(&(info.vid, info.pid)) { - return None; - } - Some(JadeNativeDevice { - path: port.port_name, - transport: JadeTransportKind::Serial, - name: info.product.clone(), - serial_number: info.serial_number.clone(), - }) - }) - .collect() -} - -/// A serial link to a device. -pub(crate) struct SerialTransport { - /// A std mutex rather than a tokio one: the guard is taken inside - /// `spawn_blocking`, where a tokio guard could not be held. - port: Arc>>, -} - -impl SerialTransport { - pub(crate) fn open(path: &str) -> Result { - let mut port = serialport::new(path, BAUD_RATE) - .timeout(Duration::from_millis(250)) - // Asserting DTR or RTS resets the ESP32 on several of the bridge - // chips above, so the line has to come up with both clear. - .dtr_on_open(false) - .open() - .map_err(|error| JadeError::ConnectionError { - error_details: format!("could not open {path}: {error}"), - })?; - - if let Err(error) = port.write_request_to_send(false) { - log::warn!("[jade] could not clear RTS on {path}: {error}"); - } - - Ok(Self { - port: Arc::new(Mutex::new(port)), - }) - } -} - -#[async_trait] -impl JadeTransport for SerialTransport { - async fn write_all(&self, data: Vec) -> Result<(), JadeError> { - let port = Arc::clone(&self.port); - tokio::task::spawn_blocking(move || { - let mut port = port - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for chunk in data.chunks(CHUNK_BYTES) { - std::io::Write::write_all(&mut *port, chunk).map_err(|error| { - JadeError::transport(format!("serial write failed: {error}")) - })?; - } - std::io::Write::flush(&mut *port) - .map_err(|error| JadeError::transport(format!("serial flush failed: {error}"))) - }) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("serial write task failed: {error}"), - })? - } - - async fn read_some(&self, timeout: Duration) -> Result, JadeError> { - let port = Arc::clone(&self.port); - tokio::task::spawn_blocking(move || { - let mut port = port - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Err(error) = port.set_timeout(timeout) { - log::debug!("[jade] could not set the serial timeout: {error}"); - } - - let mut buffer = vec![0u8; 4096]; - match std::io::Read::read(&mut *port, &mut buffer) { - Ok(read) => { - buffer.truncate(read); - Ok(buffer) - } - // A timeout means nothing arrived, which is the normal state - // while the user is deciding on the device. - Err(error) if error.kind() == std::io::ErrorKind::TimedOut => Ok(Vec::new()), - Err(error) => Err(JadeError::transport(format!("serial read failed: {error}"))), - } - }) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("serial read task failed: {error}"), - })? - } - - async fn close(&self) -> Result<(), JadeError> { - let port = Arc::clone(&self.port); - tokio::task::spawn_blocking(move || { - let mut port = port - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // Leaving DTR or RTS asserted on close resets the device. - let _ = port.write_data_terminal_ready(false); - let _ = port.write_request_to_send(false); - }) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("serial close task failed: {error}"), - }) - } -} diff --git a/src/modules/jade/tests.rs b/src/modules/jade/tests.rs index 951170f..c955e96 100644 --- a/src/modules/jade/tests.rs +++ b/src/modules/jade/tests.rs @@ -1,1267 +1,188 @@ -use super::errors::{rpc_code, JadeError}; -use super::protocol::{ - classify, decode_reply, encode_request, result_bool, result_bytes, result_text, try_take_frame, - JadeReply, ReplyMatch, RequestIds, MAX_FRAME_BYTES, +//! Tests for the FFI adapter. +//! +//! Protocol level behaviour (framing, correlation, the unlock exchange, PSBT +//! checks) is tested in the `jade-client-rs` crate. What is left here is the +//! adapter: the account type mapping, and the bridge from the foreign callback +//! onto the crate's transport trait. + +use super::callbacks::{ + CallbackTransport, JadeNativeDevice, JadeTransportCallback, JadeTransportReadResult, + JadeTransportResult, }; -use serde::Serialize; - -// ============================================================================ -// Helpers -// ============================================================================ - -/// Encode a CBOR map from `(key, value)` pairs. -fn cbor_map(entries: Vec<(&str, ciborium::Value)>) -> Vec { - let value = ciborium::Value::Map( - entries - .into_iter() - .map(|(key, value)| (ciborium::Value::Text(key.to_string()), value)) - .collect(), - ); - let mut encoded = Vec::new(); - ciborium::into_writer(&value, &mut encoded).unwrap(); - encoded -} - -fn text(value: &str) -> ciborium::Value { - ciborium::Value::Text(value.to_string()) -} - -fn int(value: i64) -> ciborium::Value { - ciborium::Value::Integer(value.into()) -} - -fn reply_frame(id: &str, result: ciborium::Value) -> Vec { - cbor_map(vec![("id", text(id)), ("result", result)]) -} - -fn error_frame(id: &str, code: i64, message: &str) -> Vec { - cbor_map(vec![ - ("id", text(id)), - ( - "error", - ciborium::Value::Map(vec![ - (text("code"), int(code)), - (text("message"), text(message)), - ]), - ), - ]) -} - -// ============================================================================ -// Byte string encoding -// -// This is the single easiest thing to get silently wrong. serde encodes a plain -// `Vec` as a CBOR array of integers, but Jade reads `psbt` and `entropy` -// with `rpc_get_bytes_ptr`, which requires major type 2. Without -// `#[serde(with = "serde_bytes")]` the device rejects every sign_psbt and -// add_entropy with BAD_PARAMETERS, and nothing catches it until real hardware. -// ============================================================================ - -#[derive(Serialize)] -struct BytesParams { - #[serde(with = "serde_bytes")] - psbt: Vec, -} - -#[derive(Serialize)] -struct NaiveBytesParams { - psbt: Vec, -} - -/// Locate the CBOR header byte immediately following the text key `psbt`. -fn byte_after_psbt_key(encoded: &[u8]) -> u8 { - // "psbt" as a CBOR text string of length 4 is 0x64 followed by the ASCII. - let key = [0x64, b'p', b's', b'b', b't']; - let position = encoded - .windows(key.len()) - .position(|window| window == key) - .expect("psbt key not found in encoding"); - encoded[position + key.len()] -} - -#[test] -fn binary_params_encode_as_cbor_byte_strings() { - let params = BytesParams { - psbt: vec![0x70, 0x73, 0x62, 0x74, 0xff], - }; - let encoded = encode_request("1", "sign_psbt", Some(params)).unwrap(); - - // Major type 2 (byte string) occupies 0x40..=0x5f. - let header = byte_after_psbt_key(&encoded); - assert!( - (0x40..=0x5f).contains(&header), - "expected a byte string header, got {header:#04x}" - ); -} - -#[test] -fn binary_params_without_serde_bytes_would_encode_as_an_array() { - // Guards the reason the annotation exists. If ciborium ever started writing - // byte strings for a plain Vec, this test would fail and the annotation - // could be revisited. - let params = NaiveBytesParams { - psbt: vec![0x70, 0x73, 0x62, 0x74, 0xff], - }; - let encoded = encode_request("1", "sign_psbt", Some(params)).unwrap(); - - // Major type 4 (array) occupies 0x80..=0x9f. - let header = byte_after_psbt_key(&encoded); - assert!( - (0x80..=0x9f).contains(&header), - "expected an array header, got {header:#04x}" - ); -} - -#[test] -fn absent_params_are_omitted_rather_than_encoded_as_null() { - // Jade's typed getters treat a CBOR null as a missing value and then fail - // with BAD_PARAMETERS, so the key must not be present at all. - let encoded = encode_request("7", "ping", Option::<()>::None).unwrap(); - assert!( - !encoded - .windows(6) - .any(|w| w == [0x66, b'p', b'a', b'r', b'a', b'm']), - "params key should be absent" - ); - let reply: JadeReply = decode_reply(&encoded).unwrap(); - assert_eq!(reply.id.as_deref(), Some("7")); -} - -// ============================================================================ -// Framing -// ============================================================================ - -#[test] -fn a_frame_split_across_reads_reassembles() { - let frame = reply_frame("1", text("xpub")); - let mut buf = Vec::new(); - - for chunk in frame.chunks(3) { - // Every partial state must report "need more bytes", never a frame. - if buf.len() + chunk.len() < frame.len() { - buf.extend_from_slice(chunk); - assert!(try_take_frame(&mut buf).unwrap().is_none()); - } else { - buf.extend_from_slice(chunk); - } - } - - let taken = try_take_frame(&mut buf).unwrap().expect("frame"); - assert_eq!(taken, frame); - assert!(buf.is_empty()); -} - -#[test] -fn two_frames_in_one_read_are_decoded_separately() { - let first = reply_frame("1", text("one")); - let second = reply_frame("2", text("two")); - let mut buf = [first.clone(), second.clone()].concat(); - - assert_eq!(try_take_frame(&mut buf).unwrap().unwrap(), first); - assert_eq!(try_take_frame(&mut buf).unwrap().unwrap(), second); - assert!(try_take_frame(&mut buf).unwrap().is_none()); -} - -#[test] -fn an_empty_buffer_needs_more_bytes() { - let mut buf = Vec::new(); - assert!(try_take_frame(&mut buf).unwrap().is_none()); -} +use super::types::{account_type_to_variant, JadeAddressVariant, JadeTransportKind}; +use crate::onchain::AccountType; +use jade_client_rs::{JadeError, JadeTransport, MAX_CHUNK_BYTES}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; #[test] -fn a_corrupt_length_header_is_capped_rather_than_buffered_forever() { - // 0x5b announces a byte string whose length is the next eight bytes, here - // nearly 2^64. skip() will report end of input on every call, so without a - // cap the read buffer would grow without bound. - let mut buf = vec![0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; - assert!(try_take_frame(&mut buf).unwrap().is_none()); - - buf.extend(std::iter::repeat_n(0u8, MAX_FRAME_BYTES + 1)); - let error = try_take_frame(&mut buf).unwrap_err(); - assert!(matches!(error, JadeError::ProtocolError { .. })); - assert!( - buf.is_empty(), - "buffer must be cleared on a framing failure" - ); -} - -#[test] -fn malformed_cbor_errors_rather_than_returning_a_truncated_frame() { - // 0x1f is a reserved additional-information value for major type 0. - let mut buf = vec![0x1f, 0x00, 0x00]; - let error = try_take_frame(&mut buf).unwrap_err(); - assert!(matches!(error, JadeError::ProtocolError { .. })); - assert!(buf.is_empty()); -} - -// ============================================================================ -// Reply correlation -// ============================================================================ - -#[test] -fn a_log_frame_carrying_no_id_is_ignored() { - // The device emits these unsolicited on the same stream. - let frame = cbor_map(vec![( - "log", - ciborium::Value::Bytes(b"I (123) main: boot".to_vec()), - )]); - let reply = decode_reply(&frame).unwrap(); - assert!(reply.id.is_none()); - assert!(matches!(classify(reply, "1"), ReplyMatch::Ignore)); -} - -#[test] -fn a_stale_reply_is_ignored_rather_than_failing_the_next_request() { - let reply = decode_reply(&reply_frame("1", text("old"))).unwrap(); - assert!(matches!(classify(reply, "2"), ReplyMatch::Ignore)); -} - -#[test] -fn a_matching_reply_is_delivered() { - let reply = decode_reply(&reply_frame("2", text("xpub"))).unwrap(); - match classify(reply, "2") { - ReplyMatch::Matched(reply) => { - assert_eq!(result_text(&reply.result.unwrap()).unwrap(), "xpub"); - } - other => panic!("expected a match, got {other:?}"), - } -} - -#[test] -fn an_unattributed_error_resolves_the_outstanding_request() { - // Jade replies with id "00" when it rejects a message before it can recover - // the request id, for example an oversize or malformed request. Ignoring - // these would turn every such rejection into a full length timeout. - let frame = error_frame( - super::protocol::UNATTRIBUTED_ID, - rpc_code::INVALID_REQUEST, - "Invalid RPC Request message", - ); - let reply = decode_reply(&frame).unwrap(); - match classify(reply, "7") { - ReplyMatch::Unattributed(error) => { - assert_eq!(error.code, rpc_code::INVALID_REQUEST); - } - other => panic!("expected an unattributed error, got {other:?}"), - } -} - -#[test] -fn an_unattributed_frame_without_an_error_member_is_ignored() { - let reply = decode_reply(&reply_frame(super::protocol::UNATTRIBUTED_ID, text("x"))).unwrap(); - assert!(matches!(classify(reply, "7"), ReplyMatch::Ignore)); -} - -// ============================================================================ -// Error mapping -// ============================================================================ - -#[test] -fn rpc_error_codes_map_to_typed_errors() { +fn account_types_map_to_descriptor_variants() { let cases = [ - (rpc_code::USER_CANCELLED, JadeError::UserCancelled), - (rpc_code::HW_LOCKED, JadeError::DeviceLocked), + (AccountType::Legacy, JadeAddressVariant::Pkh), + (AccountType::WrappedSegwit, JadeAddressVariant::ShWpkh), + (AccountType::NativeSegwit, JadeAddressVariant::Wpkh), + (AccountType::Taproot, JadeAddressVariant::Tr), ]; - for (code, expected) in cases { - let reply = decode_reply(&error_frame("1", code, "denied")).unwrap(); - assert_eq!(reply.into_result("1.0.0").unwrap_err(), expected); + for (account_type, expected) in cases { + assert_eq!(account_type_to_variant(account_type), expected); } - - let reply = decode_reply(&error_frame("1", rpc_code::NETWORK_MISMATCH, "wrong net")).unwrap(); - assert!(matches!( - reply.into_result("1.0.0").unwrap_err(), - JadeError::NetworkMismatch { .. } - )); - - // An old device answering UNKNOWN_METHOD is reporting its age, not a host - // bug, so it must not surface as a generic protocol error. - let reply = decode_reply(&error_frame("1", rpc_code::UNKNOWN_METHOD, "nope")).unwrap(); - assert!(matches!( - reply.into_result("1.0.30").unwrap_err(), - JadeError::UnsupportedFirmware { .. } - )); - - let reply = decode_reply(&error_frame("1", rpc_code::INVALID_REQUEST, "bad")).unwrap(); - assert!(matches!( - reply.into_result("1.0.0").unwrap_err(), - JadeError::ProtocolError { .. } - )); - - let reply = decode_reply(&error_frame("1", -32099, "novel")).unwrap(); - assert!(matches!( - reply.into_result("1.0.0").unwrap_err(), - JadeError::DeviceError { .. } - )); -} - -#[test] -fn a_reply_with_neither_result_nor_error_is_a_protocol_error() { - let reply = decode_reply(&cbor_map(vec![("id", text("1"))])).unwrap(); - assert!(matches!( - reply.into_result("1.0.0").unwrap_err(), - JadeError::ProtocolError { .. } - )); -} - -// ============================================================================ -// Result readers -// ============================================================================ - -#[test] -fn byte_string_results_are_read_as_bytes() { - // sign_psbt returns a CBOR byte string. Going through - // Value::deserialized::>() would fail here, because the Value - // deserializer maps deserialize_seq onto Value::Array only. - let frame = reply_frame("1", ciborium::Value::Bytes(vec![0x70, 0x73, 0x62, 0x74])); - let reply = decode_reply(&frame).unwrap(); - let value = reply.into_result("1.0.0").unwrap(); - assert_eq!(result_bytes(&value).unwrap(), vec![0x70, 0x73, 0x62, 0x74]); -} - -#[test] -fn boolean_and_text_results_are_read() { - let reply = decode_reply(&reply_frame("1", ciborium::Value::Bool(true))).unwrap(); - assert!(result_bool(&reply.into_result("1.0.0").unwrap()).unwrap()); - - let reply = decode_reply(&reply_frame("1", text("tpub..."))).unwrap(); - assert_eq!( - result_text(&reply.into_result("1.0.0").unwrap()).unwrap(), - "tpub..." - ); } -#[test] -fn a_wrongly_typed_result_is_a_protocol_error() { - let reply = decode_reply(&reply_frame("1", int(5))).unwrap(); - let value = reply.into_result("1.0.0").unwrap(); - assert!(matches!( - result_text(&value).unwrap_err(), - JadeError::ProtocolError { .. } - )); +/// A callback that records what it was asked to do. +struct MockCallback { + chunk_size: u32, + writes: Mutex>>, + reads: Mutex>>, + fail_write: bool, } -#[test] -fn sequenced_replies_expose_seqnum_and_seqlen() { - let frame = cbor_map(vec![ - ("id", text("1")), - ("result", ciborium::Value::Bytes(vec![1, 2, 3])), - ("seqnum", int(1)), - ("seqlen", int(3)), - ]); - let reply = decode_reply(&frame).unwrap(); - assert_eq!(reply.seqnum, Some(1)); - assert_eq!(reply.seqlen, Some(3)); -} - -// ============================================================================ -// Request ids -// ============================================================================ - -#[test] -fn request_ids_are_sequential_per_connection_and_fit_the_device_limit() { - let mut ids = RequestIds::new(); - assert_eq!(ids.next_id(), "1"); - assert_eq!(ids.next_id(), "2"); - assert_eq!(ids.next_id(), "3"); - - // Two connections do not share a counter, so ids stay deterministic in a - // test process that runs many of them concurrently. - let mut other = RequestIds::new(); - assert_eq!(other.next_id(), "1"); -} - -#[test] -fn request_ids_never_exceed_the_sixteen_character_limit() { - let mut ids = RequestIds::new(); - for _ in 0..1000 { - let id = ids.next_id(); - assert!(!id.is_empty()); - assert!(id.len() < 16, "id {id} is too long for Jade"); - } -} - -// ============================================================================ -// Path validation -// -// DerivationPath::from_str alone accepts "" as the master path and accepts a -// path with no "m/" prefix, either of which would be a silent footgun. -// ============================================================================ - -mod paths { - use super::super::errors::JadeError; - use super::super::path; - - #[test] - fn a_valid_path_lowers_to_the_wire_representation() { - assert_eq!( - path::to_wire("m/84'/0'/0'/0/0", false).unwrap(), - vec![2147483732, 2147483648, 2147483648, 0, 0] - ); - // The 'h' hardened notation is equivalent to an apostrophe. - assert_eq!( - path::to_wire("m/84h/1h/0h", false).unwrap(), - vec![2147483732, 2147483649, 2147483648] - ); - } - - #[test] - fn the_empty_path_is_rejected_unless_explicitly_allowed() { - // Signing with the master key because a caller passed "" is exactly the - // outcome this guards against. - assert!(matches!( - path::validate("", false).unwrap_err(), - JadeError::InvalidPath { .. } - )); - assert!(matches!( - path::validate("m", false).unwrap_err(), - JadeError::InvalidPath { .. } - )); - assert!(matches!( - path::validate("m/", false).unwrap_err(), - JadeError::InvalidPath { .. } - )); - - // The master-fingerprint lookup opts in deliberately. - assert!(path::validate("m", true).is_ok()); - assert_eq!(path::to_wire("m", true).unwrap(), Vec::::new()); - } - - #[test] - fn a_path_without_the_m_prefix_is_rejected() { - assert!(matches!( - path::validate("84'/0'/0'", false).unwrap_err(), - JadeError::InvalidPath { .. } - )); - } - - #[test] - fn malformed_and_overdeep_paths_are_rejected() { - for bad in [ - "m/84'/x/0'", - "m/84'//0'", - "m/84'/0'/", - "n/84'/0'", - "m/84'/0'/0'/0/0/0/0/0/0", - ] { - assert!( - matches!( - path::validate(bad, false), - Err(JadeError::InvalidPath { .. }) - ), - "{bad} should have been rejected" - ); - } +impl MockCallback { + fn with_chunk_size(chunk_size: u32) -> Arc { + Arc::new(Self { + chunk_size, + writes: Mutex::new(Vec::new()), + reads: Mutex::new(Vec::new()), + fail_write: false, + }) } - #[test] - fn the_purpose_element_is_readable_for_variant_cross_checks() { - assert_eq!(path::purpose("m/84'/0'/0'/0/0"), Some(84)); - assert_eq!(path::purpose("m/44'/0'/0'"), Some(44)); - assert_eq!(path::purpose("m"), None); + fn failing() -> Arc { + Arc::new(Self { + chunk_size: 64, + writes: Mutex::new(Vec::new()), + reads: Mutex::new(Vec::new()), + fail_write: true, + }) } } -// ============================================================================ -// Types -// ============================================================================ - -mod device_types { - use super::super::types::*; - use crate::onchain::AccountType; - - #[test] - fn networks_use_jades_own_names() { - assert_eq!(JadeNetwork::Mainnet.wire_name(), "mainnet"); - assert_eq!(JadeNetwork::Testnet.wire_name(), "testnet"); - // Jade calls regtest "localtest". - assert_eq!(JadeNetwork::Regtest.wire_name(), "localtest"); +impl JadeTransportCallback for MockCallback { + fn scan_devices(&self, _timeout_ms: u32) -> Vec { + vec![JadeNativeDevice { + path: "AA:BB:CC:DD:EE:FF".to_string(), + transport: JadeTransportKind::Bluetooth, + name: Some("Jade C0FFEE".to_string()), + serial_number: Some("C0FFEE".to_string()), + }] } - #[test] - fn account_types_map_to_descriptor_variants() { - let cases = [ - (AccountType::Legacy, JadeAddressVariant::Pkh, 44), - (AccountType::WrappedSegwit, JadeAddressVariant::ShWpkh, 49), - (AccountType::NativeSegwit, JadeAddressVariant::Wpkh, 84), - (AccountType::Taproot, JadeAddressVariant::Tr, 86), - ]; - for (account_type, expected, purpose) in cases { - let variant = JadeAddressVariant::from(account_type); - assert_eq!(variant, expected); - assert_eq!(variant.purpose(), purpose); + fn open_device(&self, _path: String) -> JadeTransportResult { + JadeTransportResult { + success: true, + error: String::new(), + error_code: None, } - assert_eq!(JadeAddressVariant::Wpkh.wire_name(), "wpkh(k)"); - assert_eq!(JadeAddressVariant::ShWpkh.wire_name(), "sh(wpkh(k))"); - assert_eq!(JadeAddressVariant::Tr.wire_name(), "tr(k)"); } - #[test] - fn device_ids_carry_the_transport_so_paths_cannot_collide() { - // An Android USB host path and a Rust enumerated serial path can be the - // same string; without the prefix, connect could pick the wrong one. - let ble = JadeDeviceInfo::build_id(JadeTransportKind::Bluetooth, "AA:BB:CC"); - let serial = JadeDeviceInfo::build_id(JadeTransportKind::Serial, "AA:BB:CC"); - assert_ne!(ble, serial); - - assert_eq!( - JadeDeviceInfo::parse_id(&ble), - Some((JadeTransportKind::Bluetooth, "AA:BB:CC")) - ); - assert_eq!( - JadeDeviceInfo::parse_id("serial:/dev/tty.usbserial-1"), - Some((JadeTransportKind::Serial, "/dev/tty.usbserial-1")) - ); - assert_eq!(JadeDeviceInfo::parse_id("nonsense"), None); - assert_eq!(JadeDeviceInfo::parse_id("carrier:/dev/x"), None); - } - - #[test] - fn version_info_maps_from_the_screaming_snake_wire_shape() { - // Deriving Deserialize straight onto the FFI record would yield None for - // every field, since the wire uses JADE_VERSION rather than jade_version. - let wire = cbor_version_info("1.0.34", "LOCKED"); - let parsed: WireVersionInfo = ciborium::from_reader(wire.as_slice()).unwrap(); - let info = JadeVersionInfo::from(parsed); - - assert_eq!(info.jade_version, "1.0.34"); - assert_eq!(info.jade_state, JadeState::Locked); - assert_eq!(info.jade_networks.as_deref(), Some("TEST")); - assert_eq!(info.jade_has_pin, Some(true)); - assert_eq!(info.battery_status, Some(4)); - } - - #[test] - fn an_unrecognised_state_string_does_not_fail_the_decode() { - let wire = cbor_version_info("9.9.9", "SOMETHING_NEW"); - let parsed: WireVersionInfo = ciborium::from_reader(wire.as_slice()).unwrap(); - assert_eq!(JadeVersionInfo::from(parsed).jade_state, JadeState::Unknown); - } - - #[test] - fn every_documented_state_string_maps() { - for (wire, expected) in [ - ("UNINIT", JadeState::Uninit), - ("UNSAVED", JadeState::Unsaved), - ("LOCKED", JadeState::Locked), - ("READY", JadeState::Ready), - ("TEMP", JadeState::Temp), - ] { - let encoded = cbor_version_info("1.0.34", wire); - let parsed: WireVersionInfo = ciborium::from_reader(encoded.as_slice()).unwrap(); - assert_eq!(JadeVersionInfo::from(parsed).jade_state, expected); + fn close_device(&self, _path: String) -> JadeTransportResult { + JadeTransportResult { + success: true, + error: String::new(), + error_code: None, } } - #[test] - fn ping_status_maps_from_the_wire_integer() { - assert_eq!(JadePingStatus::from_wire(0), JadePingStatus::Idle); - assert_eq!(JadePingStatus::from_wire(1), JadePingStatus::Busy); - assert_eq!( - JadePingStatus::from_wire(2), - JadePingStatus::AwaitingUserInput - ); - } - - fn cbor_version_info(version: &str, state: &str) -> Vec { - let text = |v: &str| ciborium::Value::Text(v.to_string()); - let value = ciborium::Value::Map(vec![ - (text("JADE_VERSION"), text(version)), - (text("JADE_STATE"), text(state)), - (text("JADE_NETWORKS"), text("TEST")), - (text("JADE_HAS_PIN"), ciborium::Value::Bool(true)), - (text("BOARD_TYPE"), text("JADE_V2")), - (text("BATTERY_STATUS"), ciborium::Value::Integer(4.into())), - ]); - let mut encoded = Vec::new(); - ciborium::into_writer(&value, &mut encoded).unwrap(); - encoded - } -} - -// ============================================================================ -// Transport and the request/reply loop -// -// Driven by a scripted mock device, so framing, correlation, fragment -// reassembly, cancellation and poisoning are all covered without hardware. -// ============================================================================ - -mod connection { - use super::super::errors::JadeError; - use super::super::transport::{JadeConnection, JadeTransport}; - use super::cbor_map; - use async_trait::async_trait; - use serde::Deserialize; - use std::collections::VecDeque; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc, Mutex}; - use std::time::Duration; - - pub(super) fn text(value: &str) -> ciborium::Value { - ciborium::Value::Text(value.to_string()) - } - - pub(super) fn int(value: i64) -> ciborium::Value { - ciborium::Value::Integer(value.into()) - } - - /// The fields of a request the mock needs in order to answer it. - #[derive(Debug, Deserialize)] - pub(super) struct SeenRequest { - pub(super) id: String, - pub(super) method: String, - } - - pub(super) type Responder = Box Vec + Send + Sync>; - - /// A scripted device. - /// - /// Each write consumes one responder, which builds the reply bytes from the - /// request that triggered it. Replies are queued and handed out by - /// `read_some` in whatever chunk sizes the test asked for, so a frame split - /// across reads is exercised end to end. - pub(super) struct MockTransport { - responders: Mutex>, - pending: Mutex>>, - writes: Mutex>>, - read_chunk: usize, - fail_next_read: AtomicBool, - closed: AtomicBool, - } - - impl MockTransport { - pub(super) fn new(responders: Vec) -> Arc { - Arc::new(Self { - responders: Mutex::new(responders.into()), - pending: Mutex::new(VecDeque::new()), - writes: Mutex::new(Vec::new()), - read_chunk: usize::MAX, - fail_next_read: AtomicBool::new(false), - closed: AtomicBool::new(false), - }) - } - - fn with_read_chunk(responders: Vec, read_chunk: usize) -> Arc { - let mut mock = Self { - responders: Mutex::new(responders.into()), - pending: Mutex::new(VecDeque::new()), - writes: Mutex::new(Vec::new()), - read_chunk, - fail_next_read: AtomicBool::new(false), - closed: AtomicBool::new(false), + fn write_chunk(&self, _path: String, data: Vec) -> JadeTransportResult { + if self.fail_write { + return JadeTransportResult { + success: false, + error: "device went away".to_string(), + error_code: Some(jade_client_rs::JadeTransportErrorCode::Disconnected), }; - mock.read_chunk = read_chunk; - Arc::new(mock) - } - - pub(super) fn write_count(&self) -> usize { - self.writes.lock().unwrap().len() - } - - /// The raw bytes of the nth request, for byte level assertions. - pub(super) fn writes_for_test(&self, index: usize) -> Vec { - self.writes.lock().unwrap()[index].clone() } - - pub(super) fn seen(&self, index: usize) -> SeenRequest { - let writes = self.writes.lock().unwrap(); - ciborium::from_reader(writes[index].as_slice()).unwrap() + self.writes.lock().unwrap().push(data); + JadeTransportResult { + success: true, + error: String::new(), + error_code: None, } } - #[async_trait] - impl JadeTransport for MockTransport { - async fn write_all(&self, data: Vec) -> Result<(), JadeError> { - let request: SeenRequest = ciborium::from_reader(data.as_slice()) - .map_err(|error| JadeError::protocol(format!("mock: {error}")))?; - self.writes.lock().unwrap().push(data); - - if let Some(responder) = self.responders.lock().unwrap().pop_front() { - let reply = responder(&request); - if !reply.is_empty() { - self.pending.lock().unwrap().push_back(reply); - } - } - Ok(()) - } - - async fn read_some(&self, _timeout: Duration) -> Result, JadeError> { - if self.fail_next_read.swap(false, Ordering::SeqCst) { - return Err(JadeError::DeviceDisconnected); - } - let mut pending = self.pending.lock().unwrap(); - let Some(mut next) = pending.pop_front() else { - return Ok(Vec::new()); - }; - if self.read_chunk < next.len() { - let rest = next.split_off(self.read_chunk); - pending.push_front(rest); - } - Ok(next) - } - - async fn close(&self) -> Result<(), JadeError> { - self.closed.store(true, Ordering::SeqCst); - Ok(()) + fn read_chunk(&self, _path: String, _timeout_ms: u32) -> JadeTransportReadResult { + let data = self.reads.lock().unwrap().pop().unwrap_or_default(); + JadeTransportReadResult { + success: true, + data, + error: String::new(), + error_code: None, } } - fn ok_reply(result: ciborium::Value) -> Responder { - Box::new(move |request: &SeenRequest| { - cbor_map(vec![("id", text(&request.id)), ("result", result.clone())]) - }) - } - - pub(super) fn connect(mock: Arc) -> (JadeConnection, Arc) { - let aborted = Arc::new(AtomicBool::new(false)); - let connection = JadeConnection::new(mock, Arc::clone(&aborted)); - (connection, aborted) - } - - #[tokio::test] - async fn a_request_gets_its_reply() { - let mock = MockTransport::new(vec![ok_reply(text("tpubDC"))]); - let (mut connection, _) = connect(Arc::clone(&mock)); - - let reply = connection - .exchange("get_xpub", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap(); - assert_eq!( - super::super::protocol::result_text(&reply.into_result("1.0.0").unwrap()).unwrap(), - "tpubDC" - ); - assert_eq!(mock.seen(0).method, "get_xpub"); - } - - #[tokio::test] - async fn a_reply_arriving_one_byte_at_a_time_still_decodes() { - let mock = MockTransport::with_read_chunk(vec![ok_reply(text("tpubDC"))], 1); - let (mut connection, _) = connect(mock); - - let reply = connection - .exchange("get_xpub", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap(); - assert_eq!( - super::super::protocol::result_text(&reply.into_result("1.0.0").unwrap()).unwrap(), - "tpubDC" - ); - } - - #[tokio::test] - async fn an_unsolicited_log_frame_is_skipped() { - // The device interleaves log frames with replies on the same stream. - let responder: Responder = Box::new(|request: &SeenRequest| { - let log = cbor_map(vec![( - "log", - ciborium::Value::Bytes(b"I (1) main: hello".to_vec()), - )]); - let reply = cbor_map(vec![("id", text(&request.id)), ("result", text("ok"))]); - [log, reply].concat() - }); - let mock = MockTransport::new(vec![responder]); - let (mut connection, _) = connect(mock); - - let reply = connection - .exchange("ping", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap(); - assert!(reply.result.is_some()); - } - - #[tokio::test] - async fn an_unattributed_error_resolves_the_request_instead_of_timing_out() { - // Jade answers with id "00" when it rejects a message before recovering - // its id. Discarding that would strand the caller until the deadline. - let responder: Responder = Box::new(|_: &SeenRequest| { - cbor_map(vec![ - ("id", text("00")), - ( - "error", - ciborium::Value::Map(vec![ - (text("code"), int(-32600)), - (text("message"), text("Invalid RPC Request message")), - ]), - ), - ]) - }); - let mock = MockTransport::new(vec![responder]); - let (mut connection, _) = connect(mock); - - let error = connection - .exchange("sign_psbt", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap_err(); - assert!(matches!(error, JadeError::ProtocolError { .. })); - } - - #[tokio::test] - async fn a_multi_fragment_reply_is_reassembled_in_order() { - // sign_psbt splits long replies across get_extended_data calls. Each of - // those carries a fresh id while origid names the original request, so - // the id being matched changes every round. - let fragment = |bytes: Vec, seqnum: i64, seqlen: i64| -> Responder { - Box::new(move |request: &SeenRequest| { - cbor_map(vec![ - ("id", text(&request.id)), - ("result", ciborium::Value::Bytes(bytes.clone())), - ("seqnum", int(seqnum)), - ("seqlen", int(seqlen)), - ]) - }) - }; - let mock = MockTransport::new(vec![ - fragment(vec![1, 2, 3], 1, 3), - fragment(vec![4, 5, 6], 2, 3), - fragment(vec![7, 8], 3, 3), - ]); - let (mut connection, _) = connect(Arc::clone(&mock)); - - let payload = connection - .exchange_reassembled("sign_psbt", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap(); - - assert_eq!(payload, vec![1, 2, 3, 4, 5, 6, 7, 8]); - assert_eq!(mock.write_count(), 3); - - // The follow-ups are get_extended_data, and each has its own id rather - // than reusing the original. - let original = mock.seen(0); - let second = mock.seen(1); - assert_eq!(second.method, "get_extended_data"); - assert_ne!(second.id, original.id); - assert_ne!(mock.seen(2).id, second.id); - } - - #[tokio::test] - async fn a_single_fragment_reply_needs_no_follow_up() { - let responder: Responder = Box::new(|request: &SeenRequest| { - cbor_map(vec![ - ("id", text(&request.id)), - ("result", ciborium::Value::Bytes(vec![9, 9])), - ("seqnum", int(1)), - ("seqlen", int(1)), - ]) - }); - let mock = MockTransport::new(vec![responder]); - let (mut connection, _) = connect(Arc::clone(&mock)); - - let payload = connection - .exchange_reassembled("sign_psbt", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap(); - assert_eq!(payload, vec![9, 9]); - assert_eq!(mock.write_count(), 1); - } - - #[tokio::test] - async fn a_fragment_with_the_wrong_sequence_number_is_rejected() { - let mock = MockTransport::new(vec![ - Box::new(|request: &SeenRequest| { - cbor_map(vec![ - ("id", text(&request.id)), - ("result", ciborium::Value::Bytes(vec![1])), - ("seqnum", int(1)), - ("seqlen", int(3)), - ]) - }), - Box::new(|request: &SeenRequest| { - cbor_map(vec![ - ("id", text(&request.id)), - ("result", ciborium::Value::Bytes(vec![2])), - ("seqnum", int(3)), // should be 2 - ("seqlen", int(3)), - ]) - }), - ]); - let (mut connection, _) = connect(mock); - - let error = connection - .exchange_reassembled("sign_psbt", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap_err(); - assert!(matches!(error, JadeError::ProtocolError { .. })); - } - - #[tokio::test] - async fn a_transport_error_poisons_the_connection() { - // A failure mid frame leaves no way to find the next boundary, so the - // connection must refuse further work rather than desynchronise. - let mock = MockTransport::new(vec![ok_reply(text("never read"))]); - mock.fail_next_read.store(true, Ordering::SeqCst); - let (mut connection, _) = connect(Arc::clone(&mock)); - - let error = connection - .exchange("ping", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap_err(); - assert_eq!(error, JadeError::DeviceDisconnected); - - let next = connection - .exchange("ping", Option::<()>::None, Duration::from_secs(5)) - .await - .unwrap_err(); - assert_eq!(next, JadeError::DeviceDisconnected); - } - - #[tokio::test] - async fn cancelling_returns_promptly_rather_than_waiting_out_the_deadline() { - // Jade has no cancel RPC, so aborting is how the application implements - // a cancel button on a signing screen. A ten minute deadline must not - // mean a ten minute wait. - let mock = MockTransport::new(vec![Box::new(|_: &SeenRequest| Vec::new())]); - let (mut connection, aborted) = connect(mock); - aborted.store(true, Ordering::SeqCst); - - let started = std::time::Instant::now(); - let error = connection - .exchange("sign_psbt", Option::<()>::None, Duration::from_secs(600)) - .await - .unwrap_err(); - - assert_eq!(error, JadeError::UserCancelled); - assert!(started.elapsed() < Duration::from_secs(5)); - } - - #[tokio::test] - async fn a_silent_device_times_out_without_spinning() { - let mock = MockTransport::new(vec![Box::new(|_: &SeenRequest| Vec::new())]); - let (mut connection, _) = connect(mock); - - let error = connection - .exchange("ping", Option::<()>::None, Duration::from_millis(200)) - .await - .unwrap_err(); - assert_eq!(error, JadeError::Timeout); - } - - #[tokio::test] - async fn a_stale_reply_does_not_satisfy_the_next_request() { - let responder: Responder = Box::new(|_: &SeenRequest| { - cbor_map(vec![("id", text("999")), ("result", text("stale"))]) - }); - let mock = MockTransport::new(vec![responder]); - let (mut connection, _) = connect(mock); - - let error = connection - .exchange("ping", Option::<()>::None, Duration::from_millis(200)) - .await - .unwrap_err(); - assert_eq!(error, JadeError::Timeout); + fn get_chunk_size(&self, _path: String) -> u32 { + self.chunk_size } } -// ============================================================================ -// Pinserver unlock -// ============================================================================ - -mod unlock { - use super::super::errors::JadeError; - use super::super::pinserver::{self, PinServerHttp}; - use super::super::types::JadeNetwork; - use super::cbor_map; - use super::connection::{connect, int, text, MockTransport, Responder, SeenRequest}; - use async_trait::async_trait; - use std::sync::{Arc, Mutex}; - - /// A pinserver that never touches the network. - struct FakePinServer { - response: Mutex, JadeError>>>, - calls: Mutex)>>, - } - - impl FakePinServer { - fn returning(body: &str) -> Arc { - Arc::new(Self { - response: Mutex::new(Some(Ok(body.as_bytes().to_vec()))), - calls: Mutex::new(Vec::new()), - }) - } - - fn failing() -> Arc { - Arc::new(Self { - response: Mutex::new(Some(Err(JadeError::PinServerError { - error_details: "network down".to_string(), - }))), - calls: Mutex::new(Vec::new()), - }) - } - } - - #[async_trait] - impl PinServerHttp for FakePinServer { - async fn request( - &self, - url: &str, - method: &str, - body: Option, - ) -> Result, JadeError> { - self.calls - .lock() - .unwrap() - .push((url.to_string(), method.to_string(), body)); - self.response - .lock() - .unwrap() - .take() - .unwrap_or(Ok(b"{}".to_vec())) - } - } - - /// An auth_user reply asking the host to call the pinserver. - fn http_request_reply(urls: Vec<&str>, on_reply: &str) -> Responder { - let urls: Vec = urls.into_iter().map(str::to_string).collect(); - let on_reply = on_reply.to_string(); - Box::new(move |request: &SeenRequest| { - let url_values = - ciborium::Value::Array(urls.iter().map(|url| text(url)).collect::>()); - let params = ciborium::Value::Map(vec![ - (text("urls"), url_values), - (text("method"), text("POST")), - (text("accept"), text("json")), - ( - text("data"), - ciborium::Value::Map(vec![(text("data"), text("cGF5bG9hZA=="))]), - ), - ]); - let http_request = ciborium::Value::Map(vec![ - (text("params"), params), - (text("on-reply"), text(&on_reply)), - ]); - cbor_map(vec![ - ("id", text(&request.id)), - ( - "result", - ciborium::Value::Map(vec![(text("http_request"), http_request)]), - ), - ]) - }) - } - - fn bool_reply(value: bool) -> Responder { - Box::new(move |request: &SeenRequest| { - cbor_map(vec![ - ("id", text(&request.id)), - ("result", ciborium::Value::Bool(value)), - ]) - }) - } +#[tokio::test] +async fn writes_are_split_at_the_reported_chunk_size() { + let callback = MockCallback::with_chunk_size(4); + let transport = CallbackTransport::new(Arc::clone(&callback) as Arc<_>, "path".to_string()); - #[tokio::test] - async fn an_already_unlocked_device_needs_no_pinserver_call() { - let mock = MockTransport::new(vec![bool_reply(true)]); - let (mut connection, _) = connect(Arc::clone(&mock)); - let http = FakePinServer::returning("{}"); - - pinserver::run_unlock( - &mut connection, - JadeNetwork::Testnet, - http.as_ref(), - 1_700_000_000, - ) + transport + .write_all(vec![1, 2, 3, 4, 5, 6, 7, 8, 9]) .await .unwrap(); - assert_eq!(mock.write_count(), 1); - assert_eq!(mock.seen(0).method, "auth_user"); - assert!(http.calls.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn a_locked_device_completes_the_pinserver_round_trip() { - let mock = MockTransport::new(vec![ - http_request_reply(vec!["https://jadepin.blockstream.com/get_pin"], "pin"), - bool_reply(true), - ]); - let (mut connection, _) = connect(Arc::clone(&mock)); - let http = FakePinServer::returning(r#"{"data":"YWJj"}"#); - - pinserver::run_unlock( - &mut connection, - JadeNetwork::Mainnet, - http.as_ref(), - 1_700_000_000, - ) - .await - .unwrap(); - - // The device saw auth_user then pin. - assert_eq!(mock.write_count(), 2); - assert_eq!(mock.seen(1).method, "pin"); - - // The payload was rendered as a JSON document, not forwarded as CBOR. - let calls = http.calls.lock().unwrap(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].1, "POST"); - assert_eq!(calls[0].2.as_deref(), Some(r#"{"data":"cGF5bG9hZA=="}"#)); - } - - #[tokio::test] - async fn a_wrong_pin_is_reported_as_such() { - let mock = MockTransport::new(vec![ - http_request_reply(vec!["https://jadepin.blockstream.com/get_pin"], "pin"), - bool_reply(false), - ]); - let (mut connection, _) = connect(mock); - let http = FakePinServer::returning(r#"{"data":"YWJj"}"#); - - let error = pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) - .await - .unwrap_err(); - assert_eq!(error, JadeError::InvalidPin); - } - - #[tokio::test] - async fn an_http_failure_still_sends_pin_so_the_device_stays_in_step() { - // The device blocks indefinitely waiting for a pin message. Abandoning - // the exchange would leave it consuming the next unrelated request as - // the awaited reply, putting every later call one message out of step. - let mock = MockTransport::new(vec![ - http_request_reply(vec!["https://jadepin.blockstream.com/get_pin"], "pin"), - bool_reply(false), - ]); - let (mut connection, _) = connect(Arc::clone(&mock)); - let http = FakePinServer::failing(); - - let error = pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) - .await - .unwrap_err(); - assert_eq!(error, JadeError::InvalidPin); - - assert_eq!(mock.write_count(), 2); - let sent = mock.seen(1); - assert_eq!(sent.method, "pin"); - - // And it carried no params at all, which is what signals the failure. - let writes_have_params = { - let raw: ciborium::Value = { - let writes = mock.writes_for_test(1); - ciborium::from_reader(writes.as_slice()).unwrap() - }; - match raw { - ciborium::Value::Map(entries) => entries - .iter() - .any(|(key, _)| key.as_text() == Some("params")), - _ => panic!("request was not a map"), - } - }; - assert!(!writes_have_params, "pin must be sent with no params"); - } - - #[tokio::test] - async fn a_device_naming_a_method_other_than_pin_is_rejected() { - // on-reply is device supplied. Dispatching on it blindly would let a - // device make the host invoke any RPC with params of its choosing. - let mock = MockTransport::new(vec![http_request_reply( - vec!["https://jadepin.blockstream.com/get_pin"], - "sign_psbt", - )]); - let (mut connection, _) = connect(mock); - let http = FakePinServer::returning("{}"); - - let error = pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) - .await - .unwrap_err(); - assert!(matches!(error, JadeError::ProtocolError { .. })); - } - - #[tokio::test] - async fn an_onion_url_is_skipped_in_favour_of_the_clearnet_one() { - // Firmware sends http://<...>.onion/get_pin, so a suffix test on the - // whole URL would not spot it. - let mock = MockTransport::new(vec![ - http_request_reply( - vec![ - "https://jadepin.blockstream.com/get_pin", - "http://abcdefghij.onion/get_pin", - ], - "pin", - ), - bool_reply(true), - ]); - let (mut connection, _) = connect(mock); - let http = FakePinServer::returning(r#"{"data":"YWJj"}"#); + let writes = callback.writes.lock().unwrap(); + assert_eq!(writes.len(), 3); + assert_eq!(writes[0], vec![1, 2, 3, 4]); + assert_eq!(writes[1], vec![5, 6, 7, 8]); + assert_eq!(writes[2], vec![9]); +} - pinserver::run_unlock(&mut connection, JadeNetwork::Mainnet, http.as_ref(), 0) - .await - .unwrap(); +#[tokio::test] +async fn a_zero_chunk_size_does_not_stall_the_write_loop() { + // A native implementation can report 0 before the MTU is negotiated. + // Without clamping, chunks(0) panics and the loop never advances. + let callback = MockCallback::with_chunk_size(0); + let transport = CallbackTransport::new(Arc::clone(&callback) as Arc<_>, "path".to_string()); - let calls = http.calls.lock().unwrap(); - assert_eq!(calls[0].0, "https://jadepin.blockstream.com/get_pin"); - } + transport.write_all(vec![1, 2, 3]).await.unwrap(); - // ------------------------------------------------------------------ - // Value conversion - // ------------------------------------------------------------------ + let writes = callback.writes.lock().unwrap(); + assert_eq!( + writes.len(), + 3, + "a clamped size of 1 sends one byte per write" + ); +} - #[test] - fn cbor_and_json_round_trip_for_the_shapes_the_pinserver_uses() { - let cbor = ciborium::Value::Map(vec![ - (text("data"), text("cGF5bG9hZA==")), - (text("count"), int(3)), - (text("ok"), ciborium::Value::Bool(true)), - ]); - let json = pinserver::cbor_to_json(&cbor).unwrap(); - assert_eq!(json["data"], "cGF5bG9hZA=="); - assert_eq!(json["count"], 3); - assert_eq!(json["ok"], true); +#[tokio::test] +async fn an_oversized_chunk_size_is_capped_to_the_bluetooth_limit() { + let callback = MockCallback::with_chunk_size(100_000); + let transport = CallbackTransport::new(Arc::clone(&callback) as Arc<_>, "path".to_string()); - // Compare as sets of entries: serde_json sorts object keys while CBOR - // preserves insertion order, and Jade's docs state that named field - // order is unimportant. - let entries = |value: &ciborium::Value| -> Vec<(String, ciborium::Value)> { - let ciborium::Value::Map(entries) = value else { - panic!("expected a map"); - }; - let mut entries: Vec<(String, ciborium::Value)> = entries - .iter() - .map(|(key, value)| (key.as_text().unwrap().to_string(), value.clone())) - .collect(); - entries.sort_by(|a, b| a.0.cmp(&b.0)); - entries - }; - let back = pinserver::json_to_cbor(&json).unwrap(); - assert_eq!(entries(&back), entries(&cbor)); - } + let payload = vec![7u8; MAX_CHUNK_BYTES as usize + 10]; + transport.write_all(payload).await.unwrap(); - #[test] - fn a_byte_string_is_not_silently_rendered_as_json() { - // The pinserver protocol carries binary as base64 text, so a raw byte - // string means the device sent something unexpected. - let cbor = ciborium::Value::Map(vec![(text("data"), ciborium::Value::Bytes(vec![1, 2]))]); - assert!(pinserver::cbor_to_json(&cbor).is_err()); - } + let writes = callback.writes.lock().unwrap(); + assert_eq!(writes.len(), 2); + assert_eq!(writes[0].len(), MAX_CHUNK_BYTES as usize); + assert_eq!(writes[1].len(), 10); } -// ============================================================================ -// Firmware version comparison -// ============================================================================ +#[tokio::test] +async fn a_typed_transport_error_survives_the_bridge() { + // The trezor adapter has to encode its error code into a string and parse it + // back out, because its upstream crate offers no typed channel. This one + // carries the code the whole way, so the mapping is exact. + let callback = MockCallback::failing(); + let transport = CallbackTransport::new(callback as Arc<_>, "path".to_string()); -mod firmware { - use super::super::types::{version_at_least, MIN_JADE_FIRMWARE_TAPROOT}; - - #[test] - fn versions_compare_by_component_not_lexically() { - assert!(version_at_least("1.0.34", "1.0.34")); - assert!(version_at_least("1.0.41", "1.0.34")); - assert!(version_at_least("1.1.0", "1.0.34")); - assert!(!version_at_least("1.0.33", "1.0.34")); - assert!(!version_at_least("0.1.48", "1.0.34")); - // Lexically "1.0.9" sorts after "1.0.34", numerically it does not. - assert!(!version_at_least("1.0.9", "1.0.34")); - } + let error = transport.write_all(vec![1]).await.unwrap_err(); + assert_eq!(error, JadeError::DeviceDisconnected); +} - #[test] - fn a_build_suffix_does_not_defeat_the_comparison() { - assert!(version_at_least("1.0.34-dirty", MIN_JADE_FIRMWARE_TAPROOT)); - assert!(version_at_least("1.0.35+ble", MIN_JADE_FIRMWARE_TAPROOT)); - } +#[tokio::test] +async fn an_empty_read_is_not_an_error() { + // Success with no data means "nothing yet", which is the normal state while + // the user is deciding on the device. + let callback = MockCallback::with_chunk_size(64); + let transport = CallbackTransport::new(callback as Arc<_>, "path".to_string()); - #[test] - fn an_unparsable_version_never_blocks_the_operation_by_itself() { - // The device stays the authority; it rejects what it cannot do. - assert!(!version_at_least("", "1.0.34")); - assert!(!version_at_least("nonsense", "1.0.34")); - } + let data = transport + .read_some(Duration::from_millis(10)) + .await + .unwrap(); + assert!(data.is_empty()); } diff --git a/src/modules/jade/transport.rs b/src/modules/jade/transport.rs deleted file mode 100644 index 53ea594..0000000 --- a/src/modules/jade/transport.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! Byte transport and the request/reply loop. -//! -//! `JadeTransport` is the internal seam: `CallbackTransport` drives the native -//! implementation, `SerialTransport` drives a serial port directly, and tests -//! substitute a scripted double. `JadeConnection` sits above it and owns the -//! read buffer, the request id counter and the correlation rules. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use async_trait::async_trait; -use serde::Serialize; - -use super::callbacks::{JadeTransportCallback, JadeTransportErrorCode}; -use super::errors::JadeError; -use super::protocol::{ - classify, decode_reply, encode_request, try_take_frame, JadeReply, ReplyMatch, RequestIds, -}; - -/// Bluetooth writes are capped here regardless of the reported MTU. -pub(crate) const MAX_CHUNK_BYTES: u32 = 509; - -/// How long a single `read_chunk` may block. -/// -/// Deliberately short. The long per-operation deadline is enforced by the loop -/// in `exchange`, so a user taking two minutes to confirm on the device does not -/// sit inside one uninterruptible native call. -const READ_CHUNK_TIMEOUT_MS: u32 = 250; - -/// Floor on the polling interval when a read returns nothing. -/// -/// A native implementation that returns immediately with no data would -/// otherwise turn the read loop into a busy spin that pins a blocking thread. -const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// A byte pipe to a device. -#[async_trait] -pub(crate) trait JadeTransport: Send + Sync { - /// Write a complete request. Implementations chunk as the transport needs. - /// - /// Takes ownership because callback and serial implementations both hand the - /// buffer to a blocking task, which needs a `'static` payload. - async fn write_all(&self, data: Vec) -> Result<(), JadeError>; - - /// Read whatever has arrived, waiting at most `timeout`. - /// - /// An empty vector means nothing arrived, which is not an error. - async fn read_some(&self, timeout: Duration) -> Result, JadeError>; - - /// Release the device. Safe to call more than once. - async fn close(&self) -> Result<(), JadeError>; -} - -fn code_to_error(code: Option, message: String) -> JadeError { - match code { - Some(JadeTransportErrorCode::DeviceBusy) => JadeError::DeviceBusy, - Some(JadeTransportErrorCode::NotConnected) => JadeError::NotConnected, - Some(JadeTransportErrorCode::Disconnected) => JadeError::DeviceDisconnected, - Some(JadeTransportErrorCode::Timeout) => JadeError::Timeout, - Some(JadeTransportErrorCode::PermissionDenied) | None => JadeError::TransportError { - error_details: message, - }, - } -} - -/// Transport backed by the native application. -pub(crate) struct CallbackTransport { - callback: Arc, - path: String, - chunk_size: usize, -} - -impl CallbackTransport { - pub(crate) fn new(callback: Arc, path: String) -> Self { - // Clamp whatever the native layer reports. A zero would make the write - // loop fail to advance, and anything above the Bluetooth cap would be - // rejected by the link layer. - let reported = callback.get_chunk_size(path.clone()); - let chunk_size = reported.clamp(1, MAX_CHUNK_BYTES) as usize; - Self { - callback, - path, - chunk_size, - } - } -} - -#[async_trait] -impl JadeTransport for CallbackTransport { - async fn write_all(&self, data: Vec) -> Result<(), JadeError> { - let callback = Arc::clone(&self.callback); - let path = self.path.clone(); - let chunk_size = self.chunk_size; - - // Foreign callbacks are synchronous and can block. Running them on a - // worker thread would park it for the duration; the blocking pool is - // sized for exactly this. - tokio::task::spawn_blocking(move || { - for chunk in data.chunks(chunk_size) { - let result = callback.write_chunk(path.clone(), chunk.to_vec()); - if !result.success { - return Err(code_to_error(result.error_code, result.error)); - } - } - Ok(()) - }) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("write task failed: {error}"), - })? - } - - async fn read_some(&self, timeout: Duration) -> Result, JadeError> { - let callback = Arc::clone(&self.callback); - let path = self.path.clone(); - let timeout_ms = timeout.as_millis().min(u128::from(u32::MAX)) as u32; - - tokio::task::spawn_blocking(move || { - let result = callback.read_chunk(path, timeout_ms); - if !result.success { - return Err(code_to_error(result.error_code, result.error)); - } - Ok(result.data) - }) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("read task failed: {error}"), - })? - } - - async fn close(&self) -> Result<(), JadeError> { - let callback = Arc::clone(&self.callback); - let path = self.path.clone(); - tokio::task::spawn_blocking(move || { - let result = callback.close_device(path); - if !result.success { - return Err(code_to_error(result.error_code, result.error)); - } - Ok(()) - }) - .await - .map_err(|error| JadeError::IoError { - error_details: format!("close task failed: {error}"), - })? - } -} - -/// A request/reply session over one transport. -pub(crate) struct JadeConnection { - transport: Arc, - buffer: Vec, - ids: RequestIds, - /// Set when the stream can no longer be trusted. A framing failure or a - /// transport error leaves no way to find the next frame boundary, so the - /// connection refuses further work rather than returning confusing errors - /// far from the real cause. - poisoned: bool, - aborted: Arc, - min_firmware: String, -} - -impl JadeConnection { - pub(crate) fn new(transport: Arc, aborted: Arc) -> Self { - Self { - transport, - buffer: Vec::new(), - ids: RequestIds::new(), - poisoned: false, - aborted, - min_firmware: super::types::MIN_JADE_FIRMWARE.to_string(), - } - } - - fn check_usable(&self) -> Result<(), JadeError> { - if self.poisoned { - return Err(JadeError::DeviceDisconnected); - } - if self.aborted.load(Ordering::SeqCst) { - return Err(JadeError::UserCancelled); - } - Ok(()) - } - - /// Mark the stream unusable and drop anything half read. - fn poison(&mut self) { - self.poisoned = true; - self.buffer.clear(); - } - - /// Send a request and wait for its reply. - pub(crate) async fn exchange( - &mut self, - method: &str, - params: Option

, - timeout: Duration, - ) -> Result { - self.check_usable()?; - - let id = self.ids.next_id(); - let request = encode_request(&id, method, params)?; - log::debug!("[jade] -> {method} id={id} ({} bytes)", request.len()); - - if let Err(error) = self.transport.write_all(request).await { - self.poison(); - return Err(error); - } - - self.await_reply(&id, method, timeout).await - } - - /// Wait for the reply to `id`, discarding log frames and stale replies. - async fn await_reply( - &mut self, - id: &str, - method: &str, - timeout: Duration, - ) -> Result { - let deadline = Instant::now() + timeout; - - loop { - // Drain everything already buffered before reading again, so two - // frames arriving in one read are both seen. - loop { - let frame = match try_take_frame(&mut self.buffer) { - Ok(Some(frame)) => frame, - Ok(None) => break, - Err(error) => { - self.poison(); - return Err(error); - } - }; - - let reply = match decode_reply(&frame) { - Ok(reply) => reply, - Err(error) => { - self.poison(); - return Err(error); - } - }; - - match classify(reply, id) { - ReplyMatch::Matched(reply) => { - log::debug!("[jade] <- {method} id={id}"); - return Ok(reply); - } - ReplyMatch::Unattributed(error) => { - // The device rejected the message before it could - // recover the id. This is terminal for the request in - // flight; ignoring it would strand the caller until the - // deadline. - log::debug!("[jade] <- {method} unattributed error {}", error.code); - return Err(JadeError::from_rpc( - error.code, - error.message, - &self.min_firmware, - )); - } - ReplyMatch::Ignore => continue, - } - } - - if self.aborted.load(Ordering::SeqCst) { - self.poison(); - return Err(JadeError::UserCancelled); - } - let now = Instant::now(); - if now >= deadline { - self.poison(); - return Err(JadeError::Timeout); - } - - let remaining = deadline - now; - let slice = remaining.min(Duration::from_millis(u64::from(READ_CHUNK_TIMEOUT_MS))); - let chunk = match self.transport.read_some(slice).await { - Ok(chunk) => chunk, - Err(error) => { - self.poison(); - return Err(error); - } - }; - - if chunk.is_empty() { - // Nothing yet. Yield so a native implementation that returns - // immediately cannot spin a blocking thread at full tilt. - tokio::time::sleep(IDLE_POLL_INTERVAL.min(remaining)).await; - } else { - self.buffer.extend_from_slice(&chunk); - } - } - } - - /// Send a request whose reply may arrive in `seqnum`/`seqlen` fragments and - /// return the concatenated bytes. - /// - /// Fragments are fetched with `get_extended_data`. Each of those carries its - /// own fresh request id while `origid` names the original request, so the id - /// being matched changes on every round. `seqnum` must advance by exactly - /// one and `seqlen` must be echoed unchanged, or the device aborts with a - /// protocol error. - /// - /// Any failure part way through poisons the connection: the device stays - /// blocked waiting for the next fragment request, so the link has to be torn - /// down rather than reused. - pub(crate) async fn exchange_reassembled( - &mut self, - method: &str, - params: Option

, - timeout: Duration, - ) -> Result, JadeError> { - self.check_usable()?; - - let origid = self.ids.next_id(); - let request = encode_request(&origid, method, params)?; - log::debug!("[jade] -> {method} id={origid} ({} bytes)", request.len()); - if let Err(error) = self.transport.write_all(request).await { - self.poison(); - return Err(error); - } - - let reply = self.await_reply(&origid, method, timeout).await?; - let seqlen = reply.seqlen.unwrap_or(1).max(1); - let mut seqnum = reply.seqnum.unwrap_or(1); - let mut payload = super::protocol::result_bytes(&reply.into_result(&self.min_firmware)?)?; - - if seqlen > 1 { - log::debug!("[jade] {method} reply spans {seqlen} fragments"); - } - - while seqnum < seqlen { - let next = seqnum + 1; - let fragment = self - .fetch_fragment(&origid, method, next, seqlen, timeout) - .await - .inspect_err(|_| { - // Leaving the device mid-stream desynchronises it; the - // connection cannot be reused. - self.poisoned = true; - })?; - payload.extend_from_slice(&fragment); - seqnum = next; - } - - Ok(payload) - } - - async fn fetch_fragment( - &mut self, - origid: &str, - orig: &str, - seqnum: u32, - seqlen: u32, - timeout: Duration, - ) -> Result, JadeError> { - #[derive(Serialize)] - struct ExtendedDataParams<'a> { - origid: &'a str, - orig: &'a str, - seqnum: u32, - seqlen: u32, - } - - let params = ExtendedDataParams { - origid, - orig, - seqnum, - seqlen, - }; - let reply = self - .exchange("get_extended_data", Some(params), timeout) - .await?; - - if let Some(reported) = reply.seqnum { - if reported != seqnum { - return Err(JadeError::protocol(format!( - "expected fragment {seqnum}, device sent {reported}" - ))); - } - } - super::protocol::result_bytes(&reply.into_result(&self.min_firmware)?) - } -} diff --git a/src/modules/jade/types.rs b/src/modules/jade/types.rs index e809ed6..fdac7f1 100644 --- a/src/modules/jade/types.rs +++ b/src/modules/jade/types.rs @@ -1,115 +1,37 @@ -//! FFI-compatible types for the Jade module. +//! UniFFI scaffolding for the `jade-client-rs` types. //! -//! The records here are the shapes the bindings see. Wire shapes stay private: -//! Jade's `get_version_info` reply uses SCREAMING_SNAKE keys and a string state, -//! so deriving `Deserialize` straight onto the FFI record would silently yield -//! nothing but `None`. +//! Every type here is defined in that crate, not this one. `#[uniffi::remote]` +//! attaches the same scaffolding `#[derive(uniffi::…)]` would, without a +//! mirrored set of structs and hand-written `From` conversions in both +//! directions. The trezor module predates this and pays that cost; this module +//! does not. +//! +//! The declarations below must match the upstream definitions variant for +//! variant and field for field. The compiler catches a mismatch, and the tests +//! in `tests.rs` exercise the round trip. -use serde::Deserialize; +pub use jade_client_rs::{ + JadeAccount, JadeAccountExport, JadeAddressVariant, JadeDeviceInfo, JadeError, JadeNetwork, + JadePingStatus, JadeSignedMessage, JadeState, JadeTransportErrorCode, JadeTransportKind, + JadeVersionInfo, JadeXpubResponse, +}; use crate::onchain::AccountType; -/// The oldest firmware this module targets. -/// -/// Single-signature `get_receive_address` and `sign_psbt` were added during the -/// 0.1.x series. The device is the authority here: an older unit answers -/// `UNKNOWN_METHOD`, which maps to `JadeError::UnsupportedFirmware`, so this -/// constant is advisory and only improves the message. -pub(crate) const MIN_JADE_FIRMWARE: &str = "0.1.48"; - -/// Taproot address support landed in 1.0.34. -pub(crate) const MIN_JADE_FIRMWARE_TAPROOT: &str = "1.0.34"; - -/// Compare two dotted version strings. -/// -/// Returns false when either side cannot be parsed, so an unrecognised version -/// string never blocks an operation the device might well support. The device -/// remains the authority: it answers `BAD_PARAMETERS` for a variant it does not -/// know, and this check only turns that into a clearer message. -pub(crate) fn version_at_least(installed: &str, required: &str) -> bool { - fn parts(version: &str) -> Option<(u32, u32, u32)> { - let trimmed = version - .trim() - .split(|c: char| !c.is_ascii_digit() && c != '.') - .next()?; - let mut fields = trimmed.split('.').map(str::parse::); - let major = fields.next()?.ok()?; - let minor = fields.next().transpose().ok()?.unwrap_or(0); - let patch = fields.next().transpose().ok()?.unwrap_or(0); - Some((major, minor, patch)) - } - - match (parts(installed), parts(required)) { - (Some(installed), Some(required)) => installed >= required, - _ => false, - } -} - -/// The Bitcoin networks Jade recognises. -/// -/// Jade has no signet, so there are exactly three. Its regtest is named -/// `localtest` on the wire. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[uniffi::remote(Enum)] pub enum JadeNetwork { Mainnet, Testnet, Regtest, } -impl JadeNetwork { - pub(crate) fn wire_name(self) -> &'static str { - match self { - JadeNetwork::Mainnet => "mainnet", - JadeNetwork::Testnet => "testnet", - JadeNetwork::Regtest => "localtest", - } - } - - /// The BIP44 coin type this network derives under. - pub(crate) fn coin_type(self) -> u32 { - match self { - JadeNetwork::Mainnet => 0, - JadeNetwork::Testnet | JadeNetwork::Regtest => 1, - } - } -} - -impl From for bitcoin::Network { - fn from(network: JadeNetwork) -> Self { - match network { - JadeNetwork::Mainnet => bitcoin::Network::Bitcoin, - JadeNetwork::Testnet => bitcoin::Network::Testnet, - JadeNetwork::Regtest => bitcoin::Network::Regtest, - } - } -} - -/// How the host reaches a particular device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[uniffi::remote(Enum)] pub enum JadeTransportKind { Bluetooth, Serial, } -impl JadeTransportKind { - pub(crate) fn as_str(self) -> &'static str { - match self { - JadeTransportKind::Bluetooth => "ble", - JadeTransportKind::Serial => "serial", - } - } - - pub(crate) fn from_str(value: &str) -> Option { - match value { - "ble" => Some(JadeTransportKind::Bluetooth), - "serial" => Some(JadeTransportKind::Serial), - _ => None, - } - } -} - -/// The single-signature descriptor variants Jade accepts. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[uniffi::remote(Enum)] pub enum JadeAddressVariant { Pkh, Wpkh, @@ -117,125 +39,44 @@ pub enum JadeAddressVariant { Tr, } -impl JadeAddressVariant { - pub(crate) fn wire_name(self) -> &'static str { - match self { - JadeAddressVariant::Pkh => "pkh(k)", - JadeAddressVariant::Wpkh => "wpkh(k)", - JadeAddressVariant::ShWpkh => "sh(wpkh(k))", - JadeAddressVariant::Tr => "tr(k)", - } - } - - /// The BIP44 purpose this variant is derived under. - pub(crate) fn purpose(self) -> u32 { - match self { - JadeAddressVariant::Pkh => 44, - JadeAddressVariant::ShWpkh => 49, - JadeAddressVariant::Wpkh => 84, - JadeAddressVariant::Tr => 86, - } - } -} - -impl From for JadeAddressVariant { - fn from(account_type: AccountType) -> Self { - match account_type { - AccountType::Legacy => JadeAddressVariant::Pkh, - AccountType::WrappedSegwit => JadeAddressVariant::ShWpkh, - AccountType::NativeSegwit => JadeAddressVariant::Wpkh, - AccountType::Taproot => JadeAddressVariant::Tr, - } - } -} - -/// The device's wallet state, as reported by `get_version_info`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[uniffi::remote(Enum)] pub enum JadeState { - /// No wallet. Setup has to be completed on the device itself. Uninit, - /// A wallet exists but has not been persisted with a PIN. Unsaved, - /// A wallet exists and is PIN locked. Call `jade_unlock`. Locked, - /// Unlocked and usable. Ready, - /// A temporary wallet session is active. Temp, - /// Firmware reported a state this version does not know about. Unknown, } -impl JadeState { - fn from_wire(value: &str) -> Self { - match value { - "UNINIT" => JadeState::Uninit, - "UNSAVED" => JadeState::Unsaved, - "LOCKED" => JadeState::Locked, - "READY" => JadeState::Ready, - "TEMP" => JadeState::Temp, - _ => JadeState::Unknown, - } - } -} - -/// The result of `ping`. -/// -/// Modelled as an enum rather than the raw `u8` the device sends. It documents -/// the three states, and it keeps this module clear of unsigned 8 and 16 bit -/// FFI returns, which needed a binding-generator fix to work on Android ARM32. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[uniffi::remote(Enum)] pub enum JadePingStatus { Idle, Busy, AwaitingUserInput, } -impl JadePingStatus { - pub(crate) fn from_wire(value: u64) -> Self { - match value { - 0 => JadePingStatus::Idle, - 1 => JadePingStatus::Busy, - _ => JadePingStatus::AwaitingUserInput, - } - } +#[uniffi::remote(Enum)] +pub enum JadeTransportErrorCode { + DeviceBusy, + NotConnected, + Disconnected, + Timeout, + PermissionDenied, } -/// A device discovered by a scan. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +#[uniffi::remote(Record)] pub struct JadeDeviceInfo { - /// Stable identifier passed to `jade_connect`. - /// - /// Formed as `{transport}:{path}` so an Android USB host path and a Rust - /// enumerated serial path cannot collide and send a connect to the wrong - /// transport. - pub id: String, + pub path: String, pub transport: JadeTransportKind, - /// Advertised or descriptor name, for example "Jade C0FFEE". pub name: Option, - /// Transport specific address: a BLE identifier or a serial device path. - pub path: String, pub serial_number: Option, } -impl JadeDeviceInfo { - pub(crate) fn build_id(transport: JadeTransportKind, path: &str) -> String { - format!("{}:{}", transport.as_str(), path) - } - - /// Split an id back into its transport and path. - pub(crate) fn parse_id(id: &str) -> Option<(JadeTransportKind, &str)> { - let (kind, path) = id.split_once(':')?; - Some((JadeTransportKind::from_str(kind)?, path)) - } -} - -/// Device firmware and state summary. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +#[uniffi::remote(Record)] pub struct JadeVersionInfo { pub jade_version: String, pub jade_state: JadeState, - /// "ALL", "MAIN" or "TEST": which networks this unit is locked to. pub jade_networks: Option, pub jade_has_pin: Option, pub board_type: Option, @@ -244,129 +85,72 @@ pub struct JadeVersionInfo { pub idf_version: Option, pub chip_features: Option, pub efuse_mac: Option, - /// Battery bucket, 0 to 5. Widened from the wire's small integer so this - /// crate exposes no unsigned 8 bit types over FFI. pub battery_status: Option, pub jade_ota_max_chunk: Option, } -/// The wire shape of `get_version_info`, kept separate from the FFI record. -#[derive(Debug, Deserialize)] -pub(crate) struct WireVersionInfo { - #[serde(rename = "JADE_VERSION")] - pub jade_version: Option, - #[serde(rename = "JADE_STATE")] - pub jade_state: Option, - #[serde(rename = "JADE_NETWORKS")] - pub jade_networks: Option, - #[serde(rename = "JADE_HAS_PIN")] - pub jade_has_pin: Option, - #[serde(rename = "BOARD_TYPE")] - pub board_type: Option, - #[serde(rename = "JADE_CONFIG")] - pub jade_config: Option, - #[serde(rename = "JADE_FEATURES")] - pub jade_features: Option, - #[serde(rename = "IDF_VERSION")] - pub idf_version: Option, - #[serde(rename = "CHIP_FEATURES")] - pub chip_features: Option, - #[serde(rename = "EFUSEMAC")] - pub efuse_mac: Option, - #[serde(rename = "BATTERY_STATUS")] - pub battery_status: Option, - #[serde(rename = "JADE_OTA_MAX_CHUNK")] - pub jade_ota_max_chunk: Option, -} - -impl From for JadeVersionInfo { - fn from(wire: WireVersionInfo) -> Self { - JadeVersionInfo { - jade_version: wire.jade_version.unwrap_or_default(), - jade_state: wire - .jade_state - .as_deref() - .map(JadeState::from_wire) - .unwrap_or(JadeState::Unknown), - jade_networks: wire.jade_networks, - jade_has_pin: wire.jade_has_pin, - board_type: wire.board_type, - jade_config: wire.jade_config, - jade_features: wire.jade_features, - idf_version: wire.idf_version, - chip_features: wire.chip_features, - efuse_mac: wire.efuse_mac, - battery_status: wire.battery_status, - jade_ota_max_chunk: wire.jade_ota_max_chunk, - } - } -} - -/// An extended public key, echoed back with the request it answers. -/// -/// The path and fingerprint travel with the key so the caller can confirm the -/// device answered the question that was asked. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +#[uniffi::remote(Record)] pub struct JadeXpubResponse { pub xpub: String, pub derivation_path: String, - /// Master fingerprint, eight lowercase hex characters. pub master_fingerprint: String, } -/// One account within an export. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +#[uniffi::remote(Record)] pub struct JadeAccount { - pub account_type: AccountType, + pub variant: JadeAddressVariant, pub xpub: String, pub derivation_path: String, } -/// A multi-account export, shaped like `PassportAccountExport` so applications -/// have one import path for both signers. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +#[uniffi::remote(Record)] pub struct JadeAccountExport { pub master_fingerprint: String, pub account_index: u32, pub accounts: Vec, } -/// A signed message, with the address that verifies it. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +#[uniffi::remote(Record)] pub struct JadeSignedMessage { - /// Base64 encoded recoverable signature. pub signature: String, - /// Address derived from the signing path, for verification. pub address: String, pub derivation_path: String, } -#[derive(Debug, Clone, uniffi::Record)] -pub struct JadeGetXpubParams { - pub network: JadeNetwork, - pub derivation_path: String, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct JadeVerifyAddressParams { - pub network: JadeNetwork, - pub variant: JadeAddressVariant, - pub derivation_path: String, - /// The address the application is about to display. The device is asked to - /// show its own derivation, and the two are compared. - pub expected_address: String, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct JadeSignMessageParams { - pub derivation_path: String, - pub message: String, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct JadeSignPsbtParams { - pub network: JadeNetwork, - /// Base64 encoded PSBT. The signed PSBT comes back base64 encoded too, so - /// it feeds straight into `finalize_psbt`. - pub psbt: String, +#[uniffi::remote(Error)] +pub enum JadeError { + TransportError { error_details: String }, + DeviceNotFound, + DeviceDisconnected, + DeviceBusy, + NotConnected, + NotInitialized, + ConnectionError { error_details: String }, + ProtocolError { error_details: String }, + Timeout, + UserCancelled, + DeviceLocked, + DeviceUninitialized, + InvalidPin, + NetworkMismatch { error_details: String }, + UnsupportedFirmware { installed: String, required: String }, + InvalidPath { error_details: String }, + InvalidPsbt { error_details: String }, + PsbtTooLarge { size: u64, max: u64 }, + FingerprintMismatch { device: String, psbt: String }, + NothingSigned, + AddressMismatch { expected: String, returned: String }, + PinServerError { error_details: String }, + DeviceError { error_details: String }, + IoError { error_details: String }, +} + +/// Map the signer-neutral account type onto Jade's descriptor variant. +pub(crate) fn account_type_to_variant(account_type: AccountType) -> JadeAddressVariant { + match account_type { + AccountType::Legacy => JadeAddressVariant::Pkh, + AccountType::WrappedSegwit => JadeAddressVariant::ShWpkh, + AccountType::NativeSegwit => JadeAddressVariant::Wpkh, + AccountType::Taproot => JadeAddressVariant::Tr, + } } From 8414ec07d5c1c89f24058790f76bb483ab53d133 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 4 Sep 2026 14:15:57 -0400 Subject: [PATCH 3/3] chore(jade): bump the jade-client-rs pin to d55fafb 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. --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ee4659..46a2e10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2547,7 +2547,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jade-client-rs" version = "0.1.0" -source = "git+https://github.com/coreyphillips/jade-client-rs?rev=ea260cb#ea260cb62594bc7d11b74211dbb55b179ecf365a" +source = "git+https://github.com/coreyphillips/jade-client-rs?rev=d55fafb#d55fafbca117fa1da7c3863ae53ea1d69d142e18" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 201dc1c..3cc7e42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,11 +66,11 @@ android_logger = "0.14" # through JadeTransportCallback, so the crate's own serial transport is only # wanted where a Rust side serial port makes sense. [target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies] -jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "ea260cb", default-features = false, features = ["reqwest-pinserver"] } +jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "d55fafb", default-features = false, features = ["reqwest-pinserver"] } # Desktop and Python additionally get the crate's serial transport. [target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] -jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "ea260cb", features = ["reqwest-pinserver", "serial"] } +jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "d55fafb", features = ["reqwest-pinserver", "serial"] } [dev-dependencies] tokio = { version = "1.40.0", features = ["full"] }