Files
ledger-handshake/README.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

135 lines
8.2 KiB
Markdown

# Handshake Ledger app
Hardware-wallet app for [Handshake](https://handshake.org) (HNS). Lets a Ledger Stax / Flex / Nano S+ / Nano X derive Handshake addresses and sign Handshake transactions, including name-auction covenants.
> ⚠️ **Status:** v0.1, pre-audit. Do **not** use with mainnet keys yet.
Forked from [LedgerHQ/app-boilerplate-rust](https://github.com/LedgerHQ/app-boilerplate-rust) (Apache-2.0).
## Devices
Stax (primary target), Flex, Nano S+, Nano X, Apex+. All five compile; only Stax has been exercised on real hardware.
## What the app does (v0.1)
| INS | Command | Behavior |
| ------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0x03` | `GET_VERSION` | Returns app version (major.minor.patch). |
| `0x04` | `GET_APP_NAME` | Returns `Handshake`. |
| `0x05` | `GET_PUBKEY` | Derives a secp256k1 pubkey from a BIP-32 path under `m/44'/5353'/...`, encodes as bech32 (`hs…` mainnet / `ts…` testnet). With `P1=1`, shows the address and its derivation path on-device to confirm. |
| `0x06` | `SIGN_TX` | PSBT-style state machine selected by `P1`: `BEGIN` / `ADD_INPUT` / `ADD_OUTPUT` / `REVIEW` / `SIGN_INPUT`. The host streams the tx as structured records, the device sums values itself, shows one review, then signs each input on demand. See `src/handlers/sign_tx.rs`. |
| `0x07` | `SIGN_MESSAGE` | Prepends `"handshake signed message:\n"`, BLAKE2b-256 hashes, shows signer address, path and preview, returns `recovery_id ‖ sig ‖ pubkey`. |
CLA: `0xe0`. Status words live in `src/main.rs::AppSW`.
## What the device refuses
A hardware wallet's job is to be correct when the host lies, so the app rejects anything its review screen cannot represent honestly. `tests/standalone/test_sign_tx_policy.py` covers each of these.
- **Sighash types other than `ALL` (`0x01`).** The review lists concrete outputs and a concrete fee, which is only what the signature commits to under `ALL`. `NONE` commits to no outputs, `SINGLE`/`SINGLEREVERSE` to one, `ANYONECANPAY` drops the other inputs and `NOINPUT` drops the outpoint. Checked per input, not once for the session.
- **Derivation paths outside `m/44'/{5353..5356}'/account'[/change/index]`.** The install manifest whitelists the same prefixes, but BOLOS terminates the app on a path outside them instead of returning an error, so the app checks first. All inputs in one session must share a coin type, since the review renders every address under a single HRP.
- **Sessions larger than 16 inputs / 8 outputs, or 1 KB of covenant items in total.** Sized to the configured heap. The counts no longer drive an up-front allocation, so a single `BEGIN` cannot exhaust it.
- **Covenant kinds above `REVOKE` (11), and kind `0` carrying items.** Either would put bytes into the sighash under a label that does not describe them.
- **Declared input values that sum past `u64`, or outputs exceeding inputs.** The fee is computed in `u128` and the session refused rather than truncated into a plausible-looking number.
## Building (no Docker)
Native Rust + ARM toolchain: no Docker, no `ledger-app-builder`.
### Prerequisites
```sh
# Rust nightly is pinned in rust-toolchain.toml; rustup will install it on demand.
rustup show
# ARM bare-metal toolchain + LLVM (macOS)
brew install --cask gcc-arm-embedded
brew install llvm
# Ledger Cargo subcommand + sideload tool + emulator
cargo install --git https://github.com/LedgerHQ/cargo-ledger
pip install ledgerwallet speculos
```
`scripts/build` has the full one-time setup in its header comment, including the
BOLOS Secure SDK clone and the API level that must match your device firmware.
### Build
```sh
./scripts/build stax # or: flex / nanox / nanosplus / apex_p
```
Use `scripts/build` rather than `cargo ledger build` directly: on macOS the latter
writes neither the Intel HEX nor the `ledgerctl` manifest, and it cannot compile
the Nano NBGL sources without a `uint` define. The script handles all three.
The manifest is **generated from the built ELF** by `scripts/gen-manifest`, never
hand-written: `dataSize` has to match the binary, and an undersized one installs
cleanly and then refuses to launch.
Binary and manifest land under `target/<device>/release/`.
## Testing
### Speculos emulator
```sh
speculos --apdu-port 9999 --api-port 5001 --model stax \
target/stax/release/app-handshake
```
### Ragger functional tests
```sh
./scripts/test # stax
./scripts/test flex # or nanosp / nanox / apex_p
./scripts/test stax -k policy -v
```
Speculos does not run natively on macOS, so `scripts/test` runs it and ragger in
a container built from Ledger's dev-tools image (`tests/Dockerfile`). The image
is multi-arch, so this runs natively on Apple silicon. The first invocation
builds the image, which takes a few minutes; after that a full device run is
about 30 seconds.
Tests that drive the UI compare against golden snapshots under
`tests/standalone/snapshots/<device>/`. Regenerate them with
`./scripts/test <device> --golden_run`, and look at the diff before committing:
those images are the record of what the review screen shows, which is the whole
point of the device. `test_sign_tx_policy.py` needs no snapshots, because every
case in it is refused before anything is drawn.
## Installing on a real device
Unlock the device and stay on the dashboard (not inside an app):
```sh
ledgerctl install -f target/stax/release/app_stax.json
```
## Architecture
- `src/main.rs`: APDU dispatcher and app entry.
- `src/handlers/`: one file per APDU command.
- `src/app_ui/`: NBGL UI screens (menu, address confirm, tx review, message review).
- `src/handshake/`: protocol primitives (addresses, covenants, sighash, BLAKE2b).
- `src/swap.rs`: Ledger Exchange integration, a stub. `handler_sign_review` refuses outright when swap parameters are present rather than auto-approving, so wiring Exchange up requires adding the validation deliberately.
## Spec / signing
The app mirrors `shd`:
- Curve: **secp256k1**, compressed public keys (33 B).
- BIP-32 path: `m/44'/5353'/account'/change/index`. Handshake assigns one SLIP-44 coin type per network (**5353'** mainnet, **5354'** testnet, **5355'** regtest, **5356'** simnet), and the coin type is what selects the HRP on-device (`Network::from_coin_type`).
- Address: bech32 (BIP-173, never bech32m) with HRPs `hs` / `ts` / `rs` / `ss`, witness version 0, P2WPKH = `BLAKE2b-160(compressed_pubkey)`.
- Sighash: BIP-143-style preimage, hashed with **BLAKE2b-256** (not SHA256d). The preimage builder handles `ALL`, `NONE`, `SINGLE`, `SINGLEREVERSE` and the `NOINPUT` / `ANYONECANPAY` flags so it matches `shd` byte for byte, but `SIGN_TX` only signs `ALL`. `tests/application_client/handshake_sighash.py` is an independent reimplementation used to cross-check the device's signatures.
- Signature: 64-byte compact r‖s with a 1-byte sighash type appended. Low-S comes from BOLOS itself: `cx_ecdsa_sign` canonicalizes unless `CX_NO_CANONICAL` is set, and the SDK's `deterministic_sign` does not set it.
- Outputs carry a `covenant` field (name auctions). Items are re-serialized verbatim into the sighash preimage, and the review decodes them: the operation label for every kind, the plaintext name for OPEN/BID/FINALIZE once checked against the hash the covenant commits to, the name hash otherwise, and for TRANSFER the destination address, which decides who ends up owning the name and appears nowhere else on screen.
Authoritative source: `shd/Sources/Script/SigHash.swift`, `shd/Sources/Protocol/Transaction.swift`, `shd/Sources/Covenants/CovenantData.swift`.
## License
Apache-2.0, see `LICENSE.md`. Original boilerplate © Ledger SAS.