Hardware-wallet app for Handshake (HNS). Derives Handshake addresses and signs
Handshake transactions, including name-auction covenants, on Ledger Stax, Flex,
Nano S+, Nano X and Apex+.
The design premise is that the host computer is untrusted: every APDU byte is
attacker-controlled, and the device's job is to be correct when the host lies.
Two failures outrank all others, and the code is shaped around them.
Signing something other than what the user approved. Any field the signature
commits to and the host can vary has to appear on the review screen, and
anything the screen cannot represent honestly is refused rather than shown under
a label that does not describe it. So the device signs SIGHASH_ALL only,
checked per input rather than once for the session; it displays a TRANSFER
covenant's destination, which decides who ends up owning the name and appears
nowhere else in the output; it shows a covenant's name hash when the plaintext
name cannot be verified against it; and it refuses covenant kinds it cannot
name, outputs whose values exceed their inputs, and declared input totals that
would overflow the fee arithmetic.
Misusing key material. Derivation paths are constrained to
m/44'/{5353..5356}'/account'[/change/index] in the app itself, not only by the
install manifest, because BOLOS terminates the app on an out-of-whitelist path
instead of returning an error. One coin type per signing session, since the
review renders every address under a single HRP.
Panics count too: set_panic!(exiting_panic) means a panic kills the app and
strands the session, so nothing unwraps on host-derived data and the session
ceilings are sized to the configured heap.
Consensus follows shd, the Swift Handshake node, which is the reference for the
sighash preimage, covenant item layouts and address encoding.
SIGN_TX is a PSBT-style state machine driven by P1 (BEGIN / ADD_INPUT /
ADD_OUTPUT / REVIEW / SIGN_INPUT): the host streams the transaction as
structured records, the device accumulates its own view of the values, shows one
review, then signs each input on demand.
Tests run under speculos in a container (./scripts/test), 66 of them, green on
all five devices. test_sign_tx_policy.py covers each refusal above and needs no
golden snapshots because every case is rejected before anything is drawn; the
snapshots that do exist are the record of what the user sees before approving.
tests/application_client/handshake_sighash.py is a deliberately independent
reimplementation of the sighash, used to verify real signatures.
Status: v0.1, pre-audit. Not for mainnet keys. Signing has not yet been
exercised on physical hardware.
Forked from LedgerHQ/app-boilerplate-rust (Apache-2.0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1.7 KiB
Functional tests
application_client/ is the Python client: APDU encoding, transaction and
covenant builders, response unpackers, and an independent reimplementation of
the Handshake sighash used to cross-check the device's signatures.
standalone/ holds the tests, run against the app started from the device
dashboard. There is no swap test directory: swap is not implemented (see
src/swap.rs), and handler_sign_review refuses outright when swap parameters
are present rather than auto-approving.
Running
../scripts/test # stax
../scripts/test nanosp # or flex / nanox / apex_p
Speculos does not run natively on macOS, so scripts/test runs it and ragger in
a container built from Dockerfile here, on top of Ledger's dev-tools image.
That image ships speculos but not ragger, and its system Python is PEP-668
managed, so ragger is installed into the same venv speculos lives in.
All five devices pass. To run pytest directly instead (on Linux, with ragger installed):
pip install -r standalone/requirements.txt
pytest standalone --tb=short -v --device stax
Snapshots
UI tests compare against golden PNGs under
standalone/snapshots/<device>/<test_name>/. Regenerate with
../scripts/test <device> --golden_run, or through the "Build and run
functional tests" workflow with golden_run set to "Open a PR". Read the diff
before committing one: those images are the record of what the user is shown
before they approve a signature.
standalone/test_sign_tx_policy.py deliberately needs none of them: every case
there is refused before the device draws anything.
standalone/review_nav.py explains why the approval path drives the navigator
directly instead of using scenario_navigator.review_approve().