Handshake Ledger app
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

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>
This commit is contained in:
eskimo
2026-09-06 15:13:36 -04:00
commit 1b70b558c6
315 changed files with 6172 additions and 0 deletions

34
.cargo/config.toml Normal file
View File

@@ -0,0 +1,34 @@
[target.apex_p]
runner = "speculos -m apex_p"
[build]
target = "apex_p"
[profile.release]
opt-level = 'z'
lto = true
# Uncomment the line below to generate a map file
# rustflags = ["-Clink-args=-Map=app.map"]
[unstable]
build-std = ["core", "alloc"]
build-std-features = ["compiler-builtins-mem"]
# Authorized values are [2048, 4096, 8192, 16384, 24576]. The default of 8192
# is not enough headroom: a full review screen (MAX_OUTPUTS outputs, each with
# an address, an amount and up to three covenant rows) allocates two Strings and
# a Field per row on top of the accumulated session state, and the two peak at
# the same moment. See the session ceilings in `handlers::sign_tx`.
[env]
HEAP_SIZE = "16384"
# Native (no-Docker) build wiring.
# - Set LEDGER_SDK_PATH in your shell to point at a clone of
# LedgerHQ/ledger-secure-sdk at the API_LEVEL matching your device firmware
# (Stax SE 1.9.1 → API_LEVEL_25). `scripts/build` defaults it to
# $HOME/.ledger-sdk/secure-sdk-25.
# - The ARM toolchain (arm-none-eabi-gcc) must also be on PATH; the brew cask
# installs it to /Applications/ArmGNUToolchain/<version>/arm-none-eabi/bin
# and does NOT add it to PATH: either prepend it in your shell rc or use
# `scripts/build`, which wires both env vars automatically.

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

8
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,8 @@
# Checklist
<!-- Put an `x` in each box when you have completed the items. -->
- [ ] App update process has been followed <!-- See comment below -->
- [ ] Target branch is `develop` <!-- unless you have a very good reason -->
- [ ] Application version has been bumped <!-- required if your changes are to be deployed -->
<!-- Make sure you followed the process described in https://developers.ledger.com/docs/device-app/deliver/maintenance before opening your Pull Request.
Don't hesitate to contact us directly on Discord if you have any questions ! https://developers.ledger.com/discord -->

96
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,96 @@
# 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](https://handshake.org) 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
```bash
./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.
```bash
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.

14
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
allow:
- dependency-name: "ledger_device_sdk"
- dependency-name: "include_gif"

View File

@@ -0,0 +1,50 @@
name: Build and run functional tests using ragger through reusable workflow
# This workflow will build the app and then run functional tests using the Ragger framework upon Speculos emulation.
# It calls a reusable workflow developed by Ledger's internal developer team to build the application and upload the
# resulting binaries.
# It then calls another reusable workflow to run the Ragger tests on the compiled application binary.
#
# The build part of this workflow is mandatory, this ensures that the app will be deployable in the Ledger App Store.
# While the test part of this workflow is optional, having functional testing on your application is mandatory and this workflow and
# tooling environment is meant to be easy to use and adapt after forking your application
permissions:
contents: write
actions: write
pull-requests: write
on:
workflow_dispatch:
inputs:
golden_run:
type: choice
required: true
default: 'Raise an error (default)'
description: CI behavior if the test snapshots are different than expected.
options:
- 'Raise an error (default)'
- 'Open a PR'
push:
branches:
- master
- main
- develop
pull_request:
jobs:
build_application:
name: Build application using the reusable workflow
uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_build.yml@v1
with:
upload_app_binaries_artifact: "app_handshake_binaries"
builder: ledger-app-builder
tests_standalone:
name: Run standalone ragger tests using the reusable workflow
needs: build_application
uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_ragger_tests.yml@v1
with:
download_app_binaries_artifact: "app_handshake_binaries"
regenerate_snapshots: ${{ github.event_name == 'workflow_dispatch' && inputs.golden_run == 'Open a PR' }}
test_dir: "tests/standalone"

View File

@@ -0,0 +1,24 @@
name: Run coding style check
# This workflow will run linting checks to ensure a level of code quality among all Ledger applications.
#
# The presence of this workflow is mandatory as a minimal level of linting is required.
permissions:
contents: read
on:
workflow_dispatch:
push:
branches:
- master
- main
- develop
pull_request:
jobs:
check_linting:
name: Check linting using the reusable workflow
uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_lint.yml@v1
with:
source: './src'

View File

@@ -0,0 +1,27 @@
name: Ensure compliance with Ledger guidelines
# This workflow is mandatory in all applications
# It calls a reusable workflow guidelines_enforcer developed by Ledger's internal developer team.
# The successful completion of the reusable workflow is a mandatory step for an app to be available on the Ledger
# application store.
#
# More information on the guidelines can be found in the repository:
# LedgerHQ/ledger-app-workflows/
permissions:
contents: read
actions: write
on:
workflow_dispatch:
push:
branches:
- master
- main
- develop
pull_request:
jobs:
guidelines_enforcer:
name: Call Ledger guidelines_enforcer
uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_guidelines_enforcer.yml@v1

View File

@@ -0,0 +1,22 @@
name: Misspellings checks
# This workflow performs some misspelling checks on the repository
# It is there to help us maintain a level of quality in our codebase and does not have to be kept on forked
# applications.
permissions:
contents: read
on:
workflow_dispatch:
push:
branches:
- master
- main
- develop
pull_request:
jobs:
misspell:
name: Check misspellings
uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_spell_check.yml@v1

View File

@@ -0,0 +1,28 @@
name: Checks on the Python tests
permissions:
contents: read
# This workflow performs some checks on the Python client used by the ragger tests
# It is there to help us maintain a level of quality in our codebase and does not have to be kept on forked
# applications.
on:
workflow_dispatch:
push:
branches:
- master
- main
- develop
pull_request:
jobs:
lint:
name: Call Ledger Python linters
uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_python_checks.yml@v1
with:
run_linter: pylint
run_type_check: true
src_directory: application_client
setup_directory: tests
req_directory: tests

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
target
.claude/
app.json
app_nanos.json
app_nanosplus.json
app_nanox.json
app_stax.json
app_flex.json
# Automatic generated NBGL glyphs
glyphs/home_nano_nbgl.png
# Temporary directory with snapshots taken during test runs
snapshots-tmp/
# Python
*.pyc[cod]
*.egg
__pycache__/
*.egg-info/
.eggs/
.python-version
# Related to the Ledger VSCode extension
# Virtual env for sideload (macOS and Windows)
ledger/
# Build directory
build/
# Helper script git clone
tests/swap/.test_dependencies/app-exchange/
tests/swap/.test_dependencies/app-ethereum/
# macOS
.DS_Store

1425
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

46
Cargo.toml Normal file
View File

@@ -0,0 +1,46 @@
[package]
name = "app-handshake"
version = "0.1.0"
authors = ["eskimo <eskimo@namebase.io>", "Ledger SAS (boilerplate)"]
edition = "2021"
[dependencies]
ledger_device_sdk = {version = "1.34.0", features = ["io_new"]}
ledger_secure_sdk_sys = "1.15"
serde = { version="1.0.192", default-features = false, features = ["derive"] }
serde-json-core = "0.6.0"
hex = { version = "0.4.3", default-features = false, features = ["serde", "alloc"] }
numtoa = "0.2.4"
arrayvec = { version = "0.7", default-features = false }
bech32 = { version = "0.11", default-features = false, features = ["alloc"] }
[build-dependencies]
image = "0.25.7"
[features]
default = ["ledger_device_sdk/nano_nbgl"]
debug = ["ledger_device_sdk/debug"]
[package.metadata.ledger]
curve = ["secp256k1"]
flags = "0"
path = ["44'/5353'", "44'/5354'", "44'/5355'", "44'/5356'"]
name = "Handshake"
[package.metadata.ledger.nanox]
icon = "icons/handshake_14x14.gif"
[package.metadata.ledger.nanosplus]
icon = "icons/handshake_14x14.gif"
[package.metadata.ledger.stax]
icon = "icons/handshake_32x32.gif"
[package.metadata.ledger.flex]
icon = "icons/handshake_40x40.gif"
[package.metadata.ledger.apex_p]
icon = "icons/handshake_32x32.png"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("apex_p", "stax", "flex", "nanos", "nanox", "nanosplus"))'] }

201
LICENSE.md Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

134
README.md Normal file
View File

@@ -0,0 +1,134 @@
# 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.

34
build.rs Normal file
View File

@@ -0,0 +1,34 @@
use image::{ImageFormat, ImageReader, Pixel};
fn main() {
println!("cargo:rerun-if-changed=script.ld");
println!("cargo:rerun-if-changed=icons/handshake_14x14.gif");
println!("cargo:rerun-if-changed=icons/mask_14x14.gif");
let path = std::path::PathBuf::from("icons");
let reader = ImageReader::open(path.join("handshake_14x14.gif")).unwrap();
let img = reader.decode().unwrap();
let mut gray = img.into_luma8();
// Apply mask
let mask = ImageReader::open(path.join("mask_14x14.gif"))
.unwrap()
.decode()
.unwrap()
.into_luma8();
for (x, y, mask_pixel) in mask.enumerate_pixels() {
let mask_value = mask_pixel[0];
let mut gray_pixel = *gray.get_pixel(x, y);
if mask_value == 0 {
gray_pixel = image::Luma([0]);
} else {
gray_pixel.invert();
}
gray.put_pixel(x, y, gray_pixel);
}
let glyph_path = std::path::PathBuf::from("glyphs");
gray.save_with_format(glyph_path.join("home_nano_nbgl.png"), ImageFormat::Png)
.unwrap();
}

BIN
glyphs/handshake_48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

BIN
glyphs/handshake_64x64.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

BIN
icons/handshake_14x14.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

BIN
icons/handshake_32x32.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

BIN
icons/handshake_32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

BIN
icons/handshake_40x40.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

BIN
icons/mask_14x14.gif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

7
ledger_app.toml Normal file
View File

@@ -0,0 +1,7 @@
[app]
build_directory = "./"
sdk = "Rust"
devices = ["nanox", "nanos+", "stax", "flex", "apex_p"]
[pytest.standalone]
directory = "./tests/standalone/"

21
mypy.ini Normal file
View File

@@ -0,0 +1,21 @@
[mypy]
python_version = 3.10
warn_unused_configs = True
disallow_untyped_defs = False
check_untyped_defs = False
warn_return_any = False
strict_optional = False
mypy_path = tests
# Ignore missing imports for external libraries without type stubs
[mypy-hid.*]
ignore_missing_imports = True
[mypy-pytest.*]
ignore_missing_imports = True
[mypy-ragger.*]
ignore_missing_imports = True
[mypy-ecdsa.*]
ignore_missing_imports = True

2
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly-2025-12-05"

70
scripts/build Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Build the Handshake Ledger app for a given device, with all the macOS
# native-toolchain env wrangling that cargo-ledger and the secure SDK assume.
#
# Usage: ./scripts/build [stax|flex|nanox|nanosplus] (default: stax)
#
# Prerequisites (one-time setup):
# brew install --cask gcc-arm-embedded # ARM bare-metal toolchain
# brew install llvm # llvm-objcopy, llvm-nm
# cargo install --git https://github.com/LedgerHQ/cargo-ledger cargo-ledger
# cargo ledger setup # installs target.json files
# git clone --depth=1 --branch API_LEVEL_25 \
# https://github.com/LedgerHQ/ledger-secure-sdk ~/.ledger-sdk/secure-sdk-25
# # API level must match the device firmware:
# # Stax SE 1.9.1 → API_LEVEL_25.
# # If install fails with status 0x511f, you're probably on the wrong level:
# # re-clone the SDK at the matching API_LEVEL_NN branch and override
# # LEDGER_SDK_PATH for the build.
# python3 -m pip install --user --break-system-packages Pillow
# # Patch the rustup-installed link_wrap.sh to be macOS-portable:
# sed -i '' 's|stat -c %s|wc -c <|' \
# ~/.rustup/toolchains/$(rustup show active-toolchain | cut -d' ' -f1)/lib/rustlib/$(rustc -vV | sed -n 's/host: //p')/bin/link_wrap.sh
set -euo pipefail
DEVICE="${1:-stax}"
ARM_BIN="/Applications/ArmGNUToolchain/15.2.rel1/arm-none-eabi/bin"
LLVM_BIN="/opt/homebrew/opt/llvm/bin"
SDK="${LEDGER_SDK_PATH:-$HOME/.ledger-sdk/secure-sdk-25}"
if [[ ! -x "$ARM_BIN/arm-none-eabi-gcc" ]]; then
echo "ARM toolchain not found at $ARM_BIN: install with: brew install --cask gcc-arm-embedded"
exit 1
fi
if [[ ! -x "$LLVM_BIN/llvm-objcopy" ]]; then
echo "llvm-objcopy not found at $LLVM_BIN: install with: brew install llvm"
exit 1
fi
if [[ ! -f "$SDK/install_params.py" ]]; then
echo "BOLOS Secure SDK not found at $SDK: see prerequisites in this script"
exit 1
fi
# The Nano NBGL sources use `uint`, a BSD spelling that arm-none-eabi-gcc
# accepts and the clang this script drives does not, so nanosplus and nanox
# fail to compile out of the box. `uint32_t` is the right width for the two
# loop counters that use it (lib_nbgl/src/nbgl_use_case_nanos.c:373,1581).
EXTRA_CFLAGS=""
if [[ "$DEVICE" == "nanosplus" || "$DEVICE" == "nanox" ]]; then
EXTRA_CFLAGS="-Duint=uint32_t"
fi
PATH="$ARM_BIN:$LLVM_BIN:$PATH" \
LEDGER_SDK_PATH="$SDK" \
CFLAGS="${CFLAGS:-} $EXTRA_CFLAGS" \
cargo ledger build "$DEVICE" "${@:2}"
# cargo-ledger's post-build expects an Intel HEX next to the manifest but
# silently produces nothing on macOS: generate it from the ELF.
ELF="target/$DEVICE/release/app-handshake"
HEX="$ELF.hex"
if [[ -f "$ELF" && ( ! -f "$HEX" || "$ELF" -nt "$HEX" ) ]]; then
"$LLVM_BIN/llvm-objcopy" -O ihex "$ELF" "$HEX"
echo "wrote $HEX"
fi
# cargo-ledger also fails to write the ledgerctl manifest on macOS. Generate it
# from the ELF, so `dataSize` can never drift from the binary being installed:
# an undersized dataSize installs cleanly and then refuses to launch.
"$(dirname "$0")/gen-manifest" "$DEVICE"

101
scripts/build-unsigned-tx Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Build a Handshake unsigned base transaction (no witnesses) from inputs and outputs.
Mirrors the wire format from shd `Sources/Protocol/Transaction.swift`:
version(4 LE) || varint(in_count) || inputs[40B each] || varint(out_count) || outputs || locktime(4 LE)
For each output:
value(8 LE) || address(version(1)+hashLen(1)+hash) || covenant(type(1)+varint(0))
Defaults: version=0, sequence=0xFFFFFFFF, locktime=0, covenant=.none.
Usage:
./scripts/build-unsigned-tx \\
--input 865d9e8011815fbfeb168bff83767f039cd6ac71bd75893d74fb89a3d932322d:0 \\
--output hs1qdydhjsla4ahvy5tn6w44ax7y62mpsd5nk3qnvw:9999000
(Amounts are in dollarydoos. 1 HNS = 1,000,000 dollarydoos.)
"""
import argparse
import sys
import bech32 # type: ignore[import-untyped]
def encode_varint(n: int) -> bytes:
if n < 0xFD:
return bytes([n])
if n <= 0xFFFF:
return b"\xfd" + n.to_bytes(2, "little")
if n <= 0xFFFFFFFF:
return b"\xfe" + n.to_bytes(4, "little")
return b"\xff" + n.to_bytes(8, "little")
def parse_input(s: str) -> tuple[bytes, int]:
"""`<txid_hex>:<vout>`: txid is natural byte order (matches shd .hex)."""
txid_str, vout_str = s.split(":")
txid = bytes.fromhex(txid_str)
if len(txid) != 32:
raise ValueError(f"input txid must be 32 bytes, got {len(txid)}")
return txid, int(vout_str)
def parse_output(s: str, network_hrp: str) -> tuple[int, int, bytes]:
"""`<bech32_addr>:<amount_in_doos>` → (amount, witness_version, hash_bytes)."""
addr, amount_str = s.split(":")
hrp, data = bech32.bech32_decode(addr)
if hrp != network_hrp:
raise ValueError(f"address HRP {hrp!r} != expected {network_hrp!r}")
if data is None or not data:
raise ValueError(f"invalid bech32 address: {addr}")
witver = data[0]
program = bech32.convertbits(data[1:], 5, 8, False)
if program is None:
raise ValueError(f"invalid bech32 program: {addr}")
return int(amount_str), witver, bytes(program)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--input", action="append", required=True,
help="`<txid>:<vout>` (repeat for multiple inputs)")
ap.add_argument("--output", action="append", required=True,
help="`<bech32_addr>:<amount_doos>` (repeat for multiple outputs)")
ap.add_argument("--version", type=int, default=0)
ap.add_argument("--locktime", type=int, default=0)
ap.add_argument("--sequence", type=lambda s: int(s, 0), default=0xFFFFFFFF)
ap.add_argument("--network", choices=["main", "testnet", "regtest", "simnet"],
default="main")
args = ap.parse_args()
hrp_map = {"main": "hs", "testnet": "ts", "regtest": "rs", "simnet": "ss"}
hrp = hrp_map[args.network]
out = bytearray()
out += args.version.to_bytes(4, "little")
inputs = [parse_input(i) for i in args.input]
out += encode_varint(len(inputs))
for txid, vout in inputs:
out += txid
out += vout.to_bytes(4, "little")
out += args.sequence.to_bytes(4, "little")
outputs = [parse_output(o, hrp) for o in args.output]
out += encode_varint(len(outputs))
for amount, witver, program in outputs:
out += amount.to_bytes(8, "little")
# Address: version(1) + hashLen(1) + hash
out += bytes([witver, len(program)]) + program
# Covenant.none: type(0) + varint(0 items)
out += b"\x00\x00"
out += args.locktime.to_bytes(4, "little")
print(out.hex())
return 0
if __name__ == "__main__":
sys.exit(main())

96
scripts/gen-manifest Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Write the ledgerctl install manifest for a built app.
cargo-ledger does not emit `app_<device>.json` on macOS, so generate it from
the built ELF plus `[package.metadata.ledger]` in Cargo.toml.
Everything that can be read from the ELF is read from the ELF: app name,
version, api level and target id all come from its `ledger.*` sections, so the
manifest cannot drift from the binary it installs.
`dataSize` is `_envram_data - _nvram_data`: the NVRAM the app is granted at
install time. Getting it too small installs an app that will not launch.
Usage: ./scripts/gen-manifest [stax|flex|nanox|nanosplus] (default: stax)
"""
import json
import pathlib
import subprocess
import sys
import tomllib
LLVM_NM = "/opt/homebrew/opt/llvm/bin/llvm-nm"
LLVM_OBJCOPY = "/opt/homebrew/opt/llvm/bin/llvm-objcopy"
# cargo-ledger's Cargo.toml key -> the [package.metadata.ledger.<key>] table
DEVICE_TABLE = {
"stax": "stax", "flex": "flex",
"nanox": "nanox", "nanosplus": "nanosplus", "apex_p": "apex_p",
}
def elf_section(elf, name):
"""Read a `ledger.*` metadata string section out of the ELF."""
out = subprocess.run(
[LLVM_OBJCOPY, f"--dump-section=ledger.{name}=/dev/stdout", elf, "/dev/null"],
capture_output=True,
).stdout
return out.decode("utf-8", "replace").strip("\0\n ")
def data_size(elf):
"""NVRAM granted to the app: `_envram_data - _nvram_data`."""
out = subprocess.run([LLVM_NM, elf], capture_output=True, text=True).stdout
sym = {}
for line in out.splitlines():
parts = line.split()
if len(parts) == 3 and parts[2] in ("_nvram_data", "_envram_data"):
sym[parts[2]] = int(parts[0], 16)
missing = {"_nvram_data", "_envram_data"} - sym.keys()
if missing:
sys.exit(f"gen-manifest: {elf} has no {', '.join(sorted(missing))} symbol")
return sym["_envram_data"] - sym["_nvram_data"]
def main():
device = sys.argv[1] if len(sys.argv) > 1 else "stax"
root = pathlib.Path(__file__).resolve().parent.parent
cargo = tomllib.loads((root / "Cargo.toml").read_text())
pkg = cargo["package"]
meta = pkg["metadata"]["ledger"]
per_device = meta.get(DEVICE_TABLE.get(device, device), {})
elf = root / "target" / device / "release" / pkg["name"]
if not elf.is_file():
sys.exit(f"gen-manifest: {elf} not built: run ./scripts/build {device} first")
hex_path = elf.with_suffix(".hex")
if not hex_path.is_file():
sys.exit(f"gen-manifest: {hex_path} missing: run ./scripts/build {device} first")
icon = per_device.get("icon")
if not icon:
sys.exit(f"gen-manifest: no icon for '{device}' in [package.metadata.ledger.{device}]")
flags = meta.get("flags", "0")
manifest = {
"name": elf_section(elf, "app_name"),
"version": elf_section(elf, "app_version"),
"icon": str(root / icon),
"targetId": elf_section(elf, "target_id"),
"flags": f"0x{int(str(flags), 0):03x}",
"apiLevel": elf_section(elf, "api_level"),
"derivationPath": {"curves": meta["curve"], "paths": meta["path"]},
"binary": hex_path.name,
"dataSize": data_size(elf),
}
out = elf.parent / f"app_{device}.json"
out.write_text(json.dumps(manifest, indent=4) + "\n")
print(f"wrote {out.relative_to(root)} "
f"(name={manifest['name']} apiLevel={manifest['apiLevel']} "
f"dataSize={manifest['dataSize']})")
if __name__ == "__main__":
main()

47
scripts/send-apdu Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Send a raw APDU (hex) to whatever app is currently open on a connected Ledger.
Usage:
./scripts/send-apdu E005010015058000002C8000374F800000000000000000000000
Or pipe:
echo E005010015058000002C8000374F800000000000000000000000 | ./scripts/send-apdu
"""
import sys
from ledgerwallet.transport import enumerate_devices
def main() -> int:
if len(sys.argv) > 1:
hex_apdu = sys.argv[1]
else:
hex_apdu = sys.stdin.read().strip()
hex_apdu = hex_apdu.replace(" ", "").replace("\n", "")
if not hex_apdu:
print("usage: send-apdu <hex-apdu>", file=sys.stderr)
return 2
apdu = bytes.fromhex(hex_apdu)
devices = enumerate_devices()
if not devices:
print("no Ledger device found", file=sys.stderr)
return 1
transport = devices[0]
transport.open()
try:
print(f"-> {apdu.hex().upper()}")
# 60s timeout to give the user time to confirm on-device.
response = transport.exchange(apdu, timeout=60000)
if len(response) < 2:
print(f"<- short response: {response.hex().upper()}")
return 1
data, sw = response[:-2], int.from_bytes(response[-2:], "big")
print(f"<- sw={sw:04x} data={data.hex().upper()} ({len(data)} bytes)")
finally:
transport.close()
return 0 if response[-2:] == b"\x90\x00" else 1
if __name__ == "__main__":
sys.exit(main())

218
scripts/sign-tx Executable file
View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""End-to-end SIGN_TX driver for the PSBT-style state machine (INS 0x06).
Streams an unsigned Handshake tx to the Ledger app as structured sub-ops
(BEGIN / ADD_INPUT / ADD_OUTPUT / REVIEW / SIGN_INPUT), then assembles the
signed tx with one witness per input.
Usage:
./scripts/sign-tx \
--path "44'/5353'/0'/0/0" \
--input 865d9e8011815fbfeb168bff83767f039cd6ac71bd75893d74fb89a3d932322d:0:10000000 \
--output hs1qdydhjsla4ahvy5tn6w44ax7y62mpsd5ny4jlej:9999000
Amounts are in dollarydoos. 1 HNS = 1,000,000 dollarydoos.
Exit code 0 = signed; nonzero = error or device rejection.
"""
import argparse
import sys
import bech32 # type: ignore[import-untyped]
from ledgerwallet.transport import enumerate_devices
CLA = 0xE0
INS_GET_PUBKEY = 0x05
INS_SIGN_TX = 0x06
P1_BEGIN = 0x00
P1_ADD_INPUT = 0x01
P1_ADD_OUTPUT = 0x02
P1_REVIEW = 0x03
P1_SIGN_INPUT = 0x04
P2_LAST = 0x00
P2_MORE = 0x80
SCRIPT_KIND_P2WPKH = 0x00
MAX_DATA = 240
HRP = {"main": "hs", "testnet": "ts", "regtest": "rs", "simnet": "ss"}
def open_device():
devs = enumerate_devices()
if not devs:
print("no Ledger device found", file=sys.stderr)
sys.exit(1)
t = devs[0]
t.open()
return t
def parse_path(s: str) -> bytes:
"""`count(1) || components[count](BE u32, hardened bit set on quoted parts)`."""
parts = [p for p in s.lstrip("m/").split("/") if p]
out = bytearray([len(parts)])
for p in parts:
n = int(p.rstrip("'"))
if p.endswith("'"):
n |= 0x80000000
out.extend(n.to_bytes(4, "big"))
return bytes(out)
def encode_varint(n: int) -> bytes:
if n < 0xFD:
return bytes([n])
if n <= 0xFFFF:
return b"\xfd" + n.to_bytes(2, "little")
if n <= 0xFFFFFFFF:
return b"\xfe" + n.to_bytes(4, "little")
return b"\xff" + n.to_bytes(8, "little")
def apdu(ins: int, p1: int, p2: int, data: bytes) -> bytes:
if len(data) > 255:
raise RuntimeError(f"APDU data too long: {len(data)}")
return bytes([CLA, ins, p1, p2, len(data)]) + data
def exchange(t, apdu_bytes: bytes, timeout_ms: int = 120000) -> bytes:
"""Same convention as scripts/send-apdu: transport returns data || sw."""
print(f"-> {apdu_bytes.hex().upper()}", file=sys.stderr)
response = t.exchange(apdu_bytes, timeout=timeout_ms)
if len(response) < 2:
raise RuntimeError(f"short response: {response.hex()}")
sw = int.from_bytes(response[-2:], "big")
data = bytes(response[:-2])
print(f"<- sw={sw:04x} data={data.hex().upper()} ({len(data)} bytes)", file=sys.stderr)
if sw != 0x9000:
raise RuntimeError(f"APDU failed: sw={sw:04x}")
return data
def get_compressed_pubkey(t, path_bytes: bytes) -> bytes:
"""GET_PUBKEY (no display): pk_len(1)||pk||cc_len(1)||cc||addr_len(1)||addr."""
data = exchange(t, apdu(INS_GET_PUBKEY, 0, 0, path_bytes))
return data[1 : 1 + data[0]]
def parse_input(s: str):
"""`<txid_hex>:<vout>:<value_dollarydoos>`."""
txid_str, vout_str, value_str = s.split(":")
txid = bytes.fromhex(txid_str)
if len(txid) != 32:
raise ValueError(f"input txid must be 32 bytes, got {len(txid)}")
return txid, int(vout_str), int(value_str)
def parse_output(s: str, hrp: str) -> bytes:
"""`<bech32_addr>:<value>` -> value(8 LE)||ver(1)||len(1)||hash||cov(0x00)||varint(0)."""
addr, amount_str = s.rsplit(":", 1)
got_hrp, data = bech32.bech32_decode(addr)
if got_hrp != hrp:
raise ValueError(f"address HRP {got_hrp!r} != expected {hrp!r}")
if not data:
raise ValueError(f"invalid bech32 address: {addr}")
witver = data[0]
program = bech32.convertbits(data[1:], 5, 8, False)
if program is None:
raise ValueError(f"invalid bech32 program: {addr}")
return (
int(amount_str).to_bytes(8, "little")
+ bytes([witver, len(program)])
+ bytes(program)
+ b"\x00" # covenant kind NONE
+ encode_varint(0) # covenant item count
)
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--path", required=True, help="BIP-32 path, e.g. 44'/5353'/0'/0/0")
ap.add_argument("--input", action="append", required=True,
help="`<txid>:<vout>:<value>` (repeat for multiple inputs)")
ap.add_argument("--output", action="append", default=[],
help="`<bech32_addr>:<value>` (repeat for multiple outputs)")
ap.add_argument("--tx-version", type=int, default=0)
ap.add_argument("--locktime", type=int, default=0)
ap.add_argument("--sequence", type=lambda s: int(s, 0), default=0xFFFFFFFF)
ap.add_argument("--sighash-type", type=lambda s: int(s, 0), default=0x01)
ap.add_argument("--network", choices=list(HRP), default="main")
args = ap.parse_args()
hrp = HRP[args.network]
path_bytes = parse_path(args.path)
inputs = [parse_input(i) for i in args.input]
outputs = [parse_output(o, hrp) for o in args.output]
t = open_device()
try:
pubkey = get_compressed_pubkey(t, path_bytes)
print(f"# compressed pubkey: {pubkey.hex().upper()}", file=sys.stderr)
# BEGIN
exchange(t, apdu(INS_SIGN_TX, P1_BEGIN, 0,
args.tx_version.to_bytes(4, "little")
+ encode_varint(len(inputs))
+ encode_varint(len(outputs))
+ args.locktime.to_bytes(4, "little")))
# ADD_INPUT (one APDU each)
for txid, vout, value in inputs:
exchange(t, apdu(INS_SIGN_TX, P1_ADD_INPUT, 0,
txid
+ vout.to_bytes(4, "little")
+ args.sequence.to_bytes(4, "little")
+ value.to_bytes(8, "little")
+ bytes([args.sighash_type])
+ path_bytes
+ bytes([SCRIPT_KIND_P2WPKH])))
# ADD_OUTPUT (chunked; P2 = MORE until the last chunk of each output)
for payload in outputs:
chunks = [payload[i:i + MAX_DATA] for i in range(0, len(payload), MAX_DATA)]
for i, c in enumerate(chunks):
p2 = P2_LAST if i == len(chunks) - 1 else P2_MORE
exchange(t, apdu(INS_SIGN_TX, P1_ADD_OUTPUT, p2, c))
# REVIEW (blocks on the user)
exchange(t, apdu(INS_SIGN_TX, P1_REVIEW, 0, b""))
# SIGN_INPUT per input -> sig(64)||sighash(1)||pk_len(1)||pk
witnesses = []
for idx in range(len(inputs)):
r = exchange(t, apdu(INS_SIGN_TX, P1_SIGN_INPUT, 0, idx.to_bytes(4, "big")))
if len(r) < 66 or len(r) != 66 + r[65]:
raise RuntimeError(f"bad SIGN_INPUT response: {r.hex()}")
sig_with_sighash = r[:65]
pk = r[66:]
print(f"# input {idx} sig: {sig_with_sighash.hex().upper()}", file=sys.stderr)
witnesses.append(
encode_varint(2)
+ encode_varint(len(sig_with_sighash)) + sig_with_sighash
+ encode_varint(len(pk)) + pk
)
# version || inputs || outputs || locktime || witnesses (shd Transaction.write)
tx = bytearray(args.tx_version.to_bytes(4, "little"))
tx += encode_varint(len(inputs))
for txid, vout, _ in inputs:
tx += txid + vout.to_bytes(4, "little") + args.sequence.to_bytes(4, "little")
tx += encode_varint(len(outputs))
for payload in outputs:
tx += payload
tx += args.locktime.to_bytes(4, "little")
for w in witnesses:
tx += w
print(tx.hex())
return 0
finally:
t.close()
if __name__ == "__main__":
sys.exit(main())

43
scripts/test Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Run the ragger functional tests against speculos, in Docker.
#
# Speculos does not run natively on macOS, so both it and ragger live in a
# container built from Ledger's dev-tools image (see tests/Dockerfile). The
# image is multi-arch, so this runs natively on Apple silicon.
#
# Usage:
# ./scripts/test # stax
# ./scripts/test flex # another device
# ./scripts/test stax --golden_run # regenerate snapshots
# ./scripts/test stax -k policy -v # any extra pytest args
#
# Regenerating snapshots is a deliberate act: they are the record of what the
# review screen shows, which is the thing a hardware wallet is for. Look at the
# diff before committing one.
set -euo pipefail
cd "$(dirname "$0")/.."
DEVICE="${1:-stax}"
[[ $# -gt 0 ]] && shift
# ragger's device names differ from the build target directory names.
case "$DEVICE" in
nanosp) TARGET=nanosplus ;;
*) TARGET="$DEVICE" ;;
esac
IMAGE=ledger-hns-test
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "building $IMAGE (first run only)..."
docker build -t "$IMAGE" -f tests/Dockerfile tests/
fi
if [[ ! -f "target/$TARGET/release/app-handshake" ]]; then
echo "app not built for $TARGET, building..."
./scripts/build "$TARGET"
fi
exec docker run --rm -v "$PWD":/app -w /app "$IMAGE" \
pytest tests/standalone --tb=short -v --device "$DEVICE" "$@"

45
src/app_ui/address.rs Normal file
View File

@@ -0,0 +1,45 @@
//! Address confirmation screen for GET_PUBKEY with P1=1.
use ledger_device_sdk::include_gif;
use ledger_device_sdk::io::Comm;
use ledger_device_sdk::nbgl::{Field, NbglAddressReview, NbglGlyph};
use crate::handshake::Network;
use crate::AppSW;
/// Show an address for the user to compare against what their wallet displays.
///
/// The derivation path goes on the screen too: without it a user confirming
/// "their" receive address cannot tell account 0 from account 99, nor a
/// legitimate index from one the host substituted.
pub fn ui_display_address(
comm: &mut Comm,
network: Network,
address: &str,
path: &str,
) -> Result<bool, AppSW> {
#[cfg(target_os = "apex_p")]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("glyphs/handshake_48x48.png", NBGL));
#[cfg(any(target_os = "stax", target_os = "flex"))]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("glyphs/handshake_64x64.gif", NBGL));
#[cfg(any(target_os = "nanosplus", target_os = "nanox"))]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("icons/handshake_14x14.gif", NBGL));
let title = match network {
Network::Mainnet => "Verify Handshake address",
Network::Testnet => "Verify Handshake testnet address",
Network::Regtest => "Verify Handshake regtest address",
Network::Simnet => "Verify Handshake simnet address",
};
let fields = [Field {
name: "Derivation path",
value: path,
}];
Ok(NbglAddressReview::new()
.glyph(&ICON)
.review_title(title)
.set_tag_value_list(&fields)
.show(comm, address))
}

17
src/app_ui/menu.rs Normal file
View File

@@ -0,0 +1,17 @@
use ledger_device_sdk::include_gif;
use ledger_device_sdk::io::Comm;
use ledger_device_sdk::nbgl::{NbglGlyph, NbglHomeAndSettings};
pub fn ui_menu_main(_: &mut Comm) -> NbglHomeAndSettings {
#[cfg(target_os = "apex_p")]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("glyphs/handshake_48x48.png", NBGL));
#[cfg(any(target_os = "stax", target_os = "flex"))]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("glyphs/handshake_64x64.gif", NBGL));
#[cfg(any(target_os = "nanosplus", target_os = "nanox"))]
const ICON: NbglGlyph =
NbglGlyph::from_include(include_gif!("glyphs/home_nano_nbgl.png", NBGL));
NbglHomeAndSettings::new()
.glyph(&ICON)
.infos("Handshake", env!("CARGO_PKG_VERSION"), "eskimo")
}

80
src/app_ui/message.rs Normal file
View File

@@ -0,0 +1,80 @@
//! Stax review screen for SIGN_MESSAGE.
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use ledger_device_sdk::include_gif;
use ledger_device_sdk::io::Comm;
use ledger_device_sdk::nbgl::{Field, NbglGlyph, NbglReview};
use crate::handshake::{address as hns_address, bech32_encode, blake2b, hex_string, Network};
use crate::utils::{format_path, get_pubkey_from_path, Bip32Path};
use crate::AppSW;
/// Show the message + signer address, wait on "Hold to sign". Returns
/// `true` on approval.
///
/// `message` is displayed verbatim; it's expected to be UTF-8 text. If
/// it's binary or longer than what fits, we fall back to showing its
/// BLAKE2b-256 hash: the user at least gets a stable fingerprint for
/// the thing they're being asked to sign.
pub fn ui_display_message(
comm: &mut Comm,
path: &Bip32Path,
message: &[u8],
) -> Result<bool, AppSW> {
#[cfg(target_os = "apex_p")]
const ICON: NbglGlyph =
NbglGlyph::from_include(include_gif!("glyphs/handshake_48x48.png", NBGL));
#[cfg(any(target_os = "stax", target_os = "flex"))]
const ICON: NbglGlyph =
NbglGlyph::from_include(include_gif!("glyphs/handshake_64x64.gif", NBGL));
#[cfg(any(target_os = "nanosplus", target_os = "nanox"))]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("icons/handshake_14x14.gif", NBGL));
let network = Network::from_coin_type(path.as_ref().get(1).copied().unwrap_or(0));
let pubkey_uncomp = get_pubkey_from_path(path)?;
let compressed = hns_address::compress_pubkey(&pubkey_uncomp);
let pubkey_hash = blake2b::blake2b_160(&compressed);
let signer_addr = bech32_encode::encode_p2wpkh(network, &pubkey_hash);
let message_display = match core::str::from_utf8(message) {
Ok(s) if s.chars().all(|c| !c.is_control() || c == '\n' || c == '\t') => {
truncate(s, 512)
}
_ => format!(
"[binary, blake2b-256: {}]",
hex_string(&blake2b::blake2b_256(message))
),
};
let owned: Vec<(String, String)> = alloc::vec![
(String::from("Signer"), signer_addr),
(String::from("Derivation path"), format_path(path)),
(String::from("Message"), message_display),
];
let fields: Vec<Field> = owned
.iter()
.map(|(n, v)| Field {
name: n.as_str(),
value: v.as_str(),
})
.collect();
let review = NbglReview::new()
.titles("Review message", "", "Sign message")
.glyph(&ICON);
Ok(review.show(comm, &fields))
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
String::from(s)
} else {
let mut out = String::from(&s[..max.saturating_sub(1).min(s.len())]);
out.push('…');
out
}
}

166
src/app_ui/sign.rs Normal file
View File

@@ -0,0 +1,166 @@
//! Stax review screen for SIGN_TX.
//!
//! Everything the signature commits to and that the host can vary must appear
//! here. `handlers::sign_tx` refuses the cases this screen cannot represent
//! honestly (non-ALL sighash types, covenant kinds the device cannot name), so
//! the two files have to be read together.
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use ledger_device_sdk::include_gif;
use ledger_device_sdk::io::Comm;
use ledger_device_sdk::nbgl::{Field, NbglGlyph, NbglReview};
use crate::handshake::{
bech32_encode, covenant, hex_string,
tx::{Address, Tx},
Network,
};
use crate::AppSW;
/// 1 HNS = 1,000,000 dollarydoos. Source: `shd/Sources/Base/Amount.swift:10`.
const DOOS_PER_HNS: u64 = 1_000_000;
pub fn ui_display_tx(
comm: &mut Comm,
network: Network,
tx: &Tx<'_>,
fee: u64,
input_count: usize,
total_in: u64,
) -> Result<bool, AppSW> {
#[cfg(target_os = "apex_p")]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("glyphs/handshake_48x48.png", NBGL));
#[cfg(any(target_os = "stax", target_os = "flex"))]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("glyphs/handshake_64x64.gif", NBGL));
#[cfg(any(target_os = "nanosplus", target_os = "nanox"))]
const ICON: NbglGlyph = NbglGlyph::from_include(include_gif!("icons/handshake_14x14.gif", NBGL));
// Build owned strings first; the `Field` slice will borrow into them.
let mut owned: Vec<(String, String)> = Vec::with_capacity(tx.outputs.len() * 3 + 6);
// Leads the screen when it is not mainnet: the HRP on each address says the
// same thing, but only to someone who reads address prefixes.
if network != Network::Mainnet {
owned.push((String::from("Network"), String::from(network_label(network))));
}
for (i, out) in tx.outputs.iter().enumerate() {
let addr_str = match &out.address {
Address::P2WPKH(h) => bech32_encode::encode_p2wpkh(network, h),
Address::P2WSH(h) => bech32_encode::encode_p2wsh(network, h),
};
owned.push((format!("Output {} to", i + 1), addr_str));
owned.push((format!("Output {} amount", i + 1), format_hns(out.value)));
// A covenant is the whole point of the transaction when there is one, so it
// gets its own rows rather than being appended to the amount as hex.
if !out.covenant.is_none() {
let kind = out.covenant.kind;
let items = &out.covenant.items;
owned.push((
format!("Output {} operation", i + 1),
String::from(covenant::label(kind)),
));
// The plaintext name is shown only when the device could check it
// against the hash the covenant commits to. When it could not, show
// the hash itself rather than nothing: otherwise a covenant whose
// name failed verification looks exactly like one that carries no
// name at all, and the user has nothing to compare against their
// wallet.
if let Some(name) = covenant::verified_name(kind, items) {
owned.push((format!("Output {} name", i + 1), name));
} else if let Some(hash) = covenant::name_hash(kind, items) {
owned.push((format!("Output {} name hash", i + 1), hex_string(hash)));
}
// TRANSFER hands the name to another address, and that address
// appears nowhere else: the output itself pays back to the name's
// current address for its existing lockup, so without this row a
// transfer to a thief renders identically to one to yourself.
if let Some((version, hash)) = covenant::transfer_destination(kind, items) {
owned.push((
format!("Output {} transfer to", i + 1),
format_program(network, version, hash),
));
}
}
}
// The device can only vouch for the values of inputs it is asked to sign;
// a host may declare others purely to move this total. Showing the count
// and sum puts a padded total on screen instead of leaving it to surface
// only as an oddly small fee.
owned.push((
String::from("Inputs"),
format!("{} totalling {}", input_count, format_hns(total_in)),
));
owned.push((String::from("Fee"), format_hns(fee)));
// Both are committed to by the signature and both come straight off the
// wire. A far-future locktime keeps an approved transaction from confirming
// at all, which for a height-bounded REVEAL or REDEEM forfeits the bid.
if tx.locktime != 0 {
owned.push((String::from("Locktime"), format!("{}", tx.locktime)));
}
if tx.version != 0 {
owned.push((String::from("Tx version"), format!("{}", tx.version)));
}
let fields: Vec<Field> = owned
.iter()
.map(|(name, value)| Field {
name: name.as_str(),
value: value.as_str(),
})
.collect();
let review = NbglReview::new()
.titles("Review HNS transaction", "", "Sign transaction")
.glyph(&ICON);
Ok(review.show(comm, &fields))
}
fn network_label(network: Network) -> &'static str {
match network {
Network::Mainnet => "mainnet",
Network::Testnet => "testnet",
Network::Regtest => "regtest",
Network::Simnet => "simnet",
}
}
/// Render a witness program as an address when the device can encode it, and
/// as its raw hex when it cannot, so an exotic version never renders as nothing.
fn format_program(network: Network, version: u8, hash: &[u8]) -> String {
match (version, hash.len()) {
(0, 20) => {
let mut h = [0u8; 20];
h.copy_from_slice(hash);
bech32_encode::encode_p2wpkh(network, &h)
}
(0, 32) => {
let mut h = [0u8; 32];
h.copy_from_slice(hash);
bech32_encode::encode_p2wsh(network, &h)
}
_ => format!("witness v{} {}", version, hex_string(hash)),
}
}
fn format_hns(doos: u64) -> String {
let whole = doos / DOOS_PER_HNS;
let frac = doos % DOOS_PER_HNS;
if frac == 0 {
return format!("{} HNS", whole);
}
let mut frac_str = format!("{:06}", frac);
while frac_str.ends_with('0') {
frac_str.pop();
}
format!("{}.{} HNS", whole, frac_str)
}

View File

@@ -0,0 +1,56 @@
//! GET_PUBLIC_KEY APDU handler.
//!
//! Derives a secp256k1 key from a BIP-32 path, returns the compressed pubkey
//! plus chain code plus the corresponding Handshake bech32 address.
//! If `display=true`, shows the address on-device for the user to confirm.
use crate::app_ui::address::ui_display_address;
use crate::handshake::{address as hns_address, Network};
use crate::utils::{coin_type, format_path, get_pubkey_from_path, validate_path, Bip32Path};
use crate::AppSW;
use ledger_device_sdk::ecc::{Secp256k1, SeedDerive};
use ledger_device_sdk::io::{Command, CommandResponse};
pub fn handler_get_public_key(
command: Command<'_>,
display: bool,
) -> Result<CommandResponse<'_>, AppSW> {
let data = command.get_data();
let path: Bip32Path = data.try_into()?;
validate_path(&path)?;
let uncompressed = get_pubkey_from_path(&path)?;
let (_, chaincode) = Secp256k1::derive_from(path.as_ref());
// An `unwrap` here would abort the app on a derivation the SDK declined to
// produce a chain code for, and the path reaching it is host-chosen.
let chaincode = chaincode.ok_or(AppSW::KeyDeriveFail)?;
let compressed = hns_address::compress_pubkey(&uncompressed);
let network = network_for_path(&path);
let address = hns_address::p2wpkh_address(network, &uncompressed);
let path_str = format_path(&path);
let comm = command.into_comm();
if display && !ui_display_address(comm, network, &address, &path_str)? {
return Err(AppSW::Deny);
}
let mut response = comm.begin_response();
response.append(&[compressed.len() as u8])?;
response.append(&compressed)?;
response.append(&[chaincode.value.len() as u8])?;
response.append(&chaincode.value)?;
let addr_bytes = address.as_bytes();
response.append(&[addr_bytes.len() as u8])?;
response.append(addr_bytes)?;
Ok(response)
}
/// Pick the Handshake network from the BIP-32 path's coin-type component.
///
/// `m/44'/5353'/...` → mainnet (`hs`), `5354'` → testnet (`ts`),
/// `5355'` → regtest (`rs`), `5356'` → simnet (`ss`).
/// Anything else defaults to mainnet for safety.
fn network_for_path(path: &Bip32Path) -> Network {
Network::from_coin_type(coin_type(path))
}

View File

@@ -0,0 +1,40 @@
/*****************************************************************************
* Ledger App Boilerplate Rust.
* (c) 2023 Ledger SAS.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*****************************************************************************/
use crate::AppSW;
use core::str::FromStr;
use ledger_device_sdk::io::{Command, CommandResponse};
pub fn handler_get_version(command: Command<'_>) -> Result<CommandResponse<'_>, AppSW> {
if let Some((major, minor, patch)) = parse_version_string(env!("CARGO_PKG_VERSION")) {
let mut response = command.into_response();
response.append(&[major, minor, patch])?;
Ok(response)
} else {
Err(AppSW::VersionParsingFail)
}
}
fn parse_version_string(input: &str) -> Option<(u8, u8, u8)> {
// Split the input string by '.'.
// Input should be of the form "major.minor.patch",
// where "major", "minor", and "patch" are integers.
let mut parts = input.split('.');
let major = u8::from_str(parts.next()?).ok()?;
let minor = u8::from_str(parts.next()?).ok()?;
let patch = u8::from_str(parts.next()?).ok()?;
Some((major, minor, patch))
}

View File

@@ -0,0 +1,151 @@
//! SIGN_MESSAGE APDU handler.
//!
//! Single-shot APDU (INS=0x07, P1=0, P2=0).
//!
//! **Payload:** `path_len(1) || path[path_len * 4 BE] || msg_len(2 LE) || message[msg_len]`
//!
//! **Response:** `recovery_id(1) || sig_compact(64) || pubkey_compressed(33)`, 98 bytes.
//!
//! The device prepends `"handshake signed message:\n"` to the message
//! (matching `shd`'s `verifymessage`; see `Sources/Node/SPVRPC.swift:693`
//! and `Sources/Node/FullNode.swift:3857`), BLAKE2b-256 hashes the result,
//! shows the signer address + message preview, and on approval signs the
//! hash. The host combines the returned `[recovery_id, sig]` into the
//! standard 65-byte recoverable signature format.
use alloc::vec::Vec;
use ledger_device_sdk::ecc::{Secp256k1, SeedDerive};
use ledger_device_sdk::io::{Command, CommandResponse};
use crate::app_ui::message::ui_display_message;
use crate::handshake::{address as hns_address, blake2b};
use crate::handlers::sign_tx::TxContext;
use crate::utils::{get_pubkey_from_path, validate_path, Bip32Path};
use crate::AppSW;
const MESSAGE_PREFIX: &[u8] = b"handshake signed message:\n";
const MAX_MESSAGE_LEN: usize = 512;
pub fn handler_sign_message<'a>(
command: Command<'a>,
_ctx: &mut TxContext,
) -> Result<CommandResponse<'a>, AppSW> {
let data = command.get_data();
let mut r = Reader::new(data);
let path_len = r.read_u8()? as usize;
if path_len == 0 || path_len > 10 {
return Err(AppSW::TxParsingFail);
}
let mut path_words = [0u32; 10];
for slot in path_words.iter_mut().take(path_len) {
*slot = r.read_u32_be()?;
}
let msg_len = r.read_u16_le()? as usize;
if msg_len > MAX_MESSAGE_LEN {
return Err(AppSW::TxWrongLength);
}
let msg = r.read_bytes(msg_len)?.to_vec();
if !r.is_empty() {
return Err(AppSW::TxWrongLength);
}
let path = Bip32Path::from_raw(&path_words[..path_len])?;
validate_path(&path)?;
let mut preimage = Vec::with_capacity(MESSAGE_PREFIX.len() + msg.len());
preimage.extend_from_slice(MESSAGE_PREFIX);
preimage.extend_from_slice(&msg);
let hash = blake2b::blake2b_256(&preimage);
let comm = command.into_comm();
let approved = ui_display_message(comm, &path, &msg)?;
if !approved {
return Err(AppSW::Deny);
}
let (der, der_len, parity) = Secp256k1::derive_from_path(path.as_ref())
.deterministic_sign(&hash)
.map_err(|_| AppSW::TxSignFail)?;
let compact = der_to_compact(&der[..der_len as usize]).ok_or(AppSW::TxSignFail)?;
// Recovery id's low bit is the Y-parity of R; the high bit flags x >=
// curve order, which happens with probability ~2^-128 for random hashes.
// Taking just the parity is correct for every signature anyone will
// ever encounter, and the host verifier tries all 4 candidates anyway.
let rec_id = parity as u8;
let pubkey_uncomp = get_pubkey_from_path(&path)?;
let compressed = hns_address::compress_pubkey(&pubkey_uncomp);
let mut response = comm.begin_response();
response.append(&[rec_id])?;
response.append(&compact)?;
response.append(&compressed)?;
Ok(response)
}
fn der_to_compact(der: &[u8]) -> Option<[u8; 64]> {
if der.len() < 8 || der[0] != 0x30 || der[2] != 0x02 {
return None;
}
let r_len = der[3] as usize;
let r_start: usize = 4;
let r_end = r_start.checked_add(r_len)?;
if der.len() < r_end + 2 || der[r_end] != 0x02 {
return None;
}
let s_len = der[r_end + 1] as usize;
let s_start = r_end + 2;
let s_end = s_start.checked_add(s_len)?;
if der.len() < s_end {
return None;
}
let r = &der[r_start..r_end];
let s = &der[s_start..s_end];
let r_trim = if r.len() > 32 && r[0] == 0 { &r[1..] } else { r };
let s_trim = if s.len() > 32 && s[0] == 0 { &s[1..] } else { s };
if r_trim.len() > 32 || s_trim.len() > 32 {
return None;
}
let mut out = [0u8; 64];
out[32 - r_trim.len()..32].copy_from_slice(r_trim);
out[64 - s_trim.len()..].copy_from_slice(s_trim);
Some(out)
}
struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn is_empty(&self) -> bool {
self.pos >= self.buf.len()
}
fn need(&mut self, n: usize) -> Result<&'a [u8], AppSW> {
let end = self.pos.checked_add(n).ok_or(AppSW::TxWrongLength)?;
if end > self.buf.len() {
return Err(AppSW::TxWrongLength);
}
let s = &self.buf[self.pos..end];
self.pos = end;
Ok(s)
}
fn read_u8(&mut self) -> Result<u8, AppSW> {
Ok(self.need(1)?[0])
}
fn read_u16_le(&mut self) -> Result<u16, AppSW> {
let b = self.need(2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
fn read_u32_be(&mut self) -> Result<u32, AppSW> {
let b = self.need(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn read_bytes(&mut self, n: usize) -> Result<&'a [u8], AppSW> {
self.need(n)
}
}

669
src/handlers/sign_tx.rs Normal file
View File

@@ -0,0 +1,669 @@
//! SIGN_TX state machine.
//!
//! PSBT-style multi-step flow driven by the host. Sub-ops (selected via P1):
//!
//! | P1 | Op | Payload |
//! | ----- | ------------ | ------------------------------------------------------------------------------------ |
//! | 0x00 | `BEGIN` | `tx_version(4 LE) \|\| input_count(varint) \|\| output_count(varint) \|\| locktime(4 LE)` |
//! | 0x01 | `ADD_INPUT` | `prev_hash(32) \|\| prev_idx(4 LE) \|\| seq(4 LE) \|\| value(8 LE) \|\| sighash(1) \|\| path_len(1) \|\| path[path_len*4 BE] \|\| script_kind(1)` |
//! | 0x02 | `ADD_OUTPUT` | chunked (P2 MORE/LAST): `value(8 LE) \|\| addr_version(1) \|\| addr_len(1) \|\| addr_hash[·] \|\| covenant_kind(1) \|\| covenant_items(varint) \|\| items[·]` |
//! | 0x03 | `REVIEW` | empty → shows outputs + `sum(input_values) sum(output_values)` fee, waits on user approval |
//! | 0x04 | `SIGN_INPUT` | `input_index(4 BE)` → returns `sig(64) \|\| sighash_byte(1) \|\| pk_len(1) \|\| pk[·]` |
//!
//! The host builds the tx, streams it in structured form, the device sums
//! input & output values itself, shows the review once, and signs each input on
//! demand after approval: same UX as Bitcoin's PSBT flow.
//!
//! `script_kind` in `ADD_INPUT`: `0x00` = P2WPKH (implemented). `0x01` =
//! P2WSH (reserved for multisig; rejected for now until the script parser
//! lands).
//!
//! `sighash` in `ADD_INPUT` must be `0x01` (SIGHASH_ALL). See
//! [`SIGHASH_ALL`] for why the other types are refused.
use alloc::vec::Vec;
use ledger_device_sdk::ecc::{Secp256k1, SeedDerive};
use ledger_device_sdk::io::{Command, CommandResponse};
use ledger_device_sdk::libcall::swap::CreateTxParams;
use ledger_device_sdk::nbgl::NbglHomeAndSettings;
use crate::app_ui::sign::ui_display_tx;
use crate::handshake::{
address as hns_address, blake2b, covenant,
sighash::SighashContext,
tx::{Address, Covenant, Input, Outpoint, Output, Tx},
Network,
};
use crate::utils::{coin_type, get_pubkey_from_path, validate_path, Bip32Path};
use crate::AppSW;
/// Per-input metadata supplied by the host alongside the structural tx data.
/// `value` and `sighash_type` feed the BIP-143 sighash preimage; `path` picks
/// the signing key.
#[derive(Default, Clone)]
struct InputMeta {
path: Bip32Path,
value: u64,
sighash_type: u8,
script_kind: u8,
}
const SCRIPT_KIND_P2WPKH: u8 = 0x00;
/// The only sighash type the device will sign.
///
/// The review screen shows the outputs and the fee as though the signature
/// commits to them, which is true only for SIGHASH_ALL. Under NONE the digest
/// commits to no outputs at all, and under SINGLE/SINGLEREVERSE to exactly one;
/// ANYONECANPAY drops the other inputs and NOINPUT drops the outpoint, making
/// the signature replayable against a different coin entirely. A signature of
/// any of those types, obtained behind a screen that listed concrete outputs and
/// a concrete fee, is a blank cheque.
///
/// The wallet only ever sends ALL. Supporting the rest means rendering, per
/// input, what each one does and does not commit to, which is a UI problem
/// rather than a signing one. Until that exists, refuse them.
const SIGHASH_ALL: u8 = 0x01;
/// Session ceilings, sized to the configured heap (`.cargo/config.toml`).
///
/// These bound three things at once: the accumulated session state, the
/// transaction view the sighash walks, and the review screen's strings, which
/// are the largest of the three (roughly 110 bytes per displayed row). A host
/// that declares more than this gets a status word; the wallet can split the
/// batch. The previous limits of 256/256 could not be allocated at all, so a
/// single BEGIN exhausted the heap and the panic handler killed the app.
const MAX_INPUTS: u64 = 16;
const MAX_OUTPUTS: u64 = 8;
/// Total covenant item bytes across the whole session, not per output.
const MAX_COVENANT_BYTES: usize = 1024;
/// Reassembly ceiling for one chunked `ADD_OUTPUT`.
const MAX_OUTPUT_BYTES: usize = 1024;
/// Signing session state machine.
#[derive(Default, PartialEq, Eq, Clone, Copy, Debug)]
enum Phase {
/// No session in progress. Accepts `BEGIN`.
#[default]
Idle,
/// `BEGIN` received; collecting inputs. Accepts `ADD_INPUT`.
CollectInputs,
/// All inputs received; collecting outputs. Accepts `ADD_OUTPUT`
/// (unless `output_count == 0`, in which case `REVIEW` is expected).
CollectOutputs,
/// All inputs + outputs received. Accepts `REVIEW`.
PendingReview,
/// User approved. Accepts `SIGN_INPUT` (any number).
Signing,
}
pub struct TxContext<'a> {
phase: Phase,
tx_version: u32,
input_count: u32,
output_count: u32,
locktime: u32,
inputs: Vec<Input>,
input_metas: Vec<InputMeta>,
outputs: Vec<Output>,
/// Reassembly buffer for multi-chunk `ADD_OUTPUT` (covenants with DNS
/// records can exceed a single APDU's 240-byte data limit).
pending_output_buf: Vec<u8>,
/// Running total of covenant item bytes accepted this session.
covenant_bytes: usize,
/// Fingerprint of the approved signing session. Defense-in-depth: the
/// state machine already rejects `SIGN_INPUT` pre-approval, but this
/// guards against logic bugs. If the session's input/output data
/// changes without a fresh REVIEW approval, the fingerprint mismatches
/// and we refuse to sign.
approved_fingerprint: Option<[u8; 32]>,
review_finished: bool,
pub home: NbglHomeAndSettings,
pub swap_params: Option<&'a CreateTxParams>,
}
impl<'a> TxContext<'a> {
pub fn new() -> TxContext<'a> {
TxContext {
phase: Phase::Idle,
tx_version: 0,
input_count: 0,
output_count: 0,
locktime: 0,
inputs: Vec::new(),
input_metas: Vec::new(),
outputs: Vec::new(),
pending_output_buf: Vec::new(),
covenant_bytes: 0,
approved_fingerprint: None,
review_finished: false,
home: Default::default(),
swap_params: None,
}
}
pub fn new_with_swap(params: &'a CreateTxParams) -> TxContext<'a> {
let mut ctx = Self::new();
ctx.swap_params = Some(params);
ctx
}
pub fn finished(&self) -> bool {
self.review_finished
}
fn reset(&mut self) {
self.phase = Phase::Idle;
self.tx_version = 0;
self.input_count = 0;
self.output_count = 0;
self.locktime = 0;
self.inputs.clear();
self.input_metas.clear();
self.outputs.clear();
self.pending_output_buf.clear();
self.covenant_bytes = 0;
self.approved_fingerprint = None;
self.review_finished = false;
}
/// Borrow the session as the `Tx` view the sighash builder and the review
/// screen expect. Borrowed rather than cloned: `SIGN_INPUT` builds one of
/// these per signature, and copying every input and output each time would
/// double peak heap use at the worst possible moment.
fn as_tx(&self) -> Tx<'_> {
Tx {
version: self.tx_version,
inputs: &self.inputs,
outputs: &self.outputs,
locktime: self.locktime,
}
}
}
// ─── BEGIN ─────────────────────────────────────────────────────────────────
pub fn handler_sign_begin<'a>(
command: Command<'a>,
ctx: &mut TxContext,
) -> Result<CommandResponse<'a>, AppSW> {
let data = command.get_data();
let mut r = Reader::new(data);
let version = r.read_u32_le()?;
let n_in = r.read_varint()?;
let n_out = r.read_varint()?;
let locktime = r.read_u32_le()?;
if !r.is_empty() {
return Err(AppSW::TxWrongLength);
}
if n_in == 0 || n_in > MAX_INPUTS || n_out > MAX_OUTPUTS {
return Err(AppSW::TxParsingFail);
}
ctx.reset();
ctx.phase = Phase::CollectInputs;
ctx.tx_version = version;
ctx.input_count = n_in as u32;
ctx.output_count = n_out as u32;
ctx.locktime = locktime;
// No `reserve` here: the declared counts are host-controlled, and reserving
// for them up front turns one APDU into an allocation the heap may not be
// able to satisfy. The vectors grow as records actually arrive.
Ok(command.into_response())
}
// ─── ADD_INPUT ─────────────────────────────────────────────────────────────
pub fn handler_sign_add_input<'a>(
command: Command<'a>,
ctx: &mut TxContext,
) -> Result<CommandResponse<'a>, AppSW> {
if ctx.phase != Phase::CollectInputs {
return Err(AppSW::WrongState);
}
let data = command.get_data();
let mut r = Reader::new(data);
let prev_hash: [u8; 32] = r.read_array::<32>()?;
let prev_index = r.read_u32_le()?;
let sequence = r.read_u32_le()?;
let value = r.read_u64_le()?;
let sighash_type = r.read_u8()?;
let path_len = r.read_u8()? as usize;
if path_len == 0 || path_len > 10 {
return Err(AppSW::TxParsingFail);
}
let mut path_words = [0u32; 10];
for slot in path_words.iter_mut().take(path_len) {
*slot = r.read_u32_be()?;
}
let script_kind = r.read_u8()?;
if !r.is_empty() {
return Err(AppSW::TxWrongLength);
}
if script_kind != SCRIPT_KIND_P2WPKH {
// P2WSH / multisig planned for phase 2.
return Err(AppSW::TxParsingFail);
}
// Checked per input, not once for the session: the type reaching the
// signature is this input's own, so a check that looked only at input 0
// would leave every later input free to carry something else.
if sighash_type != SIGHASH_ALL {
return Err(AppSW::TxParsingFail);
}
let path = Bip32Path::from_raw(&path_words[..path_len])?;
validate_path(&path)?;
// One network per session. The review renders every address under a single
// HRP, so a mixed-coin-type session would show real mainnet destinations
// wearing a regtest prefix.
if let Some(first) = ctx.input_metas.first() {
if coin_type(&first.path) != coin_type(&path) {
return Err(AppSW::BadDerivationPath);
}
}
ctx.inputs.push(Input {
outpoint: Outpoint {
hash: prev_hash,
index: prev_index,
},
sequence,
});
ctx.input_metas.push(InputMeta {
path,
value,
sighash_type,
script_kind,
});
if ctx.inputs.len() as u32 == ctx.input_count {
ctx.phase = if ctx.output_count == 0 {
Phase::PendingReview
} else {
Phase::CollectOutputs
};
}
Ok(command.into_response())
}
// ─── ADD_OUTPUT ────────────────────────────────────────────────────────────
pub fn handler_sign_add_output<'a>(
command: Command<'a>,
more: bool,
ctx: &mut TxContext,
) -> Result<CommandResponse<'a>, AppSW> {
if ctx.phase != Phase::CollectOutputs {
return Err(AppSW::WrongState);
}
let data = command.get_data();
if ctx.pending_output_buf.len() + data.len() > MAX_OUTPUT_BYTES {
return Err(AppSW::TxWrongLength);
}
ctx.pending_output_buf.extend_from_slice(data);
if more {
return Ok(command.into_response());
}
// Last chunk: parse the full output and append.
let output = parse_output(&ctx.pending_output_buf)?;
ctx.pending_output_buf.clear();
// Budget covenant bytes across the whole session, not per output: eight
// outputs each just under the per-output ceiling would otherwise add up to
// more than the heap holds.
let bytes: usize = output.covenant.items.iter().map(|i| i.len()).sum();
ctx.covenant_bytes = ctx.covenant_bytes.saturating_add(bytes);
if ctx.covenant_bytes > MAX_COVENANT_BYTES {
return Err(AppSW::TxWrongLength);
}
ctx.outputs.push(output);
if ctx.outputs.len() as u32 == ctx.output_count {
ctx.phase = Phase::PendingReview;
}
Ok(command.into_response())
}
// ─── REVIEW ────────────────────────────────────────────────────────────────
pub fn handler_sign_review<'a>(
command: Command<'a>,
ctx: &mut TxContext,
) -> Result<CommandResponse<'a>, AppSW> {
if ctx.phase != Phase::PendingReview {
return Err(AppSW::WrongState);
}
if !command.get_data().is_empty() {
return Err(AppSW::TxWrongLength);
}
// Swap mode is not implemented (see `src/swap.rs`, which is a stub, and
// `normal_main`, which is only ever called with `None`). Refusing here
// rather than approving keeps the unreachable branch from becoming an
// unreviewed signature the day someone wires Exchange up.
if ctx.swap_params.is_some() {
return Err(AppSW::SwapFail);
}
let tx = ctx.as_tx();
// The device accumulates its own view of the values rather than parsing
// them out of raw tx bytes, so for the inputs it actually signs the totals
// are self-enforcing: a lie about a signed input's value corrupts that
// input's BIP-143 preimage and consensus rejects the signature.
//
// What that does NOT cover is an input the host declares and then never
// asks the device to sign, whose value still lands in this sum. Reject
// rather than truncate, and show the input count and total alongside the
// fee so a padded total is visible on screen instead of silently wrapping
// to a plausible-looking number.
let sum_in: u128 = ctx.input_metas.iter().map(|m| m.value as u128).sum();
let sum_out: u128 = tx.outputs.iter().map(|o| o.value as u128).sum();
if sum_in > u64::MAX as u128 || sum_out > sum_in {
return Err(AppSW::TxParsingFail);
}
let total_in = sum_in as u64;
let fee = (sum_in - sum_out) as u64;
let network = network_for_path(&ctx.input_metas[0].path);
let input_count = ctx.input_metas.len();
let comm = command.into_comm();
let approved = ui_display_tx(comm, network, &tx, fee, input_count, total_in)?;
// Set after the reset, not before: `reset` clears this flag, so assigning
// it first left `finished()` false on rejection and the main loop never
// returned the device to its home screen.
if !approved {
ctx.reset();
ctx.review_finished = true;
return Err(AppSW::Deny);
}
ctx.review_finished = true;
ctx.approved_fingerprint = Some(session_fingerprint(ctx));
ctx.phase = Phase::Signing;
Ok(comm.begin_response())
}
// ─── SIGN_INPUT ────────────────────────────────────────────────────────────
pub fn handler_sign_input<'a>(
command: Command<'a>,
ctx: &mut TxContext,
) -> Result<CommandResponse<'a>, AppSW> {
if ctx.phase != Phase::Signing {
return Err(AppSW::WrongState);
}
let data = command.get_data();
if data.len() != 4 {
return Err(AppSW::TxWrongLength);
}
let idx = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if idx >= ctx.inputs.len() {
return Err(AppSW::TxParsingFail);
}
// Re-verify the approved session hasn't been mutated under us.
let expected = ctx.approved_fingerprint.as_ref().ok_or(AppSW::WrongState)?;
let actual = session_fingerprint(ctx);
if *expected != actual {
return Err(AppSW::WrongState);
}
let meta = &ctx.input_metas[idx];
// Belt and braces: `ADD_INPUT` already refuses anything else, so reaching
// this means the session state was corrupted after the fact.
if meta.sighash_type != SIGHASH_ALL {
return Err(AppSW::WrongState);
}
let tx = ctx.as_tx();
let pubkey_uncomp = get_pubkey_from_path(&meta.path)?;
let compressed = hns_address::compress_pubkey(&pubkey_uncomp);
let pubkey_hash = blake2b::blake2b_160(&compressed);
let script_code = build_p2wpkh_script_code(&pubkey_hash);
let sighash = SighashContext {
tx: &tx,
input_index: idx,
script_code: &script_code,
value: meta.value,
sighash_type: meta.sighash_type,
}
.compute();
let (der, der_len, _parity) = Secp256k1::derive_from_path(meta.path.as_ref())
.deterministic_sign(&sighash)
.map_err(|_| AppSW::TxSignFail)?;
let der_slice = &der[..der_len as usize];
let compact = der_to_compact(der_slice).ok_or(AppSW::TxSignFail)?;
let comm = command.into_comm();
let mut response = comm.begin_response();
response.append(&compact)?;
response.append(&[meta.sighash_type])?;
response.append(&[compressed.len() as u8])?;
response.append(&compressed)?;
Ok(response)
}
// ─── Helpers ───────────────────────────────────────────────────────────────
/// BLAKE2b-256 over a deterministic encoding of the session's input and
/// output data. Used to detect tampering between REVIEW approval and
/// subsequent SIGN_INPUT calls.
fn session_fingerprint(ctx: &TxContext) -> [u8; 32] {
let mut buf: Vec<u8> = Vec::with_capacity(256);
buf.extend_from_slice(&ctx.tx_version.to_le_bytes());
buf.extend_from_slice(&ctx.locktime.to_le_bytes());
for (input, meta) in ctx.inputs.iter().zip(ctx.input_metas.iter()) {
buf.extend_from_slice(&input.outpoint.hash);
buf.extend_from_slice(&input.outpoint.index.to_le_bytes());
buf.extend_from_slice(&input.sequence.to_le_bytes());
buf.extend_from_slice(&meta.value.to_le_bytes());
buf.push(meta.sighash_type);
buf.push(meta.script_kind);
let path = meta.path.as_ref();
buf.push(path.len() as u8);
for w in path {
buf.extend_from_slice(&w.to_be_bytes());
}
}
for out in &ctx.outputs {
buf.extend_from_slice(&out.value.to_le_bytes());
match &out.address {
Address::P2WPKH(h) => {
buf.push(0);
buf.push(20);
buf.extend_from_slice(h);
}
Address::P2WSH(h) => {
buf.push(0);
buf.push(32);
buf.extend_from_slice(h);
}
}
buf.push(out.covenant.kind);
buf.push(out.covenant.items.len() as u8);
for item in &out.covenant.items {
let len = item.len() as u32;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(item);
}
}
blake2b::blake2b_256(&buf)
}
/// Parse one ADD_OUTPUT payload: `value || addr_version || addr_len || addr
/// || covenant_kind || covenant_items_count || [items]`.
fn parse_output(buf: &[u8]) -> Result<Output, AppSW> {
let mut r = Reader::new(buf);
let value = r.read_u64_le()?;
let addr_version = r.read_u8()?;
if addr_version != 0 {
return Err(AppSW::TxParsingFail);
}
let addr_len = r.read_u8()? as usize;
let address = match addr_len {
20 => Address::P2WPKH(r.read_array::<20>()?),
32 => Address::P2WSH(r.read_array::<32>()?),
_ => return Err(AppSW::TxParsingFail),
};
let kind = r.read_u8()?;
// An unknown kind would be labelled "UNKNOWN" on screen while its bytes go
// into the sighash verbatim. Refuse instead of asking the user to approve
// an operation the device cannot name.
if kind > covenant::MAX_KIND {
return Err(AppSW::TxParsingFail);
}
let n_items = r.read_varint()? as usize;
if n_items > 16 {
return Err(AppSW::TxParsingFail);
}
let mut items: Vec<Vec<u8>> = Vec::with_capacity(n_items);
for _ in 0..n_items {
let item_len = r.read_varint()? as usize;
items.push(r.read_bytes(item_len)?.to_vec());
}
if !r.is_empty() {
return Err(AppSW::TxWrongLength);
}
// Kind 0 means "no covenant". Items alongside it would be committed to the
// sighash while the review screen read "none".
if kind == 0 && !items.is_empty() {
return Err(AppSW::TxParsingFail);
}
Ok(Output {
value,
address,
covenant: Covenant { kind, items },
})
}
fn network_for_path(path: &Bip32Path) -> Network {
Network::from_coin_type(coin_type(path))
}
/// P2WPKH script_code: `OP_DUP OP_BLAKE160 <push20> <hash> OP_EQUALVERIFY OP_CHECKSIG`.
/// Mirrors `shd/Sources/Script/Script.swift::p2pkh`.
fn build_p2wpkh_script_code(pubkey_hash: &[u8; 20]) -> [u8; 25] {
let mut s = [0u8; 25];
s[0] = 0x76; // OP_DUP
s[1] = 0xc0; // OP_BLAKE160 (Handshake; Bitcoin uses 0xa9 OP_HASH160)
s[2] = 0x14; // push 20 bytes
s[3..23].copy_from_slice(pubkey_hash);
s[23] = 0x88; // OP_EQUALVERIFY
s[24] = 0xac; // OP_CHECKSIG
s
}
/// DER → compact `r || s` (32-byte each, left-padded). See BIP-66 for the
/// DER layout. Returns `None` if the DER is malformed or either integer
/// exceeds 32 bytes after stripping the leading zero pad.
fn der_to_compact(der: &[u8]) -> Option<[u8; 64]> {
if der.len() < 8 || der[0] != 0x30 || der[2] != 0x02 {
return None;
}
let r_len = der[3] as usize;
let r_start: usize = 4;
let r_end = r_start.checked_add(r_len)?;
if der.len() < r_end + 2 || der[r_end] != 0x02 {
return None;
}
let s_len = der[r_end + 1] as usize;
let s_start = r_end + 2;
let s_end = s_start.checked_add(s_len)?;
if der.len() < s_end {
return None;
}
let r = &der[r_start..r_end];
let s = &der[s_start..s_end];
let r_trim = if r.len() > 32 && r[0] == 0 { &r[1..] } else { r };
let s_trim = if s.len() > 32 && s[0] == 0 { &s[1..] } else { s };
if r_trim.len() > 32 || s_trim.len() > 32 {
return None;
}
let mut out = [0u8; 64];
out[32 - r_trim.len()..32].copy_from_slice(r_trim);
out[64 - s_trim.len()..].copy_from_slice(s_trim);
Some(out)
}
// ─── Tiny byte reader ──────────────────────────────────────────────────────
struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn is_empty(&self) -> bool {
self.pos >= self.buf.len()
}
fn need(&mut self, n: usize) -> Result<&'a [u8], AppSW> {
let end = self.pos.checked_add(n).ok_or(AppSW::TxWrongLength)?;
if end > self.buf.len() {
return Err(AppSW::TxWrongLength);
}
let s = &self.buf[self.pos..end];
self.pos = end;
Ok(s)
}
fn read_u8(&mut self) -> Result<u8, AppSW> {
Ok(self.need(1)?[0])
}
fn read_u32_le(&mut self) -> Result<u32, AppSW> {
let b = self.need(4)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn read_u32_be(&mut self) -> Result<u32, AppSW> {
let b = self.need(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn read_u64_le(&mut self) -> Result<u64, AppSW> {
let b = self.need(8)?;
Ok(u64::from_le_bytes([
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
]))
}
fn read_array<const N: usize>(&mut self) -> Result<[u8; N], AppSW> {
let s = self.need(N)?;
let mut out = [0u8; N];
out.copy_from_slice(s);
Ok(out)
}
fn read_bytes(&mut self, n: usize) -> Result<&'a [u8], AppSW> {
self.need(n)
}
/// Compact-size varint (Bitcoin style): same encoding Handshake uses.
fn read_varint(&mut self) -> Result<u64, AppSW> {
let first = self.read_u8()?;
Ok(match first {
0xff => self.read_u64_le()?,
0xfe => self.read_u32_le()? as u64,
0xfd => {
let b = self.need(2)?;
u16::from_le_bytes([b[0], b[1]]) as u64
}
n => n as u64,
})
}
}

29
src/handshake/address.rs Normal file
View File

@@ -0,0 +1,29 @@
//! P2WPKH address derivation: secp256k1 pubkey → BLAKE2b-160 → bech32.
use alloc::string::String;
use super::{bech32_encode, blake2b, Network};
/// Compress a 65-byte uncompressed secp256k1 pubkey to 33 bytes.
///
/// Input layout: `0x04 || X (32) || Y (32)`
/// Output layout: `0x02 (Y even) | 0x03 (Y odd) || X (32)`
pub fn compress_pubkey(uncompressed: &[u8; 65]) -> [u8; 33] {
debug_assert_eq!(uncompressed[0], 0x04, "expected uncompressed pubkey marker");
let mut out = [0u8; 33];
out[0] = if uncompressed[64] & 1 == 0 { 0x02 } else { 0x03 };
out[1..].copy_from_slice(&uncompressed[1..33]);
out
}
/// P2WPKH witness program = BLAKE2b-160 of the compressed pubkey.
pub fn witness_program(compressed_pubkey: &[u8; 33]) -> [u8; 20] {
blake2b::blake2b_160(compressed_pubkey)
}
/// Full Handshake P2WPKH bech32 address from an uncompressed secp256k1 pubkey.
pub fn p2wpkh_address(network: Network, uncompressed_pubkey: &[u8; 65]) -> String {
let compressed = compress_pubkey(uncompressed_pubkey);
let program = witness_program(&compressed);
bech32_encode::encode_p2wpkh(network, &program)
}

View File

@@ -0,0 +1,25 @@
//! Bech32 (BIP-173) address encoding for Handshake witness-v0 outputs.
//!
//! Handshake uses the same witness-v0 bech32 scheme as Bitcoin SegWit,
//! just with HRPs `hs` / `ts` / `rs` / `ss` instead of `bc` / `tb`.
use alloc::string::String;
use bech32::Hrp;
use super::Network;
/// Encode a 20-byte P2WPKH witness program as a bech32 address.
///
/// Both inputs are statically valid (HRP is a constant; program is exactly 20 bytes),
/// so the underlying encoder cannot fail.
pub fn encode_p2wpkh(network: Network, program: &[u8; 20]) -> String {
let hrp = Hrp::parse(network.hrp()).expect("static HRP");
bech32::segwit::encode_v0(hrp, program).expect("witness program is correct length")
}
/// Encode a 32-byte P2WSH witness program as a bech32 address.
pub fn encode_p2wsh(network: Network, program: &[u8; 32]) -> String {
let hrp = Hrp::parse(network.hrp()).expect("static HRP");
bech32::segwit::encode_v0(hrp, program).expect("witness program is correct length")
}

49
src/handshake/blake2b.rs Normal file
View File

@@ -0,0 +1,49 @@
//! BLAKE2b wrappers for Handshake's two output sizes.
//!
//! - 256-bit: transaction hashes, sighash preimage hash. Uses the SDK's `Blake2b_256`.
//! - 160-bit: P2WPKH witness program (pubkey hash). Implemented locally because
//! the SDK only ships Blake2b_{256,384,512}; we mirror the SDK's `impl_hash!`
//! pattern with the cxlib syscall configured for a 160-bit digest.
use ledger_device_sdk::hash::{blake2::Blake2b_256, HashInit};
use ledger_secure_sdk_sys::{cx_blake2b_init_no_throw, cx_blake2b_t, cx_hash_t};
pub fn blake2b_256(data: &[u8]) -> [u8; 32] {
let mut h = Blake2b_256::new();
let mut out = [0u8; 32];
let _ = h.hash(data, &mut out);
out
}
#[derive(Default)]
#[allow(non_camel_case_types)]
pub struct Blake2b_160 {
ctx: cx_blake2b_t,
}
impl HashInit for Blake2b_160 {
fn as_ctx_mut(&mut self) -> &mut cx_hash_t {
&mut self.ctx.header
}
fn as_ctx(&self) -> &cx_hash_t {
&self.ctx.header
}
fn new() -> Self {
let mut h: Self = Default::default();
let _ = unsafe { cx_blake2b_init_no_throw(&mut h.ctx, 160) };
h
}
fn reset(&mut self) {
let _ = unsafe { cx_blake2b_init_no_throw(&mut self.ctx, 160) };
}
}
pub fn blake2b_160(data: &[u8]) -> [u8; 20] {
let mut h = Blake2b_160::new();
let mut out = [0u8; 20];
let _ = h.hash(data, &mut out);
out
}

125
src/handshake/covenant.rs Normal file
View File

@@ -0,0 +1,125 @@
//! Handshake covenants: what a name operation is, and which name it is about.
//!
//! The device receives covenant items as opaque bytes and re-serializes them
//! verbatim into the sighash, so signing never needs to understand them. Display
//! does: a user approving `[covenant 0x02]` against a 42-character address is not
//! meaningfully reviewing anything, which defeats the point of a hardware wallet.
use alloc::string::String;
use alloc::vec::Vec;
use ledger_device_sdk::hash::{sha3::Sha3_256, HashInit};
/// Covenant type bytes, matching `shd/Sources/Protocol/CovenantType.swift:6-29`.
///
/// Note CLAIM = 1: Handshake reserves it for the IANA/Alexa pre-reserve list, and
/// every later type is one higher than the equivalent on chains that lack it.
pub fn label(kind: u8) -> &'static str {
match kind {
0 => "none",
1 => "CLAIM",
2 => "OPEN",
3 => "BID",
4 => "REVEAL",
5 => "REDEEM",
6 => "REGISTER",
7 => "UPDATE",
8 => "RENEW",
9 => "TRANSFER",
10 => "FINALIZE",
11 => "REVOKE",
_ => "UNKNOWN",
}
}
/// The plaintext name a covenant is about, when it carries one.
///
/// OPEN, BID and FINALIZE put the name at `items[2]` alongside its hash at
/// `items[0]` (`shd/Sources/Covenants/CovenantData.swift:89-105, 170-177`). Every
/// other type carries only the 32-byte hash, and a name for those would have to
/// come from the host, which is exactly the claim a hardware wallet must not take
/// on trust, so they show none.
///
/// The name is checked against the hash the covenant actually commits to before it
/// is shown. Without that, a compromised host could display a name you own while
/// opening an auction on one you do not: the hash is what consensus reads, and the
/// string is only a label until it is verified against it.
pub fn verified_name(kind: u8, items: &[Vec<u8>]) -> Option<String> {
if !matches!(kind, 2 | 3 | 10) {
return None;
}
let name_hash = items.first()?;
let raw = items.get(2)?;
if name_hash.len() != 32 || raw.is_empty() || raw.len() > 63 {
return None;
}
// Handshake name charset (`shd` NameRules): lowercase alphanumeric, hyphen and
// underscore. Rejecting anything else also removes the interior NUL that would
// panic inside the SDK's `CString::new(..).unwrap()` on the way to the screen.
if !raw.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-' || *c == b'_') {
return None;
}
// SHA3-256, not Keccak: `NameRules.hashName` is SHA3, and the two differ in
// padding, so the wrong one fails every comparison silently.
let mut h = Sha3_256::new();
let mut digest = [0u8; 32];
if h.hash(raw, &mut digest).is_err() {
return None;
}
if digest[..] != name_hash[..] {
return None;
}
core::str::from_utf8(raw).ok().map(String::from)
}
/// Highest covenant type Handshake defines (REVOKE). Anything above this is
/// not a covenant the device can label, so `parse_output` refuses to sign it
/// rather than showing the user the word "UNKNOWN" over bytes it will commit to.
pub const MAX_KIND: u8 = 11;
/// The 32-byte name hash a covenant commits to.
///
/// Every name covenant shd builds puts it at `items[0]`
/// (`shd/Sources/Covenants/CovenantData.swift:88-191`, all eleven builders).
/// Shown when the plaintext name is unavailable or fails verification, so the
/// absence of a verified name is never the only thing on screen: the user still
/// gets a stable identifier to compare against what their wallet claims.
pub fn name_hash(kind: u8, items: &[Vec<u8>]) -> Option<&[u8]> {
if kind == 0 || kind > MAX_KIND {
return None;
}
let h = items.first()?;
if h.len() == 32 {
Some(h)
} else {
None
}
}
/// TRANSFER's destination: the address the name is being handed to.
///
/// `items = [nameHash, height, [addressVersion], addressHash]`
/// (`shd/Sources/Covenants/CovenantData.swift:158-165`, `makeTransfer`), so
/// `items[2]` is a one-byte witness version and `items[3]` the program.
///
/// This is the covenant's whole point and it appears nowhere else on screen:
/// the output pays back to the name's current address for its existing lockup,
/// so without this row a transfer to a thief and a transfer to yourself render
/// identically. Length bounds match shd's `addressHash` guard.
pub fn transfer_destination(kind: u8, items: &[Vec<u8>]) -> Option<(u8, &[u8])> {
if kind != 9 {
return None;
}
let version_item = items.get(2)?;
if version_item.len() != 1 {
return None;
}
let hash = items.get(3)?;
if hash.len() < 2 || hash.len() > 40 {
return None;
}
Some((version_item[0], hash))
}

61
src/handshake/mod.rs Normal file
View File

@@ -0,0 +1,61 @@
//! Handshake protocol primitives: addresses, transaction format, sighash.
//!
//! Mirrors `shd/Sources/Protocol/` and `shd/Sources/Script/` byte-for-byte.
pub mod address;
pub mod bech32_encode;
pub mod covenant;
pub mod blake2b;
pub mod sighash;
pub mod tx;
/// SLIP-44 coin type used in BIP-32 paths: `m/44'/5353'/...` (mainnet).
/// Handshake assigns one coin type per network; see [`Network::from_coin_type`].
pub const SLIP44_COIN_TYPE: u32 = 5353;
/// Handshake network: selects the bech32 HRP and is part of the BIP-32 path.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Network {
Mainnet,
Testnet,
Regtest,
Simnet,
}
impl Network {
pub const fn hrp(self) -> &'static str {
match self {
Network::Mainnet => "hs",
Network::Testnet => "ts",
Network::Regtest => "rs",
Network::Simnet => "ss",
}
}
/// Map the coin-type component of a BIP-32 path (`m/44'/<coin>'/...`) to a
/// network. Handshake assigns one SLIP-44 coin type per network:
/// 5353' mainnet, 5354' testnet, 5355' regtest, 5356' simnet.
///
/// Anything else, including a path too short to carry a coin type,
/// defaults to mainnet for safety.
pub const fn from_coin_type(coin_type: u32) -> Self {
match coin_type {
0x8000_14EA => Network::Testnet, // 5354'
0x8000_14EB => Network::Regtest, // 5355'
0x8000_14EC => Network::Simnet, // 5356'
_ => Network::Mainnet, // 5353' and non-standard
}
}
}
/// Lowercase hex, for the identifiers the review screen shows when it has no
/// human-readable form to offer (name hashes, unencodable address programs).
pub fn hex_string(bytes: &[u8]) -> alloc::string::String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = alloc::string::String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}

185
src/handshake/sighash.rs Normal file
View File

@@ -0,0 +1,185 @@
//! BIP-143-style sighash preimage builder, hashed with BLAKE2b-256.
//!
//! Mirrors `shd/Sources/Script/SigHash.swift::compute`.
use alloc::vec::Vec;
use super::blake2b;
use super::tx::{Address, Covenant, Output, Tx};
/// Sighash type byte values used by Handshake.
pub mod sighash_type {
pub const ALL: u8 = 0x01;
pub const NONE: u8 = 0x02;
pub const SINGLE: u8 = 0x03;
/// Handshake-specific: mirrors the input index to an output index.
pub const SINGLEREVERSE: u8 = 0x04;
pub const NOINPUT: u8 = 0x40;
pub const ANYONECANPAY: u8 = 0x80;
}
/// Inputs to a single signature: which input we're signing, the spent
/// output's `script_code` (varint-prefixed witness program for P2WPKH),
/// the value being spent, and which sighash type the wallet asked for.
pub struct SighashContext<'a> {
pub tx: &'a Tx<'a>,
pub input_index: usize,
pub script_code: &'a [u8],
pub value: u64,
pub sighash_type: u8,
}
impl SighashContext<'_> {
pub fn compute(&self) -> [u8; 32] {
let inp = &self.tx.inputs[self.input_index];
let mut buf: Vec<u8> = Vec::with_capacity(256);
buf.extend_from_slice(&self.tx.version.to_le_bytes());
buf.extend_from_slice(&self.hash_prevouts());
buf.extend_from_slice(&self.hash_sequence());
// Outpoint of the input being signed. Under NOINPUT, hsd substitutes a
// default-constructed Input: a zero hash and index 0xFFFFFFFF, NOT index 0.
// (hsd `tx.js:313` `input = new Input()`, `outpoint.js:45`.)
if self.sighash_type & sighash_type::NOINPUT != 0 {
buf.extend_from_slice(&[0u8; 32]);
buf.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
} else {
buf.extend_from_slice(&inp.outpoint.hash);
buf.extend_from_slice(&inp.outpoint.index.to_le_bytes());
}
write_varint(&mut buf, self.script_code.len() as u64);
buf.extend_from_slice(self.script_code);
buf.extend_from_slice(&self.value.to_le_bytes());
// Sequence likewise comes from the substituted default Input under NOINPUT
// (hsd `input.js:47`), so it is 0xFFFFFFFF rather than this input's own value.
let sequence = if self.sighash_type & sighash_type::NOINPUT != 0 {
0xFFFF_FFFFu32
} else {
inp.sequence
};
buf.extend_from_slice(&sequence.to_le_bytes());
buf.extend_from_slice(&self.hash_outputs());
buf.extend_from_slice(&self.tx.locktime.to_le_bytes());
buf.extend_from_slice(&(self.sighash_type as u32).to_le_bytes());
blake2b::blake2b_256(&buf)
}
fn hash_prevouts(&self) -> [u8; 32] {
// ANYONECANPAY only. hsd deliberately does NOT nullify hashPrevouts for
// NOINPUT; see the comment at `tx.js:302-310`, which notes their NOINPUT
// "is not useful by itself" precisely because the outpoint stays committed
// to here. Zeroing this as well produces a digest mainnet rejects.
if self.sighash_type & sighash_type::ANYONECANPAY != 0 {
return [0u8; 32];
}
let mut buf = Vec::with_capacity(self.tx.inputs.len() * 36);
for inp in self.tx.inputs {
buf.extend_from_slice(&inp.outpoint.hash);
buf.extend_from_slice(&inp.outpoint.index.to_le_bytes());
}
blake2b::blake2b_256(&buf)
}
fn hash_sequence(&self) -> [u8; 32] {
let base = base_type(self.sighash_type);
if self.sighash_type & sighash_type::ANYONECANPAY != 0
|| base == sighash_type::NONE
|| base == sighash_type::SINGLE
|| base == sighash_type::SINGLEREVERSE
{
return [0u8; 32];
}
let mut buf = Vec::with_capacity(self.tx.inputs.len() * 4);
for inp in self.tx.inputs {
buf.extend_from_slice(&inp.sequence.to_le_bytes());
}
blake2b::blake2b_256(&buf)
}
fn hash_outputs(&self) -> [u8; 32] {
let base = base_type(self.sighash_type);
match base {
sighash_type::ALL => {
let mut buf = Vec::new();
for out in self.tx.outputs {
serialize_output(&mut buf, out);
}
blake2b::blake2b_256(&buf)
}
sighash_type::SINGLE | sighash_type::SINGLEREVERSE => {
let idx = if base == sighash_type::SINGLEREVERSE {
self.tx
.outputs
.len()
.checked_sub(1)
.and_then(|last| last.checked_sub(self.input_index))
} else {
Some(self.input_index)
};
match idx.and_then(|i| self.tx.outputs.get(i)) {
Some(out) => {
let mut buf = Vec::new();
serialize_output(&mut buf, out);
blake2b::blake2b_256(&buf)
}
None => [0u8; 32],
}
}
// NONE or anything unexpected: zeros.
_ => [0u8; 32],
}
}
}
fn base_type(t: u8) -> u8 {
t & 0x1f
}
fn write_varint(buf: &mut Vec<u8>, n: u64) {
if n < 0xfd {
buf.push(n as u8);
} else if n <= 0xffff {
buf.push(0xfd);
buf.extend_from_slice(&(n as u16).to_le_bytes());
} else if n <= 0xffff_ffff {
buf.push(0xfe);
buf.extend_from_slice(&(n as u32).to_le_bytes());
} else {
buf.push(0xff);
buf.extend_from_slice(&n.to_le_bytes());
}
}
fn serialize_output(buf: &mut Vec<u8>, out: &Output) {
buf.extend_from_slice(&out.value.to_le_bytes());
serialize_address(buf, &out.address);
serialize_covenant(buf, &out.covenant);
}
fn serialize_address(buf: &mut Vec<u8>, addr: &Address) {
// shd `Address.write`: version(1) || hashLen(1) || hash.
buf.push(0); // witness version 0
match addr {
Address::P2WPKH(h) => {
buf.push(20);
buf.extend_from_slice(h);
}
Address::P2WSH(h) => {
buf.push(32);
buf.extend_from_slice(h);
}
}
}
fn serialize_covenant(buf: &mut Vec<u8>, cov: &Covenant) {
buf.push(cov.kind);
write_varint(buf, cov.items.len() as u64);
for item in &cov.items {
write_varint(buf, item.len() as u64);
buf.extend_from_slice(item);
}
}

66
src/handshake/tx.rs Normal file
View File

@@ -0,0 +1,66 @@
//! Handshake transaction types.
//!
//! Mirrors `shd/Sources/Protocol/Transaction.swift` and
//! `shd/Sources/Protocol/Input.swift` byte layouts.
//!
//! These are views, not parsers: `SIGN_TX` receives the transaction as
//! structured records (one APDU per input and per output) rather than as a
//! serialized blob, so the device never parses a whole serialized transaction.
//! See `handlers::sign_tx`.
use alloc::vec::Vec;
/// Hash + index: the 36-byte outpoint reference inside an input.
#[derive(Clone, Copy, Debug)]
pub struct Outpoint {
pub hash: [u8; 32],
pub index: u32,
}
/// 40 bytes on the wire: outpoint(36) + sequence(4 LE).
#[derive(Clone, Copy, Debug)]
pub struct Input {
pub outpoint: Outpoint,
pub sequence: u32,
}
/// An output's covenant: empty for plain transfers, non-empty for name auctions.
/// We store the raw bytes so the sighash digest sees the exact serialization.
#[derive(Clone, Debug)]
pub struct Covenant {
pub kind: u8,
pub items: Vec<Vec<u8>>,
}
impl Covenant {
pub fn is_none(&self) -> bool {
self.kind == 0 && self.items.is_empty()
}
}
#[derive(Clone, Debug)]
pub struct Output {
pub value: u64,
/// Address in its on-chain form: `[version_byte, hash...]` (20 or 32 bytes payload).
pub address: Address,
pub covenant: Covenant,
}
#[derive(Clone, Debug)]
pub enum Address {
P2WPKH([u8; 20]),
P2WSH([u8; 32]),
}
/// A transaction as the sighash builder and the review screen see it.
///
/// Borrows the session's input and output vectors rather than owning copies:
/// the device signs each input on demand, and cloning the whole transaction per
/// signature would double peak heap use at the worst possible moment.
#[derive(Clone, Copy, Debug)]
pub struct Tx<'a> {
pub version: u32,
pub inputs: &'a [Input],
pub outputs: &'a [Output],
pub locktime: u32,
}

272
src/main.rs Normal file
View File

@@ -0,0 +1,272 @@
/*****************************************************************************
* Ledger App Boilerplate Rust.
* (c) 2023 Ledger SAS.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*****************************************************************************/
#![no_std]
#![no_main]
mod handshake;
mod utils;
mod app_ui {
pub mod address;
pub mod menu;
pub mod message;
pub mod sign;
}
mod handlers {
pub mod get_public_key;
pub mod get_version;
pub mod sign_message;
pub mod sign_tx;
}
mod swap;
use app_ui::menu::ui_menu_main;
use handlers::{
get_public_key::handler_get_public_key,
get_version::handler_get_version,
sign_message::handler_sign_message,
sign_tx::{
handler_sign_add_input, handler_sign_add_output, handler_sign_begin,
handler_sign_input, handler_sign_review, TxContext,
},
};
use ledger_device_sdk::io::{self, init_comm, ApduHeader, Comm, Command, Reply, StatusWords};
use ledger_device_sdk::libcall::swap::CreateTxParams;
ledger_device_sdk::set_panic!(ledger_device_sdk::exiting_panic);
// Required for using String, Vec, format!...
extern crate alloc;
use ledger_device_sdk::nbgl::{NbglReviewStatus, StatusType};
ledger_device_sdk::define_comm!(COMM);
// SIGN_TX sub-op P1 selectors. Reusing INS=0x06 as a command group and
// dispatching via P1 keeps the wire format simple and backwards-familiar.
const P1_SIGN_BEGIN: u8 = 0x00; // payload: tx_version||n_in||n_out||locktime
const P1_SIGN_ADD_INPUT: u8 = 0x01; // payload: prevout||seq||value||sighash||path||kind
const P1_SIGN_ADD_OUTPUT: u8 = 0x02; // payload: output bytes (chunked, P2 = MORE/LAST)
const P1_SIGN_REVIEW: u8 = 0x03; // payload: empty
const P1_SIGN_INPUT: u8 = 0x04; // payload: input_index(4 BE)
// P2 for ADD_OUTPUT chunking.
const P2_LAST: u8 = 0x00;
const P2_MORE: u8 = 0x80;
// Application status words.
#[repr(u16)]
#[derive(Clone, Copy, PartialEq)]
pub enum AppSW {
Deny = 0x6985,
WrongP1P2 = 0x6A86,
WrongState = 0x6B00,
InsNotSupported = 0x6D00,
ClaNotSupported = 0x6E00,
CommError = 0x6F00,
TxDisplayFail = 0xB001,
AddrDisplayFail = 0xB002,
TxWrongLength = 0xB004,
TxParsingFail = 0xB005,
TxHashFail = 0xB006,
TxSignFail = 0xB008,
KeyDeriveFail = 0xB009,
VersionParsingFail = 0xB00A,
/// Derivation path outside Handshake's BIP-44 policy. Returned instead of
/// letting BOLOS terminate the app on an out-of-whitelist path.
BadDerivationPath = 0xB00B,
WrongApduLength = StatusWords::BadLen as u16,
SwapFail = 0xC000,
Ok = 0x9000,
}
impl From<AppSW> for Reply {
fn from(sw: AppSW) -> Reply {
Reply(sw as u16)
}
}
impl From<io::CommError> for AppSW {
fn from(_e: io::CommError) -> Self {
AppSW::CommError
}
}
/// Possible input commands received through APDUs.
#[derive(Debug)]
pub enum Instruction {
GetVersion,
GetAppName,
GetPubkey { display: bool },
SignBegin,
SignAddInput,
SignAddOutput { more: bool },
SignReview,
SignInput,
SignMessage,
}
impl TryFrom<ApduHeader> for Instruction {
type Error = AppSW;
/// Parse INS/P1/P2 into an [`Instruction`]. CLA is filtered separately
/// by `Comm::set_expected_cla(0xe0)` in `normal_main`.
fn try_from(value: ApduHeader) -> Result<Self, Self::Error> {
match (value.ins, value.p1, value.p2) {
(3, 0, 0) => Ok(Instruction::GetVersion),
(4, 0, 0) => Ok(Instruction::GetAppName),
(5, 0 | 1, 0) => Ok(Instruction::GetPubkey {
display: value.p1 != 0,
}),
(6, P1_SIGN_BEGIN, 0) => Ok(Instruction::SignBegin),
(6, P1_SIGN_ADD_INPUT, 0) => Ok(Instruction::SignAddInput),
(6, P1_SIGN_ADD_OUTPUT, P2_LAST) => Ok(Instruction::SignAddOutput { more: false }),
(6, P1_SIGN_ADD_OUTPUT, P2_MORE) => Ok(Instruction::SignAddOutput { more: true }),
(6, P1_SIGN_REVIEW, 0) => Ok(Instruction::SignReview),
(6, P1_SIGN_INPUT, 0) => Ok(Instruction::SignInput),
(7, 0, 0) => Ok(Instruction::SignMessage),
(3..=7, _, _) => Err(AppSW::WrongP1P2),
(_, _, _) => Err(AppSW::InsNotSupported),
}
}
}
fn show_status_and_home_if_needed(
comm: &mut Comm,
ins: &Instruction,
tx_ctx: &mut TxContext,
status: &AppSW,
) {
if tx_ctx.swap_params.is_some() {
return;
}
let success = *status == AppSW::Ok;
match (ins, status) {
(Instruction::GetPubkey { display: true }, AppSW::Deny | AppSW::Ok) => {
// Address-verify success screen (3s auto-dismiss) then home.
NbglReviewStatus::new()
.status_type(StatusType::Address)
.show(comm, success);
tx_ctx.home.show_and_return();
}
(Instruction::SignReview, AppSW::Deny | AppSW::Ok) if tx_ctx.finished() => {
// Tx review done (user approved or rejected). Go straight to
// home without the NbglReviewStatus success screen: on BLE its
// sync wait can fail to auto-dismiss and block the next APDU.
// The wallet shows its own success UI once broadcast completes.
tx_ctx.home.show_and_return();
}
(Instruction::SignMessage, AppSW::Deny | AppSW::Ok) => {
// Same reason as the SignReview branch above.
tx_ctx.home.show_and_return();
}
_ => {}
}
}
// --8<-- [start:sample_main]
#[no_mangle]
extern "C" fn sample_main(arg0: u32) {
if arg0 != 0 {
// We have been started by the Exchange application through the os_lib_call API
// We need to answer the command instead of starting the normal app main loop
swap::swap_main(arg0);
} else {
// Normal app mode, start the main loop listening for APDU commands
normal_main(None);
}
}
// --8<-- [end:sample_main]
/// Main application entry point.
///
/// Handles both standard execution (user opens app) and library mode execution
/// (Exchange app calls this app for swap).
///
/// # Arguments
///
/// * `swap_params` - Optional swap parameters. If present, the app runs in "swap mode":
/// - UI is bypassed (no main menu, no transaction review)
/// - Transaction is validated against swap params
/// - Returns `true` if signed successfully, `false` otherwise
pub fn normal_main(swap_params: Option<&CreateTxParams>) -> bool {
// Create the communication manager, and configure it to accept only APDU from the 0xe0 class.
// If any APDU with a wrong class value is received, comm will respond automatically with
// BadCla status word.
let comm = init_comm(&COMM);
comm.set_expected_cla(0xe0);
let mut tx_ctx = if let Some(params) = swap_params {
TxContext::new_with_swap(params)
} else {
TxContext::new()
};
if swap_params.is_none() {
tx_ctx.home = ui_menu_main(comm);
tx_ctx.home.show_and_return();
}
loop {
let command = comm.next_command();
let decoded = command.decode::<Instruction>();
let Ok(ins) = decoded else {
let _ = comm.send(&[], decoded.unwrap_err());
continue;
};
let _status = match handle_apdu(command, &ins, &mut tx_ctx) {
Ok(reply) => {
let _ = reply.send(AppSW::Ok);
AppSW::Ok
}
Err(sw) => {
let _ = comm.send(&[], sw);
sw
}
};
show_status_and_home_if_needed(comm, &ins, &mut tx_ctx, &_status);
// In swap mode, exit after transaction is finished (signed or rejected)
if tx_ctx.swap_params.is_some() && tx_ctx.finished() {
return _status == AppSW::Ok;
}
}
}
fn handle_apdu<'a>(
command: Command<'a>,
ins: &Instruction,
ctx: &mut TxContext,
) -> Result<io::CommandResponse<'a>, AppSW> {
match ins {
Instruction::GetAppName => {
let mut response = command.into_response();
response.append(b"Handshake")?;
Ok(response)
}
Instruction::GetVersion => handler_get_version(command),
Instruction::GetPubkey { display } => handler_get_public_key(command, *display),
Instruction::SignBegin => handler_sign_begin(command, ctx),
Instruction::SignAddInput => handler_sign_add_input(command, ctx),
Instruction::SignAddOutput { more } => handler_sign_add_output(command, *more, ctx),
Instruction::SignReview => handler_sign_review(command, ctx),
Instruction::SignInput => handler_sign_input(command, ctx),
Instruction::SignMessage => handler_sign_message(command, ctx),
}
}

18
src/swap.rs Normal file
View File

@@ -0,0 +1,18 @@
//! Ledger Exchange (atomic swap) integration: v0.1 stub.
//!
//! When the Exchange app calls our app via `os_lib_call`, we land in `swap_main`.
//! For v0.1 we don't support being driven by Exchange yet: just return without
//! doing anything so the host falls back to normal flow / surfaces the failure.
//!
//! Note `handler_sign_review` refuses outright when `swap_params` is set,
//! rather than auto-approving: whoever wires Exchange up has to add the
//! validation against `CreateTxParams` deliberately, and cannot inherit an
//! unreviewed signature by accident.
//!
//! TODO: implement `SwapCheckAddress` (re-derive our bech32 from the BIP-32 path
//! and compare), `SwapGetPrintableAmount` (format dollarydoos as `X.YYYYYY HNS`), and
//! `SwapSignTransaction` (forward to `normal_main` with `swap_params=Some(...)`).
pub fn swap_main(_arg0: u32) {
// no-op for v0.1
}

158
src/utils.rs Normal file
View File

@@ -0,0 +1,158 @@
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use crate::handshake::SLIP44_COIN_TYPE;
use crate::AppSW;
/// Hardened-derivation bit.
const HARDENED: u32 = 0x8000_0000;
/// Upper bound on BIP-32 path components accepted from the host.
pub const MAX_PATH_COMPONENTS: usize = 10;
use ledger_device_sdk::ecc::{Secp256k1, SeedDerive};
/// BIP32 derivation path stored as a vector of u32 components.
///
/// Each component represents one level in the path (e.g., m/44'/1'/0'/0/0 has 5 components).
/// Hardened derivation is indicated by setting the high bit (>= 0x80000000).
#[derive(Default, Clone)]
pub struct Bip32Path(Vec<u32>);
impl AsRef<[u32]> for Bip32Path {
fn as_ref(&self) -> &[u32] {
&self.0
}
}
impl Bip32Path {
/// Build a path from already-parsed u32 components (hardened bits
/// already set by the caller where appropriate). Used by the new
/// `SIGN_TX` state machine's `ADD_INPUT` handler, which parses the
/// path words off the wire itself and doesn't go through `TryFrom`.
pub fn from_raw(components: &[u32]) -> Result<Self, AppSW> {
if components.is_empty() || components.len() > MAX_PATH_COMPONENTS {
return Err(AppSW::WrongApduLength);
}
Ok(Bip32Path(components.to_vec()))
}
}
impl TryFrom<&[u8]> for Bip32Path {
type Error = AppSW;
/// Constructs a [`Bip32Path`] from APDU-encoded bytes.
///
/// # Format
///
/// - First byte: Number of path components (e.g., 5 for m/44'/1'/0'/0/0)
/// - Remaining bytes: Big-endian u32 components (4 bytes each)
///
/// # Example
///
/// For path m/44'/1'/0'/0/0:
/// ```text
/// [0x05, 0x8000002C, 0x80000001, 0x80000000, 0x00000000, 0x00000000]
/// ```
///
fn try_from(data: &[u8]) -> Result<Self, Self::Error> {
// Check data length
if data.is_empty() // At least the length byte is required
|| (data[0] as usize * 4 != data.len() - 1)
{
return Err(AppSW::WrongApduLength);
}
// Same ceiling as `from_raw`: without it the APDU buffer alone would
// admit 66 components.
if data[0] == 0 || data[0] as usize > MAX_PATH_COMPONENTS {
return Err(AppSW::WrongApduLength);
}
Ok(Bip32Path(
data[1..]
.chunks(4)
.map(|chunk| u32::from_be_bytes(chunk.try_into().unwrap()))
.collect(),
))
}
}
/// Derive the raw public key from a BIP32 path.
///
/// Returns the uncompressed secp256k1 public key (65 bytes):
/// - First byte: 0x04 (uncompressed marker)
/// - Next 32 bytes: X coordinate
/// - Last 32 bytes: Y coordinate
///
/// # Used by
///
/// - `handler_get_public_key`: Returns this raw pubkey to the client
/// - Internally for address computation
///
/// # Arguments
///
/// * `path` - BIP32 derivation path
///
/// # Returns
///
/// 65-byte uncompressed public key or error
pub fn get_pubkey_from_path(path: &Bip32Path) -> Result<[u8; 65], AppSW> {
let (k, _) = Secp256k1::derive_from(path.as_ref());
let pk = k.public_key().map_err(|_| AppSW::KeyDeriveFail)?;
Ok(pk.pubkey)
}
/// Enforce Handshake's BIP-44 path policy: `m/44'/<coin>'/account'` with an
/// optional `/change/index` beneath it, where `<coin>` is one of the four
/// networks (5353' mainnet through 5356' simnet).
///
/// The install manifest whitelists the same four prefixes, but BOLOS does not
/// return an error for a derivation outside them: it terminates the app. A host
/// could otherwise kill the app with a single request and strand a partially
/// streamed signing session with no status word back to the wallet. Checking
/// here turns that into an ordinary reply, and keeps `SLIP44_COIN_TYPE` the one
/// source of truth so the manifest and the code cannot drift apart.
pub fn validate_path(path: &Bip32Path) -> Result<(), AppSW> {
let words = path.as_ref();
if !(3..=5).contains(&words.len()) {
return Err(AppSW::BadDerivationPath);
}
if words[0] != (HARDENED | 44) {
return Err(AppSW::BadDerivationPath);
}
let coin = words[1];
if !((HARDENED | SLIP44_COIN_TYPE)..=(HARDENED | (SLIP44_COIN_TYPE + 3))).contains(&coin) {
return Err(AppSW::BadDerivationPath);
}
if words[2] & HARDENED == 0 {
return Err(AppSW::BadDerivationPath);
}
// change and index are non-hardened under BIP-44.
if words[3..].iter().any(|c| c & HARDENED != 0) {
return Err(AppSW::BadDerivationPath);
}
Ok(())
}
/// The coin-type component, which selects the network. Callers have already
/// passed `validate_path`, so it is always present.
pub fn coin_type(path: &Bip32Path) -> u32 {
path.as_ref().get(1).copied().unwrap_or(0)
}
/// Render a path the way a user reads it: `m/44'/5353'/0'/0/0`.
///
/// Shown on every approval screen. Without it a user confirming "their" address
/// cannot tell account 0 from account 99, nor a legitimate index from one the
/// host substituted.
pub fn format_path(path: &Bip32Path) -> String {
let mut out = String::from("m");
for component in path.as_ref() {
out.push('/');
out.push_str(&format!("{}", component & !HARDENED));
if component & HARDENED != 0 {
out.push('\'');
}
}
out
}

13
tests/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# Test runner: Ledger's dev-tools image (speculos) plus ragger.
#
# The upstream image ships speculos but not ragger, and its system Python is
# PEP-668 managed, so ragger goes into the same venv speculos lives in.
#
# Built and run by ../scripts/test.
FROM ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools:latest
COPY standalone/requirements.txt /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
ENV PATH="/opt/venv/bin:${PATH}"

45
tests/README.md Normal file
View File

@@ -0,0 +1,45 @@
# 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
```sh
../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):
```sh
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()`.

View File

@@ -0,0 +1,16 @@
# Application client
A small Python client for the Handshake Ledger app: enough to send APDUs,
build the records `SIGN_TX` streams, and decode what comes back.
- `handshake_command_sender.py`: APDU encoding and the `SIGN_TX` state machine
(BEGIN / ADD_INPUT / ADD_OUTPUT / REVIEW / SIGN_INPUT), plus the status words
from `src/main.rs::AppSW`.
- `handshake_transaction.py`: payload builders and the covenant item layouts
from `shd/Sources/Covenants/CovenantData.swift`.
- `handshake_response_unpacker.py`: response decoding. Note the app returns
**compressed** 33-byte public keys.
- `handshake_sighash.py`: an independent BIP-143-style sighash over BLAKE2b-256,
written separately from the device's implementation so that a test failure
means the two disagree rather than that one implementation is self-consistent.
- `py.typed`: marker for type checkers.

View File

View File

@@ -0,0 +1,150 @@
"""APDU client for the Handshake Ledger app.
Speaks the protocol the app actually implements: `SIGN_TX` is a state machine
selected by P1 (BEGIN / ADD_INPUT / ADD_OUTPUT / REVIEW / SIGN_INPUT), not the
chunked single-shot flow the Ledger boilerplate uses. See
`src/handlers/sign_tx.rs` and `scripts/sign-tx`.
"""
from contextlib import contextmanager
from enum import IntEnum
from typing import Generator, List, Optional
from ragger.backend.interface import BackendInterface, RAPDU
from ragger.bip import pack_derivation_path
CLA: int = 0xE0
#: Data bytes per APDU. ADD_OUTPUT payloads above this are chunked with P2_MORE.
MAX_APDU_DATA: int = 240
class InsType(IntEnum):
GET_VERSION = 0x03
GET_APP_NAME = 0x04
GET_PUBLIC_KEY = 0x05
SIGN_TX = 0x06
SIGN_MESSAGE = 0x07
class P1(IntEnum):
NONE = 0x00
#: GET_PUBLIC_KEY: show the address on-device before replying.
CONFIRM = 0x01
#: SIGN_TX sub-ops.
SIGN_BEGIN = 0x00
SIGN_ADD_INPUT = 0x01
SIGN_ADD_OUTPUT = 0x02
SIGN_REVIEW = 0x03
SIGN_INPUT = 0x04
class P2(IntEnum):
LAST = 0x00
MORE = 0x80
class Errors(IntEnum):
"""Mirrors `AppSW` in src/main.rs."""
SW_DENY = 0x6985
SW_WRONG_P1P2 = 0x6A86
SW_WRONG_STATE = 0x6B00
SW_INS_NOT_SUPPORTED = 0x6D00
SW_CLA_NOT_SUPPORTED = 0x6E00
SW_COMM_ERROR = 0x6F00
SW_TX_DISPLAY_FAIL = 0xB001
SW_ADDR_DISPLAY_FAIL = 0xB002
SW_TX_WRONG_LENGTH = 0xB004
SW_TX_PARSING_FAIL = 0xB005
SW_TX_HASH_FAIL = 0xB006
SW_TX_SIGN_FAIL = 0xB008
SW_KEY_DERIVE_FAIL = 0xB009
SW_VERSION_PARSING_FAIL = 0xB00A
SW_BAD_DERIVATION_PATH = 0xB00B
SW_SWAP_FAIL = 0xC000
def split_message(message: bytes, max_size: int) -> List[bytes]:
return [message[x:x + max_size] for x in range(0, len(message), max_size)]
class HandshakeCommandSender:
def __init__(self, backend: BackendInterface) -> None:
self.backend = backend
# ── Simple queries ─────────────────────────────────────────────────────
def get_version(self) -> RAPDU:
return self.backend.exchange(cla=CLA, ins=InsType.GET_VERSION,
p1=P1.NONE, p2=P2.LAST, data=b"")
def get_app_name(self) -> RAPDU:
return self.backend.exchange(cla=CLA, ins=InsType.GET_APP_NAME,
p1=P1.NONE, p2=P2.LAST, data=b"")
def get_public_key(self, path: str) -> RAPDU:
return self.backend.exchange(cla=CLA, ins=InsType.GET_PUBLIC_KEY,
p1=P1.NONE, p2=P2.LAST,
data=pack_derivation_path(path))
@contextmanager
def get_public_key_with_confirmation(self, path: str) -> Generator[None, None, None]:
with self.backend.exchange_async(cla=CLA, ins=InsType.GET_PUBLIC_KEY,
p1=P1.CONFIRM, p2=P2.LAST,
data=pack_derivation_path(path)) as response:
yield response
# ── SIGN_TX state machine ──────────────────────────────────────────────
def sign_tx_begin(self, payload: bytes) -> RAPDU:
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
p1=P1.SIGN_BEGIN, p2=P2.LAST, data=payload)
def sign_tx_add_input(self, payload: bytes) -> RAPDU:
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
p1=P1.SIGN_ADD_INPUT, p2=P2.LAST, data=payload)
def sign_tx_add_output(self, payload: bytes) -> RAPDU:
"""Send one output, chunking it across APDUs if needed."""
chunks = split_message(payload, MAX_APDU_DATA) or [b""]
rapdu = None
for i, chunk in enumerate(chunks):
p2 = P2.LAST if i == len(chunks) - 1 else P2.MORE
rapdu = self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
p1=P1.SIGN_ADD_OUTPUT, p2=p2, data=chunk)
return rapdu
def sign_tx_review_sync(self) -> RAPDU:
"""REVIEW without a navigator: only for cases the device rejects
before it puts anything on screen."""
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
p1=P1.SIGN_REVIEW, p2=P2.LAST, data=b"")
@contextmanager
def sign_tx_review(self) -> Generator[None, None, None]:
"""REVIEW: blocks on the user, so the caller drives the navigator."""
with self.backend.exchange_async(cla=CLA, ins=InsType.SIGN_TX,
p1=P1.SIGN_REVIEW, p2=P2.LAST,
data=b"") as response:
yield response
def sign_tx_input(self, index: int) -> RAPDU:
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
p1=P1.SIGN_INPUT, p2=P2.LAST,
data=index.to_bytes(4, "big"))
# ── SIGN_MESSAGE ───────────────────────────────────────────────────────
@contextmanager
def sign_message(self, path: str, message: bytes) -> Generator[None, None, None]:
payload = (pack_derivation_path(path)
+ len(message).to_bytes(2, "little")
+ message)
with self.backend.exchange_async(cla=CLA, ins=InsType.SIGN_MESSAGE,
p1=P1.NONE, p2=P2.LAST,
data=payload) as response:
yield response
def get_async_response(self) -> Optional[RAPDU]:
return self.backend.last_async_response

View File

@@ -0,0 +1,63 @@
"""Response unpackers for the Handshake Ledger app.
The app returns COMPRESSED (33-byte) secp256k1 public keys, unlike the Ledger
boilerplate, which returns the 65-byte uncompressed form.
"""
from struct import unpack
from typing import Tuple
def _pop_sized(buffer: bytes, size: int) -> Tuple[bytes, bytes]:
return buffer[size:], buffer[0:size]
def _pop_len_prefixed(buffer: bytes) -> Tuple[bytes, bytes]:
n = buffer[0]
return buffer[1 + n:], buffer[1:n + 1]
def compress_pubkey(uncompressed: bytes) -> bytes:
"""65-byte `04 || X || Y` to 33-byte `02|03 || X`."""
assert len(uncompressed) == 65 and uncompressed[0] == 0x04
prefix = 0x02 if uncompressed[64] % 2 == 0 else 0x03
return bytes([prefix]) + uncompressed[1:33]
def unpack_get_app_name_response(response: bytes) -> str:
return response.decode("ascii")
def unpack_get_version_response(response: bytes) -> Tuple[int, int, int]:
assert len(response) == 3
major, minor, patch = unpack("BBB", response)
return (major, minor, patch)
def unpack_get_public_key_response(response: bytes) -> Tuple[bytes, bytes, str]:
"""`pk_len(1) || pk(33) || cc_len(1) || cc(32) || addr_len(1) || addr`."""
response, pubkey = _pop_len_prefixed(response)
response, chaincode = _pop_len_prefixed(response)
response, address = _pop_len_prefixed(response)
assert len(pubkey) == 33, f"expected a compressed pubkey, got {len(pubkey)} bytes"
assert len(chaincode) == 32
assert len(response) == 0
return pubkey, chaincode, address.decode("ascii")
def unpack_sign_input_response(response: bytes) -> Tuple[bytes, int, bytes]:
"""`sig(64) || sighash_type(1) || pk_len(1) || pk(33)`."""
response, signature = _pop_sized(response, 64)
response, sighash = _pop_sized(response, 1)
response, pubkey = _pop_len_prefixed(response)
assert len(pubkey) == 33
assert len(response) == 0
return signature, sighash[0], pubkey
def unpack_sign_message_response(response: bytes) -> Tuple[int, bytes, bytes]:
"""`recovery_id(1) || sig(64) || pk(33)`, 98 bytes."""
assert len(response) == 98, f"expected 98 bytes, got {len(response)}"
return response[0], response[1:65], response[65:]

View File

@@ -0,0 +1,85 @@
"""An independent BIP-143-style sighash for Handshake, for cross-checking the
device's signatures.
Deliberately a second implementation rather than a port of the device's: a test
that reuses the code under test proves only that it is self-consistent. Field
order and lengths follow `shd/Sources/Script/SigHash.swift::compute`.
"""
from hashlib import blake2b
from typing import List, Sequence, Tuple
def encode_varint(n: int) -> bytes:
"""Deliberately duplicated rather than imported: this module is meant to be
an independent check, and it keeps the cross-check importable without ragger."""
if n < 0xFD:
return bytes([n])
if n <= 0xFFFF:
return b"\xfd" + n.to_bytes(2, "little")
if n <= 0xFFFFFFFF:
return b"\xfe" + n.to_bytes(4, "little")
return b"\xff" + n.to_bytes(8, "little")
def blake2b_256(data: bytes) -> bytes:
return blake2b(data, digest_size=32).digest()
def blake2b_160(data: bytes) -> bytes:
return blake2b(data, digest_size=20).digest()
def p2wpkh_script_code(compressed_pubkey: bytes) -> bytes:
"""OP_DUP OP_BLAKE160 <push20> <hash> OP_EQUALVERIFY OP_CHECKSIG.
Handshake uses OP_BLAKE160 (0xc0) where Bitcoin uses OP_HASH160 (0xa9).
"""
return (b"\x76\xc0\x14"
+ blake2b_160(compressed_pubkey)
+ b"\x88\xac")
def serialize_output(value: int,
addr_hash: bytes,
addr_version: int = 0,
covenant_kind: int = 0,
covenant_items: Sequence[bytes] = ()) -> bytes:
items = b"".join(encode_varint(len(i)) + i for i in covenant_items)
return (value.to_bytes(8, "little")
+ bytes([addr_version, len(addr_hash)])
+ addr_hash
+ bytes([covenant_kind])
+ encode_varint(len(covenant_items))
+ items)
def sighash_all(version: int,
inputs: List[Tuple[bytes, int, int]],
outputs: List[bytes],
locktime: int,
index: int,
script_code: bytes,
value: int) -> bytes:
"""SIGHASH_ALL only, which is the one type the device signs.
`inputs` are (txid, vout, sequence); `outputs` are pre-serialized.
"""
hash_prevouts = blake2b_256(b"".join(
txid + vout.to_bytes(4, "little") for txid, vout, _ in inputs))
hash_sequence = blake2b_256(b"".join(
seq.to_bytes(4, "little") for _, _, seq in inputs))
hash_outputs = blake2b_256(b"".join(outputs))
txid, vout, sequence = inputs[index]
preimage = (version.to_bytes(4, "little")
+ hash_prevouts
+ hash_sequence
+ txid + vout.to_bytes(4, "little")
+ encode_varint(len(script_code)) + script_code
+ value.to_bytes(8, "little")
+ sequence.to_bytes(4, "little")
+ hash_outputs
+ locktime.to_bytes(4, "little")
+ (0x01).to_bytes(4, "little"))
return blake2b_256(preimage)

View File

@@ -0,0 +1,118 @@
"""Builders for the structured records SIGN_TX streams to the device.
Wire layouts are documented at the top of `src/handlers/sign_tx.rs`; the
covenant item layouts mirror `shd/Sources/Covenants/CovenantData.swift`.
"""
from hashlib import sha3_256
from typing import List, Sequence, Tuple
from ragger.bip import pack_derivation_path
SCRIPT_KIND_P2WPKH: int = 0x00
SCRIPT_KIND_P2WSH: int = 0x01
SIGHASH_ALL: int = 0x01
SIGHASH_NONE: int = 0x02
SIGHASH_SINGLE: int = 0x03
SIGHASH_SINGLEREVERSE: int = 0x04
SIGHASH_NOINPUT: int = 0x40
SIGHASH_ANYONECANPAY: int = 0x80
# Covenant types, matching shd/Sources/Protocol/CovenantType.swift.
COVENANT_NONE: int = 0
COVENANT_CLAIM: int = 1
COVENANT_OPEN: int = 2
COVENANT_BID: int = 3
COVENANT_REVEAL: int = 4
COVENANT_REDEEM: int = 5
COVENANT_REGISTER: int = 6
COVENANT_UPDATE: int = 7
COVENANT_RENEW: int = 8
COVENANT_TRANSFER: int = 9
COVENANT_FINALIZE: int = 10
COVENANT_REVOKE: int = 11
def encode_varint(n: int) -> bytes:
if n < 0xFD:
return bytes([n])
if n <= 0xFFFF:
return b"\xfd" + n.to_bytes(2, "little")
if n <= 0xFFFFFFFF:
return b"\xfe" + n.to_bytes(4, "little")
return b"\xff" + n.to_bytes(8, "little")
def name_hash(name: bytes) -> bytes:
"""`shd` NameRules.hashName: SHA3-256, not Keccak."""
return sha3_256(name).digest()
def begin_payload(n_in: int, n_out: int, version: int = 0, locktime: int = 0) -> bytes:
return (version.to_bytes(4, "little")
+ encode_varint(n_in)
+ encode_varint(n_out)
+ locktime.to_bytes(4, "little"))
def input_payload(path: str,
value: int,
txid: bytes = b"\x11" * 32,
vout: int = 0,
sequence: int = 0xFFFFFFFF,
sighash: int = SIGHASH_ALL,
script_kind: int = SCRIPT_KIND_P2WPKH) -> bytes:
assert len(txid) == 32
return (txid
+ vout.to_bytes(4, "little")
+ sequence.to_bytes(4, "little")
+ value.to_bytes(8, "little")
+ bytes([sighash])
+ pack_derivation_path(path)
+ bytes([script_kind]))
def output_payload(value: int,
addr_hash: bytes = b"\x22" * 20,
addr_version: int = 0,
covenant_kind: int = COVENANT_NONE,
covenant_items: Sequence[bytes] = ()) -> bytes:
items = b"".join(encode_varint(len(i)) + i for i in covenant_items)
return (value.to_bytes(8, "little")
+ bytes([addr_version, len(addr_hash)])
+ addr_hash
+ bytes([covenant_kind])
+ encode_varint(len(covenant_items))
+ items)
def uint32_le(n: int) -> bytes:
return n.to_bytes(4, "little")
def open_items(name: bytes) -> List[bytes]:
"""OPEN: [nameHash, height, name] (CovenantData.makeOpen)."""
return [name_hash(name), uint32_le(0), name]
def bid_items(name: bytes, height: int = 0, blind: bytes = b"\x33" * 32) -> List[bytes]:
"""BID: [nameHash, startHeight, name, blind] (CovenantData.makeBid)."""
return [name_hash(name), uint32_le(height), name, blind]
def reveal_items(name: bytes, height: int = 0, nonce: bytes = b"\x44" * 32) -> List[bytes]:
"""REVEAL: [nameHash, startHeight, nonce]. Carries no plaintext name."""
return [name_hash(name), uint32_le(height), nonce]
def transfer_items(name: bytes,
dest_hash: bytes,
height: int = 0,
dest_version: int = 0) -> List[bytes]:
"""TRANSFER: [nameHash, startHeight, [version], addressHash].
`items[3]` is the address the name is handed to, which is the whole point
of the covenant and appears nowhere else in the output.
"""
return [name_hash(name), uint32_le(height), bytes([dest_version]), dest_hash]

View File

28
tests/setup.cfg Normal file
View File

@@ -0,0 +1,28 @@
[tool:pytest]
addopts = --strict-markers
[pylint]
disable = C0114, # missing-module-docstring
C0115, # missing-class-docstring
C0116, # missing-function-docstring
C0103, # invalid-name
C0411, # wrong-import-order
C0301, # line-too-long
C0303, # trailing-whitespace
C0415, # import-outside-toplevel
R0801, # duplicate-code
R0903, # too-few-public-methods
R0913, # too-many-arguments
W0511, # fixme (TODO comments)
W0611, # unused-import
W0612, # unused-variable
W0613, # unused-argument
W0621, # redefined-outer-name
W1309, # f-string-without-interpolation
W1510, # subprocess-run-check
E0401 # import-error
max-line-length=100
extension-pkg-whitelist=hid
[pycodestyle]
max-line-length = 100

View File

@@ -0,0 +1,35 @@
# Standalone Functional Tests
This directory contains the **standalone functional test suite** for the Ledger application.
It is intended to validate the applications behavior in a **generic context**, when launched directly from the device's dashboard.
These tests are written using:
- [pytest](https://docs.pytest.org/en/stable/): Python testing framework
- [Ragger](https://github.com/LedgerHQ/ragger): Ledger's open-source testing library for simulating device interactions
---
## Purpose
The standalone test suite ensures that:
- The application launches correctly from the dashboard
- The main menu and navigation behave as expected
- Core commands (e.g., `GET_VERSION`, `GET_PUBLIC_KEY`, `SIGN_TX`) function properly
- User approval flows work under normal conditions
- Errors are correctly reported and handled
---
## Directory Structure
```text
standalone/
├── conftest.py # Pytest fixtures and device setup
├── test_*.py # Functional test cases
├── snapshots/ # Ragger UI snapshots
├── snapshots-tmp/ # Temporary snapshot diffs (not tracked in git)
├── requirements.txt # Python dependencies
└── utils.py # Local test helpers
```

View File

View File

@@ -0,0 +1,31 @@
from ragger.conftest import configuration
from ragger.navigator import NavInsID
import pytest
###########################
### CONFIGURATION START ###
###########################
# You can configure optional parameters by overriding the value of ragger.configuration.OPTIONAL_CONFIGURATION
# Please refer to ragger/conftest/configuration.py for their descriptions and accepted values
#########################
### CONFIGURATION END ###
#########################
# Pull all features from the base ragger conftest using the overridden configuration
pytest_plugins = ("ragger.conftest.base_conftest", )
# Notes :
# 1. Remove this fixture once the pending review screen is removed from the app
# 2. This fixture clears the pending review screen before each test
# 3. The scope should be the same as the one configured by BACKEND_SCOPE in
# ragger/conftest/configuration.py
# @pytest.fixture(scope="class", autouse=True)
# def clear_pending_review(firmware, navigator):
# # Press a button to clear the pending review
# if firmware.device.startswith("nano"):
# print("Clearing pending review")
# instructions = [
# NavInsID.BOTH_CLICK,
# ]
# navigator.navigate(instructions,screen_change_before_first_instruction=False)

View File

@@ -0,0 +1,4 @@
pytest
ragger[speculos,ledgerwallet]>=1.21.1
ecdsa>=0.16.1,<0.17.0
tomli>=2.0.1

View File

@@ -0,0 +1,37 @@
"""Review navigation that does not assume a post-approval status modal.
`scenario_navigator.review_approve()` appends `USE_CASE_STATUS_DISMISS` on touch
devices, expecting an `NbglReviewStatus` screen after the confirm. This app
deliberately returns straight to its home screen instead: over BLE that screen's
sync wait can fail to auto-dismiss and block the next APDU, and the wallet shows
its own success UI once the broadcast completes. See
`show_status_and_home_if_needed` in `src/main.rs`.
So drive the navigation directly rather than changing the app to suit the
framework's default.
"""
from ragger.navigator import NavInsID
#: Text on the final review page. On touch devices this is NBGL's own hold
#: button; on Nano it is the finish title passed to `NbglReview::titles`.
TOUCH_FINISH = "^Hold to sign$"
NANO_FINISH = r"^Sign (transaction|message)$"
def approve_review(device, navigator, path, test_name, nano_text=NANO_FINISH):
"""Page through a review and approve it."""
if device.is_nano:
navigate, validate, text = NavInsID.RIGHT_CLICK, [NavInsID.BOTH_CLICK], nano_text
else:
navigate, validate, text = (NavInsID.SWIPE_CENTER_TO_LEFT,
[NavInsID.USE_CASE_REVIEW_CONFIRM],
TOUCH_FINISH)
navigator.navigate_until_text_and_compare(
navigate_instruction=navigate,
validation_instructions=validate,
text=text,
path=path,
test_case_name=test_name,
screen_change_after_last_instruction=True,
)

View File

@@ -0,0 +1,2 @@
[tool:pytest]
addopts = --strict-markers

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Some files were not shown because too many files have changed in this diff Show More