Files
ledger-handshake/.github/copilot-instructions.md
eskimo 1b70b558c6
Some checks failed
Checks on the Python tests / Call Ledger Python linters (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Build application using the reusable workflow (push) Has been cancelled
Run coding style check / Check linting using the reusable workflow (push) Has been cancelled
Ensure compliance with Ledger guidelines / Call Ledger guidelines_enforcer (push) Has been cancelled
Misspellings checks / Check misspellings (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Run standalone ragger tests using the reusable workflow (push) Has been cancelled
Handshake Ledger app
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>
2026-09-06 15:13:36 -04:00

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:

  1. 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.
  2. 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.