commit 1b70b558c6e19e5af380ea5780294fa5e78eabd1 Author: eskimo Date: Sun Sep 6 15:13:36 2026 -0400 Handshake Ledger app Hardware-wallet app for Handshake (HNS). Derives Handshake addresses and signs Handshake transactions, including name-auction covenants, on Ledger Stax, Flex, Nano S+, Nano X and Apex+. The design premise is that the host computer is untrusted: every APDU byte is attacker-controlled, and the device's job is to be correct when the host lies. Two failures outrank all others, and the code is shaped around them. Signing something other than what the user approved. Any field the signature commits to and the host can vary has to appear on the review screen, and anything the screen cannot represent honestly is refused rather than shown under a label that does not describe it. So the device signs SIGHASH_ALL only, checked per input rather than once for the session; it displays a TRANSFER covenant's destination, which decides who ends up owning the name and appears nowhere else in the output; it shows a covenant's name hash when the plaintext name cannot be verified against it; and it refuses covenant kinds it cannot name, outputs whose values exceed their inputs, and declared input totals that would overflow the fee arithmetic. Misusing key material. Derivation paths are constrained to m/44'/{5353..5356}'/account'[/change/index] in the app itself, not only by the install manifest, because BOLOS terminates the app on an out-of-whitelist path instead of returning an error. One coin type per signing session, since the review renders every address under a single HRP. Panics count too: set_panic!(exiting_panic) means a panic kills the app and strands the session, so nothing unwraps on host-derived data and the session ceilings are sized to the configured heap. Consensus follows shd, the Swift Handshake node, which is the reference for the sighash preimage, covenant item layouts and address encoding. SIGN_TX is a PSBT-style state machine driven by P1 (BEGIN / ADD_INPUT / ADD_OUTPUT / REVIEW / SIGN_INPUT): the host streams the transaction as structured records, the device accumulates its own view of the values, shows one review, then signs each input on demand. Tests run under speculos in a container (./scripts/test), 66 of them, green on all five devices. test_sign_tx_policy.py covers each refusal above and needs no golden snapshots because every case is rejected before anything is drawn; the snapshots that do exist are the record of what the user sees before approving. tests/application_client/handshake_sighash.py is a deliberately independent reimplementation of the sighash, used to verify real signatures. Status: v0.1, pre-audit. Not for mainnet keys. Signing has not yet been exercised on physical hardware. Forked from LedgerHQ/app-boilerplate-rust (Apache-2.0). Co-Authored-By: Claude Opus 5 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..3f07925 --- /dev/null +++ b/.cargo/config.toml @@ -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//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. \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f2fb620 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +# Checklist + +- [ ] App update process has been followed +- [ ] Target branch is `develop` +- [ ] Application version has been bumped + + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..04548c4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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`; `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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5462dbb --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/build_and_functional_tests.yml b/.github/workflows/build_and_functional_tests.yml new file mode 100644 index 0000000..c198920 --- /dev/null +++ b/.github/workflows/build_and_functional_tests.yml @@ -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" diff --git a/.github/workflows/coding_style_checks.yml b/.github/workflows/coding_style_checks.yml new file mode 100644 index 0000000..11d49c7 --- /dev/null +++ b/.github/workflows/coding_style_checks.yml @@ -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' diff --git a/.github/workflows/guidelines_enforcer.yml b/.github/workflows/guidelines_enforcer.yml new file mode 100644 index 0000000..b9306ac --- /dev/null +++ b/.github/workflows/guidelines_enforcer.yml @@ -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 diff --git a/.github/workflows/misspellings_checks.yml b/.github/workflows/misspellings_checks.yml new file mode 100644 index 0000000..e476e05 --- /dev/null +++ b/.github/workflows/misspellings_checks.yml @@ -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 diff --git a/.github/workflows/python_tests_checks.yml b/.github/workflows/python_tests_checks.yml new file mode 100644 index 0000000..2afc7e7 --- /dev/null +++ b/.github/workflows/python_tests_checks.yml @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f163443 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e075e36 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1425 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "app-handshake" +version = "0.1.0" +dependencies = [ + "arrayvec", + "bech32", + "hex", + "image", + "ledger_device_sdk", + "ledger_secure_sdk_sys", + "numtoa", + "serde", + "serde-json-core", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "av1-grain" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3efb2ca85bc610acfa917b5aaa36f3fcbebed5b3182d7f877b02531c4b80c8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.98", +] + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + +[[package]] +name = "bitflags" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7777341816418c02e033934a09f20dc0ccaf65a5201ef8a450ae0105a573fda" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "const-zero" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c6565524986fe3225da0beb9b4aa55ebc73cd57ff8cb4ccf016ca4c8d006af" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "embedded-alloc" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddae17915accbac2cfbc64ea0ae6e3b330e6ea124ba108dada63646fd3c6f815" +dependencies = [ + "critical-section", + "linked_list_allocator", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "image" +version = "0.25.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6a3ce16143778e24df6f95365f12ed105425b22abefd289dd88a64bab59605" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" + +[[package]] +name = "include_gif" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4046578028f0249b76fb88acb6a0eac1f635bf9e2630ba5d0562831597ffc323" +dependencies = [ + "flate2", + "image", + "syn 1.0.109", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "ledger_device_sdk" +version = "1.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adde0c75d115974c33db5e8e50dbf809d874d64ea970570cd6cc523ce009d6b" +dependencies = [ + "const-zero", + "include_gif", + "ledger_secure_sdk_sys", + "num-traits", + "numtoa", + "rand_core", + "serde_json", + "zeroize", +] + +[[package]] +name = "ledger_secure_sdk_sys" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18ccb022d22741651f4a13bb417e2e5f4f2d68b832409b80bdc3726e76b1bd9" +dependencies = [ + "bindgen", + "cc", + "critical-section", + "embedded-alloc", + "glob", +] + +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "linked_list_allocator" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afa463f5405ee81cdb9cc2baf37e08ec7e4c8209442b5d72c04cfb2cd6e6286" + +[[package]] +name = "log" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numtoa" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6924ced06e1f7dfe3fa48d57b9f74f55d8915f5036121bef647ef4b204895fac" +dependencies = [ + "proc-macro2", + "syn 2.0.98", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.98", +] + +[[package]] +name = "pxfm" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e790881194f6f6e86945f0a42a6981977323669aeb6c40e9c7ec253133b96f8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "system-deps", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" + +[[package]] +name = "serde" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-json-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b81787e655bd59cecadc91f7b6b8651330b2be6c33246039a65e5cd6f4e0828" +dependencies = [ + "heapless", + "ryu", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.98", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8c19f85 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "app-handshake" +version = "0.1.0" +authors = ["eskimo ", "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"))'] } diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..54de9bc --- /dev/null +++ b/README.md @@ -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//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//`. Regenerate them with +`./scripts/test --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. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..b583284 --- /dev/null +++ b/build.rs @@ -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(); +} diff --git a/glyphs/handshake_48x48.png b/glyphs/handshake_48x48.png new file mode 100644 index 0000000..caa8057 Binary files /dev/null and b/glyphs/handshake_48x48.png differ diff --git a/glyphs/handshake_64x64.gif b/glyphs/handshake_64x64.gif new file mode 100644 index 0000000..e696719 Binary files /dev/null and b/glyphs/handshake_64x64.gif differ diff --git a/icons/handshake_14x14.gif b/icons/handshake_14x14.gif new file mode 100644 index 0000000..e1917c3 Binary files /dev/null and b/icons/handshake_14x14.gif differ diff --git a/icons/handshake_32x32.gif b/icons/handshake_32x32.gif new file mode 100644 index 0000000..f400541 Binary files /dev/null and b/icons/handshake_32x32.gif differ diff --git a/icons/handshake_32x32.png b/icons/handshake_32x32.png new file mode 100644 index 0000000..9ec0c0d Binary files /dev/null and b/icons/handshake_32x32.png differ diff --git a/icons/handshake_40x40.gif b/icons/handshake_40x40.gif new file mode 100644 index 0000000..23a5e94 Binary files /dev/null and b/icons/handshake_40x40.gif differ diff --git a/icons/mask_14x14.gif b/icons/mask_14x14.gif new file mode 100755 index 0000000..1568a9f Binary files /dev/null and b/icons/mask_14x14.gif differ diff --git a/ledger_app.toml b/ledger_app.toml new file mode 100644 index 0000000..5959dbf --- /dev/null +++ b/ledger_app.toml @@ -0,0 +1,7 @@ +[app] +build_directory = "./" +sdk = "Rust" +devices = ["nanox", "nanos+", "stax", "flex", "apex_p"] + +[pytest.standalone] +directory = "./tests/standalone/" diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..fc18a98 --- /dev/null +++ b/mypy.ini @@ -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 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b8ce6f8 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2025-12-05" diff --git a/scripts/build b/scripts/build new file mode 100755 index 0000000..354e8c7 --- /dev/null +++ b/scripts/build @@ -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" diff --git a/scripts/build-unsigned-tx b/scripts/build-unsigned-tx new file mode 100755 index 0000000..ab00d76 --- /dev/null +++ b/scripts/build-unsigned-tx @@ -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 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]: + """`:` → (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="`:` (repeat for multiple inputs)") + ap.add_argument("--output", action="append", required=True, + help="`:` (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()) diff --git a/scripts/gen-manifest b/scripts/gen-manifest new file mode 100755 index 0000000..2d71d75 --- /dev/null +++ b/scripts/gen-manifest @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Write the ledgerctl install manifest for a built app. + +cargo-ledger does not emit `app_.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.] 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() diff --git a/scripts/send-apdu b/scripts/send-apdu new file mode 100755 index 0000000..fc3e05f --- /dev/null +++ b/scripts/send-apdu @@ -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 ", 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()) diff --git a/scripts/sign-tx b/scripts/sign-tx new file mode 100755 index 0000000..573088c --- /dev/null +++ b/scripts/sign-tx @@ -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_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: + """`:` -> 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="`::` (repeat for multiple inputs)") + ap.add_argument("--output", action="append", default=[], + help="`:` (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()) diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..ec172f6 --- /dev/null +++ b/scripts/test @@ -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" "$@" diff --git a/src/app_ui/address.rs b/src/app_ui/address.rs new file mode 100644 index 0000000..491e049 --- /dev/null +++ b/src/app_ui/address.rs @@ -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 { + #[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)) +} diff --git a/src/app_ui/menu.rs b/src/app_ui/menu.rs new file mode 100644 index 0000000..adf69f3 --- /dev/null +++ b/src/app_ui/menu.rs @@ -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") +} diff --git a/src/app_ui/message.rs b/src/app_ui/message.rs new file mode 100644 index 0000000..2c86d62 --- /dev/null +++ b/src/app_ui/message.rs @@ -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 { + #[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 = 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 + } +} diff --git a/src/app_ui/sign.rs b/src/app_ui/sign.rs new file mode 100644 index 0000000..b8fb43e --- /dev/null +++ b/src/app_ui/sign.rs @@ -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 { + #[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 = 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) +} diff --git a/src/handlers/get_public_key.rs b/src/handlers/get_public_key.rs new file mode 100644 index 0000000..e41075f --- /dev/null +++ b/src/handlers/get_public_key.rs @@ -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, 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)) +} diff --git a/src/handlers/get_version.rs b/src/handlers/get_version.rs new file mode 100644 index 0000000..4585989 --- /dev/null +++ b/src/handlers/get_version.rs @@ -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, 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)) +} diff --git a/src/handlers/sign_message.rs b/src/handlers/sign_message.rs new file mode 100644 index 0000000..a837688 --- /dev/null +++ b/src/handlers/sign_message.rs @@ -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, 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 { + Ok(self.need(1)?[0]) + } + fn read_u16_le(&mut self) -> Result { + let b = self.need(2)?; + Ok(u16::from_le_bytes([b[0], b[1]])) + } + fn read_u32_be(&mut self) -> Result { + 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) + } +} diff --git a/src/handlers/sign_tx.rs b/src/handlers/sign_tx.rs new file mode 100644 index 0000000..4b4c89d --- /dev/null +++ b/src/handlers/sign_tx.rs @@ -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_metas: Vec, + outputs: Vec, + /// 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, + /// 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, 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, 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, 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, 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, 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 = 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 { + 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::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 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 { + Ok(self.need(1)?[0]) + } + + fn read_u32_le(&mut self) -> Result { + 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 { + 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 { + 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(&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 { + 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, + }) + } +} diff --git a/src/handshake/address.rs b/src/handshake/address.rs new file mode 100644 index 0000000..7e0f910 --- /dev/null +++ b/src/handshake/address.rs @@ -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) +} diff --git a/src/handshake/bech32_encode.rs b/src/handshake/bech32_encode.rs new file mode 100644 index 0000000..ce24549 --- /dev/null +++ b/src/handshake/bech32_encode.rs @@ -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") +} diff --git a/src/handshake/blake2b.rs b/src/handshake/blake2b.rs new file mode 100644 index 0000000..4b54482 --- /dev/null +++ b/src/handshake/blake2b.rs @@ -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 +} diff --git a/src/handshake/covenant.rs b/src/handshake/covenant.rs new file mode 100644 index 0000000..a717c0d --- /dev/null +++ b/src/handshake/covenant.rs @@ -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]) -> Option { + 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]) -> 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]) -> 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)) +} diff --git a/src/handshake/mod.rs b/src/handshake/mod.rs new file mode 100644 index 0000000..8db81b1 --- /dev/null +++ b/src/handshake/mod.rs @@ -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'/'/...`) 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 +} diff --git a/src/handshake/sighash.rs b/src/handshake/sighash.rs new file mode 100644 index 0000000..7da4646 --- /dev/null +++ b/src/handshake/sighash.rs @@ -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 = 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, 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, 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, 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, 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); + } +} diff --git a/src/handshake/tx.rs b/src/handshake/tx.rs new file mode 100644 index 0000000..7f05a39 --- /dev/null +++ b/src/handshake/tx.rs @@ -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>, +} + +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, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d1fdf70 --- /dev/null +++ b/src/main.rs @@ -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 for Reply { + fn from(sw: AppSW) -> Reply { + Reply(sw as u16) + } +} + +impl From 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 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 { + 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::(); + 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, 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), + } +} diff --git a/src/swap.rs b/src/swap.rs new file mode 100644 index 0000000..812d2a7 --- /dev/null +++ b/src/swap.rs @@ -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 +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..eff696a --- /dev/null +++ b/src/utils.rs @@ -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); + +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 { + 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 { + // 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'/'/account'` with an +/// optional `/change/index` beneath it, where `` 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 +} diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..dd69152 --- /dev/null +++ b/tests/Dockerfile @@ -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}" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..4649a4e --- /dev/null +++ b/tests/README.md @@ -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///`. Regenerate with +`../scripts/test --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()`. diff --git a/tests/application_client/README.md b/tests/application_client/README.md new file mode 100644 index 0000000..a4216d3 --- /dev/null +++ b/tests/application_client/README.md @@ -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. diff --git a/tests/application_client/__init__.py b/tests/application_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/application_client/handshake_command_sender.py b/tests/application_client/handshake_command_sender.py new file mode 100644 index 0000000..43c1b65 --- /dev/null +++ b/tests/application_client/handshake_command_sender.py @@ -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 diff --git a/tests/application_client/handshake_response_unpacker.py b/tests/application_client/handshake_response_unpacker.py new file mode 100644 index 0000000..028a8b5 --- /dev/null +++ b/tests/application_client/handshake_response_unpacker.py @@ -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:] diff --git a/tests/application_client/handshake_sighash.py b/tests/application_client/handshake_sighash.py new file mode 100644 index 0000000..5383965 --- /dev/null +++ b/tests/application_client/handshake_sighash.py @@ -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 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) diff --git a/tests/application_client/handshake_transaction.py b/tests/application_client/handshake_transaction.py new file mode 100644 index 0000000..d898db7 --- /dev/null +++ b/tests/application_client/handshake_transaction.py @@ -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] diff --git a/tests/application_client/py.typed b/tests/application_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/setup.cfg b/tests/setup.cfg new file mode 100644 index 0000000..7d3bb78 --- /dev/null +++ b/tests/setup.cfg @@ -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 diff --git a/tests/standalone/README.md b/tests/standalone/README.md new file mode 100644 index 0000000..63f8120 --- /dev/null +++ b/tests/standalone/README.md @@ -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 application’s 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 +``` diff --git a/tests/standalone/__init__.py b/tests/standalone/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/standalone/conftest.py b/tests/standalone/conftest.py new file mode 100644 index 0000000..04e78ab --- /dev/null +++ b/tests/standalone/conftest.py @@ -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) diff --git a/tests/standalone/requirements.txt b/tests/standalone/requirements.txt new file mode 100644 index 0000000..ac945d2 --- /dev/null +++ b/tests/standalone/requirements.txt @@ -0,0 +1,4 @@ +pytest +ragger[speculos,ledgerwallet]>=1.21.1 +ecdsa>=0.16.1,<0.17.0 +tomli>=2.0.1 diff --git a/tests/standalone/review_nav.py b/tests/standalone/review_nav.py new file mode 100644 index 0000000..03eda43 --- /dev/null +++ b/tests/standalone/review_nav.py @@ -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, + ) diff --git a/tests/standalone/setup.cfg b/tests/standalone/setup.cfg new file mode 100644 index 0000000..f1b54e3 --- /dev/null +++ b/tests/standalone/setup.cfg @@ -0,0 +1,2 @@ +[tool:pytest] +addopts = --strict-markers diff --git a/tests/standalone/snapshots/apex_p/test_app_mainmenu/00000.png b/tests/standalone/snapshots/apex_p/test_app_mainmenu/00000.png new file mode 100644 index 0000000..25bc2a5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_app_mainmenu/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_app_mainmenu/00001.png b/tests/standalone/snapshots/apex_p/test_app_mainmenu/00001.png new file mode 100644 index 0000000..40eafb1 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_app_mainmenu/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_app_mainmenu/00002.png b/tests/standalone/snapshots/apex_p/test_app_mainmenu/00002.png new file mode 100644 index 0000000..25bc2a5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_app_mainmenu/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00000.png b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00000.png new file mode 100644 index 0000000..21874f5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00001.png b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00001.png new file mode 100644 index 0000000..2a27d35 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00002.png b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00002.png new file mode 100644 index 0000000..6ce358d Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00003.png b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00003.png new file mode 100644 index 0000000..25bc2a5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_accepted/00003.png differ diff --git a/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_refused/00000.png b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_refused/00000.png new file mode 100644 index 0000000..21874f5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_refused/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_refused/00001.png b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_refused/00001.png new file mode 100644 index 0000000..2a27d35 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_get_public_key_confirm_refused/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00000.png b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00000.png new file mode 100644 index 0000000..8ffa04b Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00001.png b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00001.png new file mode 100644 index 0000000..ffeb31d Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00002.png b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00002.png new file mode 100644 index 0000000..fdfd96e Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00003.png b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00003.png new file mode 100644 index 0000000..25bc2a5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_accepted/00003.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_refused/00000.png b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00000.png new file mode 100644 index 0000000..8ffa04b Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_refused/00001.png b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00001.png new file mode 100644 index 0000000..ffeb31d Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_refused/00002.png b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00002.png new file mode 100644 index 0000000..fdfd96e Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_message_refused/00003.png b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00003.png new file mode 100644 index 0000000..cf10939 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_message_refused/00003.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00000.png b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00000.png new file mode 100644 index 0000000..e682e4d Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00001.png b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00001.png new file mode 100644 index 0000000..5492a65 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00002.png b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00002.png new file mode 100644 index 0000000..f69213e Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00003.png b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00003.png new file mode 100644 index 0000000..3b2ca00 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00003.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00004.png b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00004.png new file mode 100644 index 0000000..25bc2a5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_accepted/00004.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00000.png b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00000.png new file mode 100644 index 0000000..e682e4d Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00001.png b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00001.png new file mode 100644 index 0000000..f61dea2 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00002.png b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00002.png new file mode 100644 index 0000000..8d9fa5e Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00003.png b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00003.png new file mode 100644 index 0000000..3b2ca00 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00003.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00004.png b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00004.png new file mode 100644 index 0000000..25bc2a5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_open_covenant/00004.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00000.png b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00000.png new file mode 100644 index 0000000..e682e4d Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00001.png b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00001.png new file mode 100644 index 0000000..9b1ca76 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00002.png b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00002.png new file mode 100644 index 0000000..dbffa93 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00003.png b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00003.png new file mode 100644 index 0000000..3b2ca00 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00003.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00004.png b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00004.png new file mode 100644 index 0000000..cf10939 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_refused/00004.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00000.png b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00000.png new file mode 100644 index 0000000..74ee5ee Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00000.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00001.png b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00001.png new file mode 100644 index 0000000..4c0b236 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00001.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00002.png b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00002.png new file mode 100644 index 0000000..347adf8 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00002.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00003.png b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00003.png new file mode 100644 index 0000000..ffc30d5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00003.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00004.png b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00004.png new file mode 100644 index 0000000..7e47ad0 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00004.png differ diff --git a/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00005.png b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00005.png new file mode 100644 index 0000000..25bc2a5 Binary files /dev/null and b/tests/standalone/snapshots/apex_p/test_sign_tx_transfer_covenant/00005.png differ diff --git a/tests/standalone/snapshots/flex/test_app_mainmenu/00000.png b/tests/standalone/snapshots/flex/test_app_mainmenu/00000.png new file mode 100644 index 0000000..e784bb1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_app_mainmenu/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_app_mainmenu/00001.png b/tests/standalone/snapshots/flex/test_app_mainmenu/00001.png new file mode 100644 index 0000000..ecd73bf Binary files /dev/null and b/tests/standalone/snapshots/flex/test_app_mainmenu/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_app_mainmenu/00002.png b/tests/standalone/snapshots/flex/test_app_mainmenu/00002.png new file mode 100644 index 0000000..e784bb1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_app_mainmenu/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00000.png b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00000.png new file mode 100644 index 0000000..8702346 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00001.png b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00001.png new file mode 100644 index 0000000..c8829e9 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00002.png b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00002.png new file mode 100644 index 0000000..8d07f2f Binary files /dev/null and b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00003.png b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00003.png new file mode 100644 index 0000000..e784bb1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_get_public_key_confirm_accepted/00003.png differ diff --git a/tests/standalone/snapshots/flex/test_get_public_key_confirm_refused/00000.png b/tests/standalone/snapshots/flex/test_get_public_key_confirm_refused/00000.png new file mode 100644 index 0000000..8702346 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_get_public_key_confirm_refused/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_get_public_key_confirm_refused/00001.png b/tests/standalone/snapshots/flex/test_get_public_key_confirm_refused/00001.png new file mode 100644 index 0000000..c8829e9 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_get_public_key_confirm_refused/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_accepted/00000.png b/tests/standalone/snapshots/flex/test_sign_message_accepted/00000.png new file mode 100644 index 0000000..0e4dd39 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_accepted/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_accepted/00001.png b/tests/standalone/snapshots/flex/test_sign_message_accepted/00001.png new file mode 100644 index 0000000..f9aea1e Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_accepted/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_accepted/00002.png b/tests/standalone/snapshots/flex/test_sign_message_accepted/00002.png new file mode 100644 index 0000000..41a522b Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_accepted/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_accepted/00003.png b/tests/standalone/snapshots/flex/test_sign_message_accepted/00003.png new file mode 100644 index 0000000..e784bb1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_accepted/00003.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_refused/00000.png b/tests/standalone/snapshots/flex/test_sign_message_refused/00000.png new file mode 100644 index 0000000..0e4dd39 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_refused/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_refused/00001.png b/tests/standalone/snapshots/flex/test_sign_message_refused/00001.png new file mode 100644 index 0000000..f9aea1e Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_refused/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_refused/00002.png b/tests/standalone/snapshots/flex/test_sign_message_refused/00002.png new file mode 100644 index 0000000..41a522b Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_refused/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_message_refused/00003.png b/tests/standalone/snapshots/flex/test_sign_message_refused/00003.png new file mode 100644 index 0000000..315d354 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_message_refused/00003.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_accepted/00000.png b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00000.png new file mode 100644 index 0000000..2e82d01 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_accepted/00001.png b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00001.png new file mode 100644 index 0000000..edb14e1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_accepted/00002.png b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00002.png new file mode 100644 index 0000000..1310503 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_accepted/00003.png b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00003.png new file mode 100644 index 0000000..2084d60 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00003.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_accepted/00004.png b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00004.png new file mode 100644 index 0000000..e784bb1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_accepted/00004.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00000.png b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00000.png new file mode 100644 index 0000000..2e82d01 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00001.png b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00001.png new file mode 100644 index 0000000..00935e6 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00002.png b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00002.png new file mode 100644 index 0000000..898539c Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00003.png b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00003.png new file mode 100644 index 0000000..2084d60 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00003.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00004.png b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00004.png new file mode 100644 index 0000000..e784bb1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_open_covenant/00004.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_refused/00000.png b/tests/standalone/snapshots/flex/test_sign_tx_refused/00000.png new file mode 100644 index 0000000..2e82d01 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_refused/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_refused/00001.png b/tests/standalone/snapshots/flex/test_sign_tx_refused/00001.png new file mode 100644 index 0000000..d1b35ed Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_refused/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_refused/00002.png b/tests/standalone/snapshots/flex/test_sign_tx_refused/00002.png new file mode 100644 index 0000000..7d691b2 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_refused/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_refused/00003.png b/tests/standalone/snapshots/flex/test_sign_tx_refused/00003.png new file mode 100644 index 0000000..2084d60 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_refused/00003.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_refused/00004.png b/tests/standalone/snapshots/flex/test_sign_tx_refused/00004.png new file mode 100644 index 0000000..315d354 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_refused/00004.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00000.png b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00000.png new file mode 100644 index 0000000..112db8f Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00000.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00001.png b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00001.png new file mode 100644 index 0000000..dc5763b Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00001.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00002.png b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00002.png new file mode 100644 index 0000000..ad73497 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00002.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00003.png b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00003.png new file mode 100644 index 0000000..b55b84e Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00003.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00004.png b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00004.png new file mode 100644 index 0000000..1651d58 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00004.png differ diff --git a/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00005.png b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00005.png new file mode 100644 index 0000000..e784bb1 Binary files /dev/null and b/tests/standalone/snapshots/flex/test_sign_tx_transfer_covenant/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_app_mainmenu/00000.png b/tests/standalone/snapshots/nanosp/test_app_mainmenu/00000.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_app_mainmenu/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_app_mainmenu/00001.png b/tests/standalone/snapshots/nanosp/test_app_mainmenu/00001.png new file mode 100644 index 0000000..77bcafd Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_app_mainmenu/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_app_mainmenu/00002.png b/tests/standalone/snapshots/nanosp/test_app_mainmenu/00002.png new file mode 100644 index 0000000..f265d69 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_app_mainmenu/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00000.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00000.png new file mode 100644 index 0000000..aa0b6bc Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00001.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00001.png new file mode 100644 index 0000000..ecdcf3d Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00002.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00003.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00003.png new file mode 100644 index 0000000..7dfa0c5 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00004.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00004.png new file mode 100644 index 0000000..84caa59 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_accepted/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00000.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00000.png new file mode 100644 index 0000000..aa0b6bc Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00001.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00001.png new file mode 100644 index 0000000..ecdcf3d Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00002.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00003.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00003.png new file mode 100644 index 0000000..7dfa0c5 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00004.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00004.png new file mode 100644 index 0000000..7b38f99 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00005.png b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00005.png new file mode 100644 index 0000000..03e5ee2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_get_public_key_confirm_refused/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00000.png b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00000.png new file mode 100644 index 0000000..6d3fa7b Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00001.png b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00001.png new file mode 100644 index 0000000..0d43942 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00002.png b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00003.png b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00003.png new file mode 100644 index 0000000..79b3799 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00004.png b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00004.png new file mode 100644 index 0000000..55bb5c8 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00005.png b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00005.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_accepted/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_refused/00000.png b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00000.png new file mode 100644 index 0000000..6d3fa7b Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_refused/00001.png b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00001.png new file mode 100644 index 0000000..0d43942 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_refused/00002.png b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_refused/00003.png b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00003.png new file mode 100644 index 0000000..79b3799 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_refused/00004.png b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00004.png new file mode 100644 index 0000000..55bb5c8 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_refused/00005.png b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00005.png new file mode 100644 index 0000000..09a1af1 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_message_refused/00006.png b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00006.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_message_refused/00006.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00000.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00001.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00001.png new file mode 100644 index 0000000..cd4dba2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00002.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00002.png new file mode 100644 index 0000000..9c176a1 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00003.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00003.png new file mode 100644 index 0000000..943f4af Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00004.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00004.png new file mode 100644 index 0000000..cb8f1b9 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00005.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00005.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00006.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00006.png new file mode 100644 index 0000000..e59cc9a Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00006.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00007.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00007.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00007.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00008.png b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00008.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_accepted/00008.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00000.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00001.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00001.png new file mode 100644 index 0000000..cd4dba2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00002.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00002.png new file mode 100644 index 0000000..17a971e Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00003.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00003.png new file mode 100644 index 0000000..65133d9 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00004.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00004.png new file mode 100644 index 0000000..764bc9a Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00005.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00005.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00006.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00006.png new file mode 100644 index 0000000..192361e Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00006.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00007.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00007.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00007.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00008.png b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00008.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_open_covenant/00008.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00000.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00001.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00001.png new file mode 100644 index 0000000..cd4dba2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00002.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00002.png new file mode 100644 index 0000000..1007792 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00003.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00003.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00004.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00004.png new file mode 100644 index 0000000..c61de7b Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00005.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00005.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00006.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00006.png new file mode 100644 index 0000000..09a1af1 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00006.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00007.png b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00007.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_refused/00007.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00000.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00000.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00001.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00001.png new file mode 100644 index 0000000..1ac92da Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00001.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00002.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00002.png new file mode 100644 index 0000000..4ebcd27 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00002.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00003.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00003.png new file mode 100644 index 0000000..b967e48 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00003.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00004.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00004.png new file mode 100644 index 0000000..bbb17e4 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00004.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00005.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00005.png new file mode 100644 index 0000000..d527187 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00005.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00006.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00006.png new file mode 100644 index 0000000..860cf8d Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00006.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00007.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00007.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00007.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00008.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00008.png new file mode 100644 index 0000000..bafaedb Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00008.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00009.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00009.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00009.png differ diff --git a/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00010.png b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00010.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanosp/test_sign_tx_transfer_covenant/00010.png differ diff --git a/tests/standalone/snapshots/nanox/test_app_mainmenu/00000.png b/tests/standalone/snapshots/nanox/test_app_mainmenu/00000.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_app_mainmenu/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_app_mainmenu/00001.png b/tests/standalone/snapshots/nanox/test_app_mainmenu/00001.png new file mode 100644 index 0000000..77bcafd Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_app_mainmenu/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_app_mainmenu/00002.png b/tests/standalone/snapshots/nanox/test_app_mainmenu/00002.png new file mode 100644 index 0000000..f265d69 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_app_mainmenu/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00000.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00000.png new file mode 100644 index 0000000..aa0b6bc Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00001.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00001.png new file mode 100644 index 0000000..ecdcf3d Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00002.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00003.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00003.png new file mode 100644 index 0000000..7dfa0c5 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00004.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00004.png new file mode 100644 index 0000000..84caa59 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_accepted/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00000.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00000.png new file mode 100644 index 0000000..aa0b6bc Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00001.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00001.png new file mode 100644 index 0000000..ecdcf3d Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00002.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00003.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00003.png new file mode 100644 index 0000000..7dfa0c5 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00004.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00004.png new file mode 100644 index 0000000..7b38f99 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00005.png b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00005.png new file mode 100644 index 0000000..03e5ee2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_get_public_key_confirm_refused/00005.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_accepted/00000.png b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00000.png new file mode 100644 index 0000000..6d3fa7b Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_accepted/00001.png b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00001.png new file mode 100644 index 0000000..0d43942 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_accepted/00002.png b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_accepted/00003.png b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00003.png new file mode 100644 index 0000000..79b3799 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_accepted/00004.png b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00004.png new file mode 100644 index 0000000..55bb5c8 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_accepted/00005.png b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00005.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_accepted/00005.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_refused/00000.png b/tests/standalone/snapshots/nanox/test_sign_message_refused/00000.png new file mode 100644 index 0000000..6d3fa7b Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_refused/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_refused/00001.png b/tests/standalone/snapshots/nanox/test_sign_message_refused/00001.png new file mode 100644 index 0000000..0d43942 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_refused/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_refused/00002.png b/tests/standalone/snapshots/nanox/test_sign_message_refused/00002.png new file mode 100644 index 0000000..95ba65e Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_refused/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_refused/00003.png b/tests/standalone/snapshots/nanox/test_sign_message_refused/00003.png new file mode 100644 index 0000000..79b3799 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_refused/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_refused/00004.png b/tests/standalone/snapshots/nanox/test_sign_message_refused/00004.png new file mode 100644 index 0000000..55bb5c8 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_refused/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_refused/00005.png b/tests/standalone/snapshots/nanox/test_sign_message_refused/00005.png new file mode 100644 index 0000000..09a1af1 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_refused/00005.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_message_refused/00006.png b/tests/standalone/snapshots/nanox/test_sign_message_refused/00006.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_message_refused/00006.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00000.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00001.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00001.png new file mode 100644 index 0000000..cd4dba2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00002.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00002.png new file mode 100644 index 0000000..9c176a1 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00003.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00003.png new file mode 100644 index 0000000..943f4af Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00004.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00004.png new file mode 100644 index 0000000..cb8f1b9 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00005.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00005.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00005.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00006.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00006.png new file mode 100644 index 0000000..e59cc9a Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00006.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00007.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00007.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00007.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00008.png b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00008.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_accepted/00008.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00000.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00001.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00001.png new file mode 100644 index 0000000..cd4dba2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00002.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00002.png new file mode 100644 index 0000000..17a971e Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00003.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00003.png new file mode 100644 index 0000000..65133d9 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00004.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00004.png new file mode 100644 index 0000000..764bc9a Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00005.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00005.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00005.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00006.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00006.png new file mode 100644 index 0000000..192361e Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00006.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00007.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00007.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00007.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00008.png b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00008.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_open_covenant/00008.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00000.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00001.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00001.png new file mode 100644 index 0000000..cd4dba2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00002.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00002.png new file mode 100644 index 0000000..1007792 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00003.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00003.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00004.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00004.png new file mode 100644 index 0000000..c61de7b Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00005.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00005.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00005.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00006.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00006.png new file mode 100644 index 0000000..09a1af1 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00006.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_refused/00007.png b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00007.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_refused/00007.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00000.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00000.png new file mode 100644 index 0000000..cb7d8e2 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00000.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00001.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00001.png new file mode 100644 index 0000000..1ac92da Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00001.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00002.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00002.png new file mode 100644 index 0000000..4ebcd27 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00002.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00003.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00003.png new file mode 100644 index 0000000..b967e48 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00003.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00004.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00004.png new file mode 100644 index 0000000..bbb17e4 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00004.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00005.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00005.png new file mode 100644 index 0000000..d527187 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00005.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00006.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00006.png new file mode 100644 index 0000000..860cf8d Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00006.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00007.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00007.png new file mode 100644 index 0000000..37cef67 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00007.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00008.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00008.png new file mode 100644 index 0000000..bafaedb Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00008.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00009.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00009.png new file mode 100644 index 0000000..c1d53a3 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00009.png differ diff --git a/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00010.png b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00010.png new file mode 100644 index 0000000..5f594e0 Binary files /dev/null and b/tests/standalone/snapshots/nanox/test_sign_tx_transfer_covenant/00010.png differ diff --git a/tests/standalone/snapshots/stax/test_app_mainmenu/00000.png b/tests/standalone/snapshots/stax/test_app_mainmenu/00000.png new file mode 100644 index 0000000..8228ba4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_app_mainmenu/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_app_mainmenu/00001.png b/tests/standalone/snapshots/stax/test_app_mainmenu/00001.png new file mode 100644 index 0000000..69d2e75 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_app_mainmenu/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_app_mainmenu/00002.png b/tests/standalone/snapshots/stax/test_app_mainmenu/00002.png new file mode 100644 index 0000000..8228ba4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_app_mainmenu/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00000.png b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00000.png new file mode 100644 index 0000000..72a01ee Binary files /dev/null and b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00001.png b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00001.png new file mode 100644 index 0000000..066528a Binary files /dev/null and b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00002.png b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00002.png new file mode 100644 index 0000000..ef04425 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00003.png b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00003.png new file mode 100644 index 0000000..8228ba4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_get_public_key_confirm_accepted/00003.png differ diff --git a/tests/standalone/snapshots/stax/test_get_public_key_confirm_refused/00000.png b/tests/standalone/snapshots/stax/test_get_public_key_confirm_refused/00000.png new file mode 100644 index 0000000..72a01ee Binary files /dev/null and b/tests/standalone/snapshots/stax/test_get_public_key_confirm_refused/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_get_public_key_confirm_refused/00001.png b/tests/standalone/snapshots/stax/test_get_public_key_confirm_refused/00001.png new file mode 100644 index 0000000..066528a Binary files /dev/null and b/tests/standalone/snapshots/stax/test_get_public_key_confirm_refused/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_accepted/00000.png b/tests/standalone/snapshots/stax/test_sign_message_accepted/00000.png new file mode 100644 index 0000000..aae3b98 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_accepted/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_accepted/00001.png b/tests/standalone/snapshots/stax/test_sign_message_accepted/00001.png new file mode 100644 index 0000000..2aa979e Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_accepted/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_accepted/00002.png b/tests/standalone/snapshots/stax/test_sign_message_accepted/00002.png new file mode 100644 index 0000000..179e6a6 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_accepted/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_accepted/00003.png b/tests/standalone/snapshots/stax/test_sign_message_accepted/00003.png new file mode 100644 index 0000000..8228ba4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_accepted/00003.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_refused/00000.png b/tests/standalone/snapshots/stax/test_sign_message_refused/00000.png new file mode 100644 index 0000000..aae3b98 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_refused/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_refused/00001.png b/tests/standalone/snapshots/stax/test_sign_message_refused/00001.png new file mode 100644 index 0000000..2aa979e Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_refused/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_refused/00002.png b/tests/standalone/snapshots/stax/test_sign_message_refused/00002.png new file mode 100644 index 0000000..179e6a6 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_refused/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_message_refused/00003.png b/tests/standalone/snapshots/stax/test_sign_message_refused/00003.png new file mode 100644 index 0000000..abc9677 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_message_refused/00003.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_accepted/00000.png b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00000.png new file mode 100644 index 0000000..5df0415 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_accepted/00001.png b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00001.png new file mode 100644 index 0000000..20a8c71 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_accepted/00002.png b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00002.png new file mode 100644 index 0000000..493a7e9 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_accepted/00003.png b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00003.png new file mode 100644 index 0000000..b347aae Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00003.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_accepted/00004.png b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00004.png new file mode 100644 index 0000000..8228ba4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_accepted/00004.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00000.png b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00000.png new file mode 100644 index 0000000..5df0415 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00001.png b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00001.png new file mode 100644 index 0000000..04945b5 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00002.png b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00002.png new file mode 100644 index 0000000..c996046 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00003.png b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00003.png new file mode 100644 index 0000000..b347aae Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00003.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00004.png b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00004.png new file mode 100644 index 0000000..8228ba4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_open_covenant/00004.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_refused/00000.png b/tests/standalone/snapshots/stax/test_sign_tx_refused/00000.png new file mode 100644 index 0000000..8ed700d Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_refused/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_refused/00001.png b/tests/standalone/snapshots/stax/test_sign_tx_refused/00001.png new file mode 100644 index 0000000..e23edd3 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_refused/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_refused/00002.png b/tests/standalone/snapshots/stax/test_sign_tx_refused/00002.png new file mode 100644 index 0000000..d1a1755 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_refused/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_refused/00003.png b/tests/standalone/snapshots/stax/test_sign_tx_refused/00003.png new file mode 100644 index 0000000..abc9677 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_refused/00003.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00000.png b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00000.png new file mode 100644 index 0000000..bc9c6d5 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00000.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00001.png b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00001.png new file mode 100644 index 0000000..98e62d4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00001.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00002.png b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00002.png new file mode 100644 index 0000000..1850cfe Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00002.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00003.png b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00003.png new file mode 100644 index 0000000..1605e28 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00003.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00004.png b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00004.png new file mode 100644 index 0000000..25db85e Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00004.png differ diff --git a/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00005.png b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00005.png new file mode 100644 index 0000000..8228ba4 Binary files /dev/null and b/tests/standalone/snapshots/stax/test_sign_tx_transfer_covenant/00005.png differ diff --git a/tests/standalone/test_app_mainmenu.py b/tests/standalone/test_app_mainmenu.py new file mode 100644 index 0000000..d399f49 --- /dev/null +++ b/tests/standalone/test_app_mainmenu.py @@ -0,0 +1,19 @@ +from ragger.navigator import NavInsID + + +def test_app_mainmenu(device, navigator, test_name, default_screenshot_path): + """Home screen and the app-info page behind it. + + The app registers no settings switches, so Nano has three pages (ready, + app info, quit) and does not wrap, and touch devices have a single info + page with no sub-settings to page through. + """ + if device.is_nano: + instructions = [NavInsID.RIGHT_CLICK, NavInsID.RIGHT_CLICK] + else: + instructions = [ + NavInsID.USE_CASE_HOME_SETTINGS, + NavInsID.USE_CASE_SUB_SETTINGS_EXIT, + ] + navigator.navigate_and_compare(default_screenshot_path, test_name, instructions, + screen_change_before_first_instruction=False) diff --git a/tests/standalone/test_appname_cmd.py b/tests/standalone/test_appname_cmd.py new file mode 100644 index 0000000..b1d40f4 --- /dev/null +++ b/tests/standalone/test_appname_cmd.py @@ -0,0 +1,10 @@ +from application_client.handshake_command_sender import HandshakeCommandSender +from application_client.handshake_response_unpacker import unpack_get_app_name_response + + +def test_app_name(backend): + """The wallet's preflight compares this to the exact string "Handshake", + so it is part of the wire contract, not a cosmetic label.""" + client = HandshakeCommandSender(backend) + rapdu = client.get_app_name() + assert unpack_get_app_name_response(rapdu.data) == "Handshake" diff --git a/tests/standalone/test_error_cmd.py b/tests/standalone/test_error_cmd.py new file mode 100644 index 0000000..ab448b4 --- /dev/null +++ b/tests/standalone/test_error_cmd.py @@ -0,0 +1,34 @@ +import pytest +from ragger.error import ExceptionRAPDU + +from application_client.handshake_command_sender import CLA, Errors, InsType, P1, P2 + + +def test_bad_cla(backend): + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA + 1, ins=InsType.GET_VERSION) + assert e.value.status == Errors.SW_CLA_NOT_SUPPORTED + + +@pytest.mark.parametrize("ins", [0x00, 0x02, 0x08, 0xFF]) +def test_bad_ins(backend, ins): + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=ins) + assert e.value.status == Errors.SW_INS_NOT_SUPPORTED + + +@pytest.mark.parametrize("ins,p1,p2", [ + (InsType.GET_VERSION, 0x01, 0x00), + (InsType.GET_VERSION, 0x00, 0x80), + (InsType.GET_APP_NAME, 0x01, 0x00), + (InsType.GET_PUBLIC_KEY, 0x02, 0x00), + (InsType.GET_PUBLIC_KEY, 0x00, 0x01), + (InsType.SIGN_TX, 0x05, 0x00), # no such SIGN_TX sub-op + (InsType.SIGN_TX, P1.SIGN_BEGIN, 0x80), + (InsType.SIGN_TX, P1.SIGN_REVIEW, 0x80), + (InsType.SIGN_MESSAGE, 0x01, 0x00), +]) +def test_wrong_p1p2(backend, ins, p1, p2): + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=ins, p1=p1, p2=p2) + assert e.value.status == Errors.SW_WRONG_P1P2 diff --git a/tests/standalone/test_pubkey_cmd.py b/tests/standalone/test_pubkey_cmd.py new file mode 100644 index 0000000..cf0bcae --- /dev/null +++ b/tests/standalone/test_pubkey_cmd.py @@ -0,0 +1,57 @@ +import pytest +from ragger.bip import calculate_public_key_and_chaincode, CurveChoice +from ragger.error import ExceptionRAPDU + +from application_client.handshake_command_sender import Errors, HandshakeCommandSender +from application_client.handshake_response_unpacker import ( + compress_pubkey, unpack_get_public_key_response, +) + +PATHS = [ + "m/44'/5353'/0'/0/0", + "m/44'/5353'/0'/1/7", + "m/44'/5353'/99'/0/0", + "m/44'/5353'/0'", + "m/44'/5354'/0'/0/0", +] + + +@pytest.mark.parametrize("path", PATHS) +def test_get_public_key_no_confirm(backend, path): + client = HandshakeCommandSender(backend) + pubkey, chaincode, address = unpack_get_public_key_response( + client.get_public_key(path=path).data) + + ref_pubkey, ref_chaincode = calculate_public_key_and_chaincode( + CurveChoice.Secp256k1, path=path) + assert pubkey == compress_pubkey(bytes.fromhex(ref_pubkey)) + assert chaincode.hex() == ref_chaincode + + # Handshake is bech32 (never bech32m), one HRP per network. + expected_hrp = "ts" if "5354'" in path else "hs" + assert address.startswith(expected_hrp + "1") + + +def test_get_public_key_confirm_accepted(backend, scenario_navigator): + client = HandshakeCommandSender(backend) + path = "m/44'/5353'/0'/0/0" + + with client.get_public_key_with_confirmation(path=path): + scenario_navigator.address_review_approve() + + pubkey, _, address = unpack_get_public_key_response( + client.get_async_response().data) + ref_pubkey, _ = calculate_public_key_and_chaincode(CurveChoice.Secp256k1, path=path) + assert pubkey == compress_pubkey(bytes.fromhex(ref_pubkey)) + assert address.startswith("hs1") + + +def test_get_public_key_confirm_refused(backend, scenario_navigator): + client = HandshakeCommandSender(backend) + + with pytest.raises(ExceptionRAPDU) as e: + with client.get_public_key_with_confirmation(path="m/44'/5353'/0'/0/0"): + scenario_navigator.address_review_reject() + + assert e.value.status == Errors.SW_DENY + assert len(e.value.data) == 0 diff --git a/tests/standalone/test_sign_message.py b/tests/standalone/test_sign_message.py new file mode 100644 index 0000000..f35eb64 --- /dev/null +++ b/tests/standalone/test_sign_message.py @@ -0,0 +1,67 @@ +"""SIGN_MESSAGE, which had no functional coverage at all before.""" + +import pytest +from ecdsa import SECP256k1, VerifyingKey +from ecdsa.util import sigdecode_string +from ragger.error import ExceptionRAPDU + +from application_client.handshake_command_sender import Errors, HandshakeCommandSender +from application_client.handshake_response_unpacker import ( + unpack_get_public_key_response, unpack_sign_message_response, +) +from application_client.handshake_sighash import blake2b_256 +from .review_nav import approve_review + +PATH = "m/44'/5353'/0'/0/0" + +#: The device prepends this before hashing, matching shd's `verifymessage`. +MESSAGE_PREFIX = b"handshake signed message:\n" + + +def test_sign_message_accepted(backend, device, navigator, default_screenshot_path, test_name): + client = HandshakeCommandSender(backend) + pubkey, _, _ = unpack_get_public_key_response(client.get_public_key(path=PATH).data) + message = b"hello handshake" + + with client.sign_message(path=PATH, message=message): + approve_review(device, navigator, default_screenshot_path, test_name) + + _, signature, returned_pubkey = unpack_sign_message_response( + client.get_async_response().data) + assert returned_pubkey == pubkey + + digest = blake2b_256(MESSAGE_PREFIX + message) + vk = VerifyingKey.from_string(pubkey, curve=SECP256k1) + assert vk.verify_digest(signature, digest, sigdecode=sigdecode_string) + + +def test_sign_message_refused(backend, scenario_navigator): + client = HandshakeCommandSender(backend) + with pytest.raises(ExceptionRAPDU) as e: + with client.sign_message(path=PATH, message=b"hello handshake"): + scenario_navigator.review_reject() + assert e.value.status == Errors.SW_DENY + + +def test_sign_message_rejects_out_of_policy_path(backend): + client = HandshakeCommandSender(backend) + with pytest.raises(ExceptionRAPDU) as e: + with client.sign_message(path="m/44'/1'/0'/0/0", message=b"hi"): + pass + assert e.value.status == Errors.SW_BAD_DERIVATION_PATH + + +def test_sign_message_rejects_truncated_payload(backend): + """A declared message length longer than the bytes that follow. + + `MAX_MESSAGE_LEN` is 512, but a single APDU carries at most 255 data bytes, + so the length cap is unreachable and the bounds check in the reader is what + actually protects here. + """ + from application_client.handshake_command_sender import CLA, InsType + from ragger.bip import pack_derivation_path + + payload = pack_derivation_path(PATH) + (200).to_bytes(2, "little") + b"A" * 10 + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=InsType.SIGN_MESSAGE, p1=0, p2=0, data=payload) + assert e.value.status == Errors.SW_TX_WRONG_LENGTH diff --git a/tests/standalone/test_sign_tx.py b/tests/standalone/test_sign_tx.py new file mode 100644 index 0000000..36821c0 --- /dev/null +++ b/tests/standalone/test_sign_tx.py @@ -0,0 +1,117 @@ +"""SIGN_TX end to end, including an independent check of what was signed.""" + +import pytest +from ecdsa import SECP256k1, VerifyingKey +from ecdsa.util import sigdecode_string +from ragger.error import ExceptionRAPDU + +from application_client.handshake_command_sender import Errors, HandshakeCommandSender +from application_client.handshake_response_unpacker import ( + unpack_get_public_key_response, unpack_sign_input_response, +) +from application_client.handshake_sighash import ( + p2wpkh_script_code, serialize_output, sighash_all, +) +from .review_nav import approve_review +from application_client.handshake_transaction import ( + COVENANT_OPEN, COVENANT_TRANSFER, SIGHASH_ALL, + begin_payload, input_payload, open_items, output_payload, transfer_items, +) + +PATH = "m/44'/5353'/0'/0/0" +COIN = 100_000_000 +TXID = b"\x11" * 32 +DEST = b"\x22" * 20 +CHANGE = b"\x33" * 20 + + +def _pubkey(client: HandshakeCommandSender) -> bytes: + pubkey, _, _ = unpack_get_public_key_response(client.get_public_key(path=PATH).data) + return pubkey + + +def test_sign_tx_accepted(backend, device, navigator, default_screenshot_path, test_name): + """Approve a two-output spend and verify the signature commits to exactly + the transaction the device was given.""" + client = HandshakeCommandSender(backend) + pubkey = _pubkey(client) + + outputs = [ + output_payload(60_000_000, addr_hash=DEST), + output_payload(39_000_000, addr_hash=CHANGE), + ] + + client.sign_tx_begin(begin_payload(n_in=1, n_out=2)) + client.sign_tx_add_input(input_payload(PATH, COIN, txid=TXID)) + for out in outputs: + client.sign_tx_add_output(out) + + with client.sign_tx_review(): + approve_review(device, navigator, default_screenshot_path, test_name) + + rapdu = client.sign_tx_input(0) + signature, sighash_type, returned_pubkey = unpack_sign_input_response(rapdu.data) + assert sighash_type == SIGHASH_ALL + assert returned_pubkey == pubkey + + digest = sighash_all( + version=0, + inputs=[(TXID, 0, 0xFFFFFFFF)], + outputs=[serialize_output(60_000_000, DEST), + serialize_output(39_000_000, CHANGE)], + locktime=0, + index=0, + script_code=p2wpkh_script_code(pubkey), + value=COIN, + ) + vk = VerifyingKey.from_string(pubkey, curve=SECP256k1) + assert vk.verify_digest(signature, digest, sigdecode=sigdecode_string) + + +def test_sign_tx_refused(backend, scenario_navigator): + client = HandshakeCommandSender(backend) + client.sign_tx_begin(begin_payload(n_in=1, n_out=1)) + client.sign_tx_add_input(input_payload(PATH, COIN, txid=TXID)) + client.sign_tx_add_output(output_payload(90_000_000, addr_hash=DEST)) + + with pytest.raises(ExceptionRAPDU) as e: + with client.sign_tx_review(): + scenario_navigator.review_reject() + assert e.value.status == Errors.SW_DENY + + # A refusal must leave no session behind for the host to sign against. + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_input(0) + assert e.value.status == Errors.SW_WRONG_STATE + + +def test_sign_tx_open_covenant(backend, device, navigator, default_screenshot_path, test_name): + """OPEN carries the plaintext name, which the device verifies against the + hash the covenant commits to before showing it.""" + client = HandshakeCommandSender(backend) + client.sign_tx_begin(begin_payload(n_in=1, n_out=1)) + client.sign_tx_add_input(input_payload(PATH, COIN, txid=TXID)) + client.sign_tx_add_output(output_payload( + 0, addr_hash=DEST, + covenant_kind=COVENANT_OPEN, covenant_items=open_items(b"alice"))) + + with client.sign_tx_review(): + approve_review(device, navigator, default_screenshot_path, test_name) + assert client.sign_tx_input(0).status == 0x9000 + + +def test_sign_tx_transfer_covenant(backend, device, navigator, default_screenshot_path, test_name): + """TRANSFER's destination is the only thing distinguishing a transfer to + yourself from one to a thief, and it lives in the covenant rather than in + the output address.""" + client = HandshakeCommandSender(backend) + client.sign_tx_begin(begin_payload(n_in=1, n_out=1)) + client.sign_tx_add_input(input_payload(PATH, COIN, txid=TXID)) + client.sign_tx_add_output(output_payload( + COIN // 2, addr_hash=CHANGE, + covenant_kind=COVENANT_TRANSFER, + covenant_items=transfer_items(b"alice", dest_hash=DEST))) + + with client.sign_tx_review(): + approve_review(device, navigator, default_screenshot_path, test_name) + assert client.sign_tx_input(0).status == 0x9000 diff --git a/tests/standalone/test_sign_tx_policy.py b/tests/standalone/test_sign_tx_policy.py new file mode 100644 index 0000000..1b81d1b --- /dev/null +++ b/tests/standalone/test_sign_tx_policy.py @@ -0,0 +1,234 @@ +"""What SIGN_TX refuses. + +Every case here is rejected before the device draws anything, so these run +without a navigator or golden snapshots. They are the regression tests for the +pre-ship review: each one corresponds to a way the review screen could have +shown the user something other than what the signature commits to. +""" + +import pytest +from ragger.error import ExceptionRAPDU + +from application_client.handshake_command_sender import ( + CLA, Errors, HandshakeCommandSender, InsType, P1, P2, +) +from application_client.handshake_transaction import ( + COVENANT_NONE, COVENANT_OPEN, COVENANT_REVOKE, + SIGHASH_ALL, SIGHASH_ANYONECANPAY, SIGHASH_NOINPUT, SIGHASH_NONE, + SIGHASH_SINGLE, begin_payload, input_payload, open_items, output_payload, +) + +PATH = "m/44'/5353'/0'/0/0" +TESTNET_PATH = "m/44'/5354'/0'/0/0" +COIN = 100_000_000 # 100 HNS in dollarydoos + + +def _begin(client: HandshakeCommandSender, n_in: int = 1, n_out: int = 1) -> None: + client.sign_tx_begin(begin_payload(n_in=n_in, n_out=n_out)) + + +# ── The blocker: a signature type the review screen cannot represent ──────── + +@pytest.mark.parametrize("sighash", [ + SIGHASH_NONE, + SIGHASH_SINGLE, + SIGHASH_NONE | SIGHASH_ANYONECANPAY, + SIGHASH_ALL | SIGHASH_NOINPUT, + SIGHASH_ALL | SIGHASH_ANYONECANPAY, + 0x00, + 0xFF, +]) +def test_sighash_must_be_all(backend, sighash): + """Anything but SIGHASH_ALL commits to fewer fields than the review shows. + + Under NONE the digest commits to no outputs; under NOINPUT it does not + commit to which coin is being spent. Either way the signature is a blank + cheque behind a screen that listed concrete outputs and a concrete fee. + """ + client = HandshakeCommandSender(backend) + _begin(client) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_add_input(input_payload(PATH, COIN, sighash=sighash)) + assert e.value.status == Errors.SW_TX_PARSING_FAIL + + +def test_second_input_sighash_must_be_all(backend): + """The original bug: only input 0's type reached the screen. + + Input 0 carries ALL, so the review screen would have shown nothing unusual, + while input 1 was signed under NONE|ANYONECANPAY. + """ + client = HandshakeCommandSender(backend) + _begin(client, n_in=2, n_out=1) + client.sign_tx_add_input(input_payload(PATH, COIN, sighash=SIGHASH_ALL)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_add_input(input_payload( + PATH, COIN, txid=b"\xaa" * 32, + sighash=SIGHASH_NONE | SIGHASH_ANYONECANPAY)) + assert e.value.status == Errors.SW_TX_PARSING_FAIL + + +# ── Derivation path policy ───────────────────────────────────────────────── + +@pytest.mark.parametrize("path", [ + "m/44'/1'/0'/0/0", # Bitcoin testnet coin type: not Handshake + "m/44'/5352'/0'/0/0", # just below the Handshake range + "m/44'/5357'/0'/0/0", # just above it + "m/43'/5353'/0'/0/0", # wrong purpose + "m/44'/5353'/0/0/0", # unhardened account + "m/44'/5353'", # too short to name an account + "m/44'/5353'/0'/0/0/0", # too deep +]) +def test_get_public_key_rejects_out_of_policy_path(backend, path): + """Out-of-whitelist paths do not merely fail in BOLOS, they terminate the + app. Validating in-app turns that into an ordinary status word.""" + client = HandshakeCommandSender(backend) + with pytest.raises(ExceptionRAPDU) as e: + client.get_public_key(path=path) + assert e.value.status == Errors.SW_BAD_DERIVATION_PATH + + +def test_sign_input_rejects_out_of_policy_path(backend): + client = HandshakeCommandSender(backend) + _begin(client) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_add_input(input_payload("m/44'/1'/0'/0/0", COIN)) + assert e.value.status == Errors.SW_BAD_DERIVATION_PATH + + +def test_mixed_coin_types_rejected(backend): + """One network per session: the review renders every address under a single + HRP, so a mixed session would show mainnet destinations wearing a testnet + prefix.""" + client = HandshakeCommandSender(backend) + _begin(client, n_in=2, n_out=1) + client.sign_tx_add_input(input_payload(PATH, COIN)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_add_input(input_payload(TESTNET_PATH, COIN, txid=b"\xaa" * 32)) + assert e.value.status == Errors.SW_BAD_DERIVATION_PATH + + +# ── Session ceilings: one APDU must not be able to exhaust the heap ──────── + +@pytest.mark.parametrize("n_in,n_out", [(0, 1), (17, 1), (1, 9), (255, 255)]) +def test_begin_rejects_out_of_range_counts(backend, n_in, n_out): + client = HandshakeCommandSender(backend) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_begin(begin_payload(n_in=n_in, n_out=n_out)) + assert e.value.status == Errors.SW_TX_PARSING_FAIL + + +def test_covenant_byte_budget_is_session_wide(backend): + """Budgeted across the session, not per output, so several large covenants + cannot add up to more than the heap holds.""" + client = HandshakeCommandSender(backend) + _begin(client, n_in=1, n_out=8) + client.sign_tx_add_input(input_payload(PATH, COIN)) + with pytest.raises(ExceptionRAPDU) as e: + for _ in range(8): + client.sign_tx_add_output(output_payload( + COIN, covenant_kind=COVENANT_REVOKE, + covenant_items=[b"\x55" * 32, b"\x00" * 4, b"\x66" * 200])) + assert e.value.status == Errors.SW_TX_WRONG_LENGTH + + +# ── Covenants the device cannot describe honestly ────────────────────────── + +@pytest.mark.parametrize("kind", [12, 13, 200, 255]) +def test_unknown_covenant_kind_rejected(backend, kind): + """An unknown kind would render as the word "UNKNOWN" while its bytes went + into the sighash verbatim.""" + client = HandshakeCommandSender(backend) + _begin(client) + client.sign_tx_add_input(input_payload(PATH, COIN)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_add_output(output_payload( + COIN, covenant_kind=kind, covenant_items=[b"\x77" * 32])) + assert e.value.status == Errors.SW_TX_PARSING_FAIL + + +def test_covenant_none_with_items_rejected(backend): + """Kind 0 means "no covenant". Items alongside it would be committed to the + sighash while the screen read "none".""" + client = HandshakeCommandSender(backend) + _begin(client) + client.sign_tx_add_input(input_payload(PATH, COIN)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_add_output(output_payload( + COIN, covenant_kind=COVENANT_NONE, covenant_items=[b"\x88" * 8])) + assert e.value.status == Errors.SW_TX_PARSING_FAIL + + +# ── Value arithmetic the screen would otherwise misreport ────────────────── + +def test_outputs_exceeding_inputs_rejected(backend): + client = HandshakeCommandSender(backend) + _begin(client) + client.sign_tx_add_input(input_payload(PATH, COIN)) + client.sign_tx_add_output(output_payload(COIN * 2)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_review_sync() + assert e.value.status == Errors.SW_TX_PARSING_FAIL + + +def test_input_total_overflow_rejected(backend): + """Declared input values that sum past u64 used to truncate on the way to + the screen, so an arbitrary fee could be displayed as 0.""" + client = HandshakeCommandSender(backend) + _begin(client, n_in=2, n_out=1) + client.sign_tx_add_input(input_payload(PATH, 2**63)) + client.sign_tx_add_input(input_payload(PATH, 2**63, txid=b"\xaa" * 32)) + client.sign_tx_add_output(output_payload(COIN)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_review_sync() + assert e.value.status == Errors.SW_TX_PARSING_FAIL + + +# ── State machine ordering ───────────────────────────────────────────────── + +def test_add_input_before_begin_rejected(backend): + client = HandshakeCommandSender(backend) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_add_input(input_payload(PATH, COIN)) + assert e.value.status == Errors.SW_WRONG_STATE + + +def test_review_before_all_records_rejected(backend): + client = HandshakeCommandSender(backend) + _begin(client, n_in=2, n_out=1) + client.sign_tx_add_input(input_payload(PATH, COIN)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_review_sync() + assert e.value.status == Errors.SW_WRONG_STATE + + +def test_sign_input_before_review_rejected(backend): + """No signature without an approval, whatever order the host asks in.""" + client = HandshakeCommandSender(backend) + _begin(client) + client.sign_tx_add_input(input_payload(PATH, COIN)) + client.sign_tx_add_output(output_payload(COIN)) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_input(0) + assert e.value.status == Errors.SW_WRONG_STATE + + +def test_begin_resets_a_stale_session(backend): + """A fresh BEGIN must clear whatever a previous attempt left behind.""" + client = HandshakeCommandSender(backend) + _begin(client, n_in=2, n_out=1) + client.sign_tx_add_input(input_payload(PATH, COIN)) + _begin(client, n_in=1, n_out=1) + client.sign_tx_add_input(input_payload(PATH, COIN)) + client.sign_tx_add_output(output_payload(COIN)) + # Reaching PendingReview means the earlier input did not survive. + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_input(0) + assert e.value.status == Errors.SW_WRONG_STATE + + +def test_trailing_bytes_rejected(backend): + client = HandshakeCommandSender(backend) + with pytest.raises(ExceptionRAPDU) as e: + client.sign_tx_begin(begin_payload(n_in=1, n_out=1) + b"\x00") + assert e.value.status == Errors.SW_TX_WRONG_LENGTH diff --git a/tests/standalone/test_version_cmd.py b/tests/standalone/test_version_cmd.py new file mode 100644 index 0000000..28d5be0 --- /dev/null +++ b/tests/standalone/test_version_cmd.py @@ -0,0 +1,10 @@ +from application_client.handshake_command_sender import HandshakeCommandSender +from application_client.handshake_response_unpacker import unpack_get_version_response + + +def test_version(backend): + """GET_VERSION must report the version in Cargo.toml.""" + client = HandshakeCommandSender(backend) + rapdu = client.get_version() + major, minor, patch = unpack_get_version_response(rapdu.data) + assert (major, minor, patch) == (0, 1, 0)