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>
4.4 KiB
Handshake Ledger app development guide
Rust app for Ledger hardware wallets (Stax, Flex, Nano S+, Nano X, Apex+) using
ledger_device_sdk. It signs Handshake transactions,
including name-auction covenants.
The threat model drives everything
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:
- Signing something other than what the user approved. Any field the signature commits to and the host can vary must appear on the review screen. If it cannot be displayed honestly, refuse to sign rather than showing a label that does not describe the bytes.
- Misusing key material. Signing under an unapproved path, or returning anything derived from the seed without the user seeing what they authorised.
A panic is also a real defect: set_panic!(exiting_panic) means the app dies
and the user's session is stranded. Never unwrap() on host-derived data.
Architecture
APDU flow: Comm receives CLA=0xe0; Instruction parses INS/P1/P2
(src/main.rs); handlers in src/handlers/ return
Result<CommandResponse, AppSW>; AppSW maps errors to status words.
SIGN_TX is a state machine, not a chunked blob. P1 selects the sub-op:
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. P2 chunks a
single large ADD_OUTPUT only. See the table at the top of
src/handlers/sign_tx.rs.
Session state lives in TxContext, with a Phase enum enforcing ordering
and a BLAKE2b fingerprint captured at approval so nothing can change between
REVIEW and SIGN_INPUT.
Protocol primitives are in src/handshake/: addresses, bech32, BLAKE2b,
covenants, sighash, transaction types. These mirror shd (the Swift Handshake
node), which is the reference for every consensus question. Check against it
rather than from memory; a divergence in the sighash is a chain-split risk.
UI is NBGL: NbglHomeAndSettings for home, NbglReview with Field arrays
for transaction and message review, NbglAddressReview for address confirm.
Glyphs load via include_gif!() under #[cfg(target_os = "...")].
Build and test
./scripts/build stax # or flex / nanox / nanosplus / apex_p
Use scripts/build, not cargo ledger build: on macOS the latter writes
neither the Intel HEX nor the manifest, and cannot compile the Nano NBGL sources
without a uint define. The manifest is generated from the ELF by
scripts/gen-manifest and must never be hand-written, because dataSize has to
match the binary.
pip install -r tests/standalone/requirements.txt
pytest tests/standalone --tb=short -v --device stax
tests/standalone/test_sign_tx_policy.py is the regression suite for what the
device refuses; it needs no golden snapshots because every case is rejected
before anything is drawn. tests/application_client/handshake_sighash.py is an
independent sighash implementation used to cross-check real signatures.
Key patterns
Error handling: map SDK errors to AppSW variants
(.map_err(|_| AppSW::TxSignFail)). Never unwrap() outside build.rs.
BIP-32 paths: length byte + big-endian 4-byte components. Bip32Path in
src/utils.rs; validate_path enforces
m/44'/{5353..5356}'/account'[/change/index] and every handler calls it before
deriving. BOLOS terminates the app on an out-of-whitelist path rather than
returning an error, which is why the check happens in-app first.
Cryptography: Secp256k1::derive_from_path() for derivation;
BLAKE2b-256 for the sighash and signed messages (not SHA256d, not Keccak);
BLAKE2b-160 for witness programs; SHA3-256 for name hashes (NameRules.hashName
is SHA3, and Keccak differs in padding). SIGN_TX returns a 64-byte compact
r||s plus the sighash byte; SIGN_MESSAGE returns
recovery_id || sig || pubkey.
Memory: #![no_std], alloc::vec::Vec and alloc::format!. The heap is set
in .cargo/config.toml and the review screen's strings are the binding
constraint, so session ceilings in sign_tx.rs are sized against it. Do not
reserve capacity for host-declared counts.
Metadata: [package.metadata.ledger] in Cargo.toml defines the app name,
icons, derivation-path whitelist and flags per device. Keep the whitelist and
validate_path in agreement.