Skip to content

Adopt vendor-maintained device crates behind the sans-I/O Interpreter #81

Description

@trevarj

Follow-up to #76.

Below is a LLM-organized and researched plan for integrating
third-party crates. My ideas but I had the LLM research the target
crates in-depth for me to see if we can easily integrate them.

Motivation

BHWI currently reimplements device protocols in bhwi. Discussion in #76
(BitBox, Trezor, and HWI maintainers) argues that reimplementation silently
diverges from upstream fixes (e.g. BitBoxSwiss/bitbox-api-rs#129 anti-klepto
hardening, message-interleaving mitigation) and repeats the maintenance
mistake that made Python HWI's vendored libraries unmaintainable.

Proposal: a two-tier support model.

  • Tier 1 (vendor-maintained): devices with a hardened Rust crate
    maintained by or with the vendor. We depend on the crate for protocol
    logic and wire types, pin it by exact version, review upgrades, and
    coordinate upstream changes we need.
  • Tier 2 (in-repo): devices without a suitable Rust crate keep their
    hand-written protocol implementations in bhwi/.

Out of scope for tier 1:

  • Jade: no vendor Rust crate for Bitcoin.
  • Coldcard: kept in-repo for backwards compatibility only; no
    conversion effort planned due to recent security issues.

Non-negotiable constraint

Per docs/VISION.md, the whole point of BHWI is that protocol logic is
sans-I/O: every device is driven through the Interpreter pump
(start -> repeated exchange -> end, with Transmit/Recipient
routing), so consumers keep full control of execution — sync, async, FFI,
WASM, or custom event loops. Tier 1 does not change this. Vendor crates are
integrated inside device interpreters, never as a replacement for them.
The common types, DeviceContext, and all existing consumers
(bhwi-async, bhwi-cli, bhwi-wasm, planned FFI) are unaffected.

Integration mechanism: coroutine adapter

Vendor clients are written inversion-of-control style: the client calls a
caller-supplied transport trait. To invert that back into a pump without
threads or a runtime, bhwi gains a small adapter that treats the vendor
client's async call as a coroutine:

  • The interpreter owns a Pin<Box<dyn Future>> built from the vendor
    client method (client constructed per command, as interpreters are
    per-command today).
  • The vendor crate's transport trait is implemented over a pair of
    in-memory channels. A transport call sends the outgoing payload and
    awaits the response.
  • start() polls the future (noop waker) until it parks on a transport
    call; the pending payload becomes the Transmit.
  • exchange(data) injects the response into the channel and re-polls,
    yielding the next Transmit or completion.
  • end() returns the future's output mapped to the common Response.

Properties:

  • No async runtime, no threads, no block_on. Works on stable Rust,
    single-threaded WASM, and behind sync FFI.
  • Send-bounded vendor transport traits (async-trait default) are
    satisfiable, since the channel halves are Send — the JS-future/?Send
    problem does not arise inside the adapter.
  • Invariant: the wrapped future may only ever suspend on our channel.
    Any other suspension point (timer, spawn, external I/O) deadlocks the
    pump. Every vendor crate must be audited for this, and violations become
    upstream asks. This invariant must be documented and enforced with tests.

Candidate crates

Device Crate Version bitcoin dep Client style Adapter fit
Ledger ledger_bitcoin_client (LedgerHQ/app-bitcoin) 0.6.2 0.32 async client over Transport trait, pure APDU request/response direct
BitBox02 bitbox-api 0.13.0 0.32 async client over ReadWrite trait; has a Runtime abstraction (sleep) needs suspension-point audit
Trezor trezor-client (trezor/trezor-firmware) 0.1.6 0.32 (optional) sync-only Transport: Sync + Send; strictly one write + one read per call(), interaction flows caller-driven two-phase bridge (see Trezor section); rusb must become optional

Tasks

Cross-cutting: coroutine adapter primitive

  • Add an adapter module in bhwi (e.g. bhwi::adapter): channel-backed
    request/response cell, manual polling with a noop waker, generic
    plumbing for wrapping an async vendor call as start/exchange/end.
  • Unit tests: multi-round exchange, error propagation, and a
    deadlock-detection test (future parks without emitting a transmit ->
    typed error, not a hang).
  • Document the suspension-point invariant and the per-crate audit
    requirement in AGENTS.md/docs.
  • Confirm the adapter compiles for wasm32 and involves no
    runtime/threads (CI check).

Ledger — back LedgerInterpreter with ledger_bitcoin_client

The crate's APDU exchange is pure request/response, the ideal adapter case.
It also shares lineage with our implementation (both originally by
Edouard), and is proven in production (Liana).

  • Add ledger_bitcoin_client = "=0.6.2" with the async feature; keep
    paranoid_client enabled (independent address derivation; its internal
    miniscript 12.2 must not cross our API boundary — verify).
  • Implement the crate's async Transport over the adapter channels
    (APDU struct <-> raw payload mapping at the boundary; Transmit
    recipient is always Device).
  • Rebuild LedgerInterpreter on the adapter: app info/open app, master
    fingerprint, xpub, address display, wallet registration (HMAC), PSBT
    signing with registered and default policies, message signing.
  • Keep common::Command/DeviceContext::Ledger conversions and their
    validation errors (missing context vs network mismatch vs protocol
    refusal) at least as specific as today.
  • Audit suspension points in the crate's client methods (expected:
    transport-only) and pin that finding in the integration notes.
  • Confirm parity for registered-policy signing and missing Merkle leaf
    handling (e23dc5b, ba26f5b); extend e2e cases if crate behavior differs.
  • Delete bhwi/src/ledger protocol internals (apdu, merkle, psbt,
    wallet, store) once parity is proven; keep the interpreter module and
    common conversions.
  • Validation: bhwi-e2e-ledger and bhwi-e2e-cli ledger suites
    before/after; wasm32 build of bhwi-wasm.

BitBox02 — back BitBoxInterpreter with bitbox-api

Largest win (the bitbox feature already pulls essentially bitbox-api's
own dep set, so the dependency-count argument is a wash). Maintainer
offered help in #76. One structural question: the crate's Runtime
abstraction.

  • Audit bitbox-api suspension points: where its Runtime::sleep and
    any polling loops are used (unlock wait, antiklepto flow). Outcome
    drives the upstream ask below.
  • Upstream ask (BitBoxSwiss/bitbox-api-rs): guarantee (or add a mode
    where) client futures suspend only on ReadWrite calls — e.g. make
    waits injectable — so the client is drivable by an external pump.
    Reference Clarification on why bitbox-api-rs is not used as a dep #76 where benma offered to address blockers.
  • Add bitbox-api = "=0.13.0" with default-features = false; no
    usb/wasm features in bhwi (those stay consumer-side concerns; the
    adapter replaces them).
  • Implement communication::ReadWrite over the adapter channels;
    decide the framing boundary (their HwwCommunication/U2F framing vs
    our existing transport framing in bhwi-async/bhwi-wasm) so frames
    are neither double-applied nor missing.
  • Rebuild BitBoxInterpreter on the adapter: noise pairing/handshake,
    xpub, address display (path, descriptor, multisig), wallet policy
    registration, PSBT signing, setup/restore/wipe/backup, toggle
    passphrase.
  • Decide noise/pairing state handling across per-command interpreter
    runs (persisted pairing config compatibility with configs written by
    the current implementation, or a migration).
  • Verify no miniscript types cross the crate's public API (it pins
    miniscript 13.0 internally; we pin a git rev) — coordinate if they do.
  • Delete bhwi/src/bitbox protocol internals and the now-unneeded
    direct deps (noise-protocol, prost, zeroize, getrandom, ...) once
    parity is proven.
  • Diff review: enumerate upstream fixes we gain (anti-klepto #129,
    interleaving mitigation, send-to-self detection) and any behavior we
    lose.
  • Validation: BitBox emulator e2e and bhwi-e2e-cli bitbox suites
    before/after; wasm32 build.

Trezor — converge draft PR #75 onto trezor-client

Trezor support already exists as draft PR #75 (part of #73): an in-repo
TrezorInterpreter with prost-generated protobuf bindings pinned to a
firmware protob rev, plus bhwi-async/CLI/e2e/nix wiring. This issue does
not restart that work; it defines the tier-1 convergence path for it.

The blocker, re-investigated. trezor-client's Transport is
sync-only (fn write_message / fn read_message, blocking), so the async
coroutine adapter cannot drive it, and threads are ruled out (WASM, sync
FFI). However, the client has no internal transport loops: call()
performs exactly one write_message followed by one read_message, and
every multi-round flow (ButtonRequest/ack, PinMatrix, SignTx's
TxRequest/TxAck streaming) is caller-driven through the returned
TrezorResponse interaction objects. Blocking therefore only ever spans a
single write -> read pair.

Solution: two-phase transport bridge (the sync analogue of the
coroutine adapter). The interpreter implements trezor_client::Transport
over two in-memory slots:

  • Capture phase: invoke the client step; write_message stores the
    encoded message (it becomes the Transmit payload); read_message
    returns a WouldBlock sentinel error, which the interpreter absorbs
    while remembering the pending step.
  • Inject phase: on exchange(data), place the device response in the
    read slot and re-invoke the same step; write_message produces an
    identical payload (pure protobuf re-encode, deduped), read_message
    yields the injected response, and the resulting TrezorResponse drives
    the next step (ack -> next capture -> next Transmit).

Each step runs at most twice, there is never more than one outstanding
round-trip (so no replay blow-up), and the pump invariant becomes "every
client step performs exactly one write then one read" — guaranteed today
by the caller-driven design and enforceable with adapter tests that fail
loudly if an upstream version starts looping internally. Wire framing
(0x3f/## chunking) stays where PR #75 already put it (bhwi-async
transport), or can reuse the crate's ProtocolV1 over an in-memory
Link, whose read_chunk never blocks at inject time.

Tasks:

  • Upstream ask (trezor/trezor-firmware): make rusb optional — it is
    a mandatory dep today and the only hard blocker to depending on the
    crate from bhwi core (prusnak offered feature-flag work in Clarification on why bitbox-api-rs is not used as a dep #76).
  • Upstream nice-to-have: expose split write/read halves of call_raw
    (e.g. call_write/call_read) to remove the double-invoke in the
    capture/inject cycle; small PR we can contribute ourselves.
  • Upstream long-term: async or sans-I/O client core; revisit the
    bridge when it lands.
  • Add trezor-client = "=0.1.6" with default-features = false,
    features = ["bitcoin"] (excludes ethereum/solana), gated on the
    rusb ask.
  • Build the two-phase bridge in bhwi::adapter next to the coroutine
    adapter, with the same test guarantees (multi-round, deadlock
    detection, error propagation).
  • Migrate PR feat(bhwi): Trezor support #75's interpreter: replace the vendored generated
    proto.rs with the crate's bindings, then progressively replace
    hand-written flow logic (SignTx streaming, interaction handling) with
    client-driven steps through the bridge.
  • Keep PR feat(bhwi): Trezor support #75's bhwi-async/CLI/e2e/nix work as-is; its emulator e2e
    suite becomes the parity gate for the migration.
  • Scope decision: docs/VISION.md currently scopes support to devices
    with a screen and Miniscript support; Trezor has no Miniscript/BIP388
    policy support. Explicitly extend or clarify the scope (also relevant
    to HWI parity: onboard Trezor and KeepKey #73/feat(bhwi): Trezor support #75 independently of this issue).

Cross-cutting: policy and guardrails

  • Dependency policy in AGENTS.md/docs: exact-version pins (=x.y.z)
    for vendor device crates, upgrade-review requirement, per-crate
    suspension-point re-audit on every bump, fork-and-patch as the
    conflict escape hatch (rust-bitcoin/miniscript lockstep is the known
    recurring risk).
  • Keep emulator e2e suites as the compatibility gate for vendor crate
    upgrades (run the device's e2e package on every bump).
  • Update docs/ support matrix and device onboarding docs for the
    tier model.
  • Track binary size and cargo tree deltas per integration; flag
    regressions.

Acceptance criteria

Every property promised by docs/VISION.md must survive the tier-1
integrations, not just the trait signature:

  • Sans-I/O core: bhwi performs no I/O of any kind. All bytes reach
    devices and third parties (PIN-server-style recipients) only through
    caller-handled Transmit exchanges. No transport, HTTP, HID, or browser
    deps in bhwi.
  • No mandated execution model: interpreters are drivable sync, async,
    or from any custom event loop — no function coloring, no async runtime,
    no threads, no block_on anywhere in bhwi. Verified by a blocking
    (non-async) consumer test alongside the existing async consumers.
  • FFI-ready: the core stays usable behind stable-ABI bindings; no
    async types or vendor types leak across the public surface.
  • WASM: bhwi and all tier-1 interpreters build and run on
    single-threaded wasm32; HTTP-style transmissions remain delegable to
    the browser's fetch API via Recipient.
  • Common interface: tier-1 and tier-2 devices sit behind the same
    common::Command/Response/Transmit surface and the Interpreter
    trait; vendor crates stay an implementation detail of their device
    interpreter — only bhwi and rust-bitcoin types appear in public APIs.
  • Multi-round protocols stay interpreted: device-driven cycles
    (Ledger Merkle callbacks, noise handshakes, PIN server unlock) remain
    inside interpreters; consumers never hand-roll protocol rounds.
  • Developer extensibility: consumers can still define their own
    traits and execution models over interpreters; existing consumers
    (bhwi-async, bhwi-cli, bhwi-wasm) require no API changes and CLI
    output contracts are unchanged.
  • BitBox and Ledger flows pass existing emulator e2e suites with the
    hand-written protocol internals deleted.
  • Trezor (draft PR feat(bhwi): Trezor support #75) passes its emulator e2e suite after migrating to
    upstream wire types and the two-phase bridge.
  • Vendor crate versions are exact-pinned with documented upgrade
    procedure.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions