Handshake Ledger app
Some checks failed
Checks on the Python tests / Call Ledger Python linters (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Build application using the reusable workflow (push) Has been cancelled
Run coding style check / Check linting using the reusable workflow (push) Has been cancelled
Ensure compliance with Ledger guidelines / Call Ledger guidelines_enforcer (push) Has been cancelled
Misspellings checks / Check misspellings (push) Has been cancelled
Build and run functional tests using ragger through reusable workflow / Run standalone ragger tests using the reusable workflow (push) Has been cancelled
Hardware-wallet app for Handshake (HNS). Derives Handshake addresses and signs
Handshake transactions, including name-auction covenants, on Ledger Stax, Flex,
Nano S+, Nano X and Apex+.
The design premise is that the host computer is untrusted: every APDU byte is
attacker-controlled, and the device's job is to be correct when the host lies.
Two failures outrank all others, and the code is shaped around them.
Signing something other than what the user approved. Any field the signature
commits to and the host can vary has to appear on the review screen, and
anything the screen cannot represent honestly is refused rather than shown under
a label that does not describe it. So the device signs SIGHASH_ALL only,
checked per input rather than once for the session; it displays a TRANSFER
covenant's destination, which decides who ends up owning the name and appears
nowhere else in the output; it shows a covenant's name hash when the plaintext
name cannot be verified against it; and it refuses covenant kinds it cannot
name, outputs whose values exceed their inputs, and declared input totals that
would overflow the fee arithmetic.
Misusing key material. Derivation paths are constrained to
m/44'/{5353..5356}'/account'[/change/index] in the app itself, not only by the
install manifest, because BOLOS terminates the app on an out-of-whitelist path
instead of returning an error. One coin type per signing session, since the
review renders every address under a single HRP.
Panics count too: set_panic!(exiting_panic) means a panic kills the app and
strands the session, so nothing unwraps on host-derived data and the session
ceilings are sized to the configured heap.
Consensus follows shd, the Swift Handshake node, which is the reference for the
sighash preimage, covenant item layouts and address encoding.
SIGN_TX is a PSBT-style state machine driven by P1 (BEGIN / ADD_INPUT /
ADD_OUTPUT / REVIEW / SIGN_INPUT): the host streams the transaction as
structured records, the device accumulates its own view of the values, shows one
review, then signs each input on demand.
Tests run under speculos in a container (./scripts/test), 66 of them, green on
all five devices. test_sign_tx_policy.py covers each refusal above and needs no
golden snapshots because every case is rejected before anything is drawn; the
snapshots that do exist are the record of what the user sees before approving.
tests/application_client/handshake_sighash.py is a deliberately independent
reimplementation of the sighash, used to verify real signatures.
Status: v0.1, pre-audit. Not for mainnet keys. Signing has not yet been
exercised on physical hardware.
Forked from LedgerHQ/app-boilerplate-rust (Apache-2.0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13
tests/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# Test runner: Ledger's dev-tools image (speculos) plus ragger.
|
||||
#
|
||||
# The upstream image ships speculos but not ragger, and its system Python is
|
||||
# PEP-668 managed, so ragger goes into the same venv speculos lives in.
|
||||
#
|
||||
# Built and run by ../scripts/test.
|
||||
FROM ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools:latest
|
||||
|
||||
COPY standalone/requirements.txt /tmp/requirements.txt
|
||||
RUN /opt/venv/bin/pip install --no-cache-dir -r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
ENV PATH="/opt/venv/bin:${PATH}"
|
||||
45
tests/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Functional tests
|
||||
|
||||
`application_client/` is the Python client: APDU encoding, transaction and
|
||||
covenant builders, response unpackers, and an independent reimplementation of
|
||||
the Handshake sighash used to cross-check the device's signatures.
|
||||
|
||||
`standalone/` holds the tests, run against the app started from the device
|
||||
dashboard. There is no swap test directory: swap is not implemented (see
|
||||
`src/swap.rs`), and `handler_sign_review` refuses outright when swap parameters
|
||||
are present rather than auto-approving.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
../scripts/test # stax
|
||||
../scripts/test nanosp # or flex / nanox / apex_p
|
||||
```
|
||||
|
||||
Speculos does not run natively on macOS, so `scripts/test` runs it and ragger in
|
||||
a container built from `Dockerfile` here, on top of Ledger's dev-tools image.
|
||||
That image ships speculos but not ragger, and its system Python is PEP-668
|
||||
managed, so ragger is installed into the same venv speculos lives in.
|
||||
|
||||
All five devices pass. To run pytest directly instead (on Linux, with ragger
|
||||
installed):
|
||||
|
||||
```sh
|
||||
pip install -r standalone/requirements.txt
|
||||
pytest standalone --tb=short -v --device stax
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
|
||||
UI tests compare against golden PNGs under
|
||||
`standalone/snapshots/<device>/<test_name>/`. Regenerate with
|
||||
`../scripts/test <device> --golden_run`, or through the "Build and run
|
||||
functional tests" workflow with `golden_run` set to "Open a PR". Read the diff
|
||||
before committing one: those images are the record of what the user is shown
|
||||
before they approve a signature.
|
||||
|
||||
`standalone/test_sign_tx_policy.py` deliberately needs none of them: every case
|
||||
there is refused before the device draws anything.
|
||||
|
||||
`standalone/review_nav.py` explains why the approval path drives the navigator
|
||||
directly instead of using `scenario_navigator.review_approve()`.
|
||||
16
tests/application_client/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Application client
|
||||
|
||||
A small Python client for the Handshake Ledger app: enough to send APDUs,
|
||||
build the records `SIGN_TX` streams, and decode what comes back.
|
||||
|
||||
- `handshake_command_sender.py`: APDU encoding and the `SIGN_TX` state machine
|
||||
(BEGIN / ADD_INPUT / ADD_OUTPUT / REVIEW / SIGN_INPUT), plus the status words
|
||||
from `src/main.rs::AppSW`.
|
||||
- `handshake_transaction.py`: payload builders and the covenant item layouts
|
||||
from `shd/Sources/Covenants/CovenantData.swift`.
|
||||
- `handshake_response_unpacker.py`: response decoding. Note the app returns
|
||||
**compressed** 33-byte public keys.
|
||||
- `handshake_sighash.py`: an independent BIP-143-style sighash over BLAKE2b-256,
|
||||
written separately from the device's implementation so that a test failure
|
||||
means the two disagree rather than that one implementation is self-consistent.
|
||||
- `py.typed`: marker for type checkers.
|
||||
0
tests/application_client/__init__.py
Normal file
150
tests/application_client/handshake_command_sender.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""APDU client for the Handshake Ledger app.
|
||||
|
||||
Speaks the protocol the app actually implements: `SIGN_TX` is a state machine
|
||||
selected by P1 (BEGIN / ADD_INPUT / ADD_OUTPUT / REVIEW / SIGN_INPUT), not the
|
||||
chunked single-shot flow the Ledger boilerplate uses. See
|
||||
`src/handlers/sign_tx.rs` and `scripts/sign-tx`.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from enum import IntEnum
|
||||
from typing import Generator, List, Optional
|
||||
|
||||
from ragger.backend.interface import BackendInterface, RAPDU
|
||||
from ragger.bip import pack_derivation_path
|
||||
|
||||
CLA: int = 0xE0
|
||||
|
||||
#: Data bytes per APDU. ADD_OUTPUT payloads above this are chunked with P2_MORE.
|
||||
MAX_APDU_DATA: int = 240
|
||||
|
||||
|
||||
class InsType(IntEnum):
|
||||
GET_VERSION = 0x03
|
||||
GET_APP_NAME = 0x04
|
||||
GET_PUBLIC_KEY = 0x05
|
||||
SIGN_TX = 0x06
|
||||
SIGN_MESSAGE = 0x07
|
||||
|
||||
|
||||
class P1(IntEnum):
|
||||
NONE = 0x00
|
||||
#: GET_PUBLIC_KEY: show the address on-device before replying.
|
||||
CONFIRM = 0x01
|
||||
#: SIGN_TX sub-ops.
|
||||
SIGN_BEGIN = 0x00
|
||||
SIGN_ADD_INPUT = 0x01
|
||||
SIGN_ADD_OUTPUT = 0x02
|
||||
SIGN_REVIEW = 0x03
|
||||
SIGN_INPUT = 0x04
|
||||
|
||||
|
||||
class P2(IntEnum):
|
||||
LAST = 0x00
|
||||
MORE = 0x80
|
||||
|
||||
|
||||
class Errors(IntEnum):
|
||||
"""Mirrors `AppSW` in src/main.rs."""
|
||||
|
||||
SW_DENY = 0x6985
|
||||
SW_WRONG_P1P2 = 0x6A86
|
||||
SW_WRONG_STATE = 0x6B00
|
||||
SW_INS_NOT_SUPPORTED = 0x6D00
|
||||
SW_CLA_NOT_SUPPORTED = 0x6E00
|
||||
SW_COMM_ERROR = 0x6F00
|
||||
SW_TX_DISPLAY_FAIL = 0xB001
|
||||
SW_ADDR_DISPLAY_FAIL = 0xB002
|
||||
SW_TX_WRONG_LENGTH = 0xB004
|
||||
SW_TX_PARSING_FAIL = 0xB005
|
||||
SW_TX_HASH_FAIL = 0xB006
|
||||
SW_TX_SIGN_FAIL = 0xB008
|
||||
SW_KEY_DERIVE_FAIL = 0xB009
|
||||
SW_VERSION_PARSING_FAIL = 0xB00A
|
||||
SW_BAD_DERIVATION_PATH = 0xB00B
|
||||
SW_SWAP_FAIL = 0xC000
|
||||
|
||||
|
||||
def split_message(message: bytes, max_size: int) -> List[bytes]:
|
||||
return [message[x:x + max_size] for x in range(0, len(message), max_size)]
|
||||
|
||||
|
||||
class HandshakeCommandSender:
|
||||
def __init__(self, backend: BackendInterface) -> None:
|
||||
self.backend = backend
|
||||
|
||||
# ── Simple queries ─────────────────────────────────────────────────────
|
||||
|
||||
def get_version(self) -> RAPDU:
|
||||
return self.backend.exchange(cla=CLA, ins=InsType.GET_VERSION,
|
||||
p1=P1.NONE, p2=P2.LAST, data=b"")
|
||||
|
||||
def get_app_name(self) -> RAPDU:
|
||||
return self.backend.exchange(cla=CLA, ins=InsType.GET_APP_NAME,
|
||||
p1=P1.NONE, p2=P2.LAST, data=b"")
|
||||
|
||||
def get_public_key(self, path: str) -> RAPDU:
|
||||
return self.backend.exchange(cla=CLA, ins=InsType.GET_PUBLIC_KEY,
|
||||
p1=P1.NONE, p2=P2.LAST,
|
||||
data=pack_derivation_path(path))
|
||||
|
||||
@contextmanager
|
||||
def get_public_key_with_confirmation(self, path: str) -> Generator[None, None, None]:
|
||||
with self.backend.exchange_async(cla=CLA, ins=InsType.GET_PUBLIC_KEY,
|
||||
p1=P1.CONFIRM, p2=P2.LAST,
|
||||
data=pack_derivation_path(path)) as response:
|
||||
yield response
|
||||
|
||||
# ── SIGN_TX state machine ──────────────────────────────────────────────
|
||||
|
||||
def sign_tx_begin(self, payload: bytes) -> RAPDU:
|
||||
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
|
||||
p1=P1.SIGN_BEGIN, p2=P2.LAST, data=payload)
|
||||
|
||||
def sign_tx_add_input(self, payload: bytes) -> RAPDU:
|
||||
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
|
||||
p1=P1.SIGN_ADD_INPUT, p2=P2.LAST, data=payload)
|
||||
|
||||
def sign_tx_add_output(self, payload: bytes) -> RAPDU:
|
||||
"""Send one output, chunking it across APDUs if needed."""
|
||||
chunks = split_message(payload, MAX_APDU_DATA) or [b""]
|
||||
rapdu = None
|
||||
for i, chunk in enumerate(chunks):
|
||||
p2 = P2.LAST if i == len(chunks) - 1 else P2.MORE
|
||||
rapdu = self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
|
||||
p1=P1.SIGN_ADD_OUTPUT, p2=p2, data=chunk)
|
||||
return rapdu
|
||||
|
||||
def sign_tx_review_sync(self) -> RAPDU:
|
||||
"""REVIEW without a navigator: only for cases the device rejects
|
||||
before it puts anything on screen."""
|
||||
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
|
||||
p1=P1.SIGN_REVIEW, p2=P2.LAST, data=b"")
|
||||
|
||||
@contextmanager
|
||||
def sign_tx_review(self) -> Generator[None, None, None]:
|
||||
"""REVIEW: blocks on the user, so the caller drives the navigator."""
|
||||
with self.backend.exchange_async(cla=CLA, ins=InsType.SIGN_TX,
|
||||
p1=P1.SIGN_REVIEW, p2=P2.LAST,
|
||||
data=b"") as response:
|
||||
yield response
|
||||
|
||||
def sign_tx_input(self, index: int) -> RAPDU:
|
||||
return self.backend.exchange(cla=CLA, ins=InsType.SIGN_TX,
|
||||
p1=P1.SIGN_INPUT, p2=P2.LAST,
|
||||
data=index.to_bytes(4, "big"))
|
||||
|
||||
# ── SIGN_MESSAGE ───────────────────────────────────────────────────────
|
||||
|
||||
@contextmanager
|
||||
def sign_message(self, path: str, message: bytes) -> Generator[None, None, None]:
|
||||
payload = (pack_derivation_path(path)
|
||||
+ len(message).to_bytes(2, "little")
|
||||
+ message)
|
||||
with self.backend.exchange_async(cla=CLA, ins=InsType.SIGN_MESSAGE,
|
||||
p1=P1.NONE, p2=P2.LAST,
|
||||
data=payload) as response:
|
||||
yield response
|
||||
|
||||
def get_async_response(self) -> Optional[RAPDU]:
|
||||
return self.backend.last_async_response
|
||||
63
tests/application_client/handshake_response_unpacker.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Response unpackers for the Handshake Ledger app.
|
||||
|
||||
The app returns COMPRESSED (33-byte) secp256k1 public keys, unlike the Ledger
|
||||
boilerplate, which returns the 65-byte uncompressed form.
|
||||
"""
|
||||
|
||||
from struct import unpack
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def _pop_sized(buffer: bytes, size: int) -> Tuple[bytes, bytes]:
|
||||
return buffer[size:], buffer[0:size]
|
||||
|
||||
|
||||
def _pop_len_prefixed(buffer: bytes) -> Tuple[bytes, bytes]:
|
||||
n = buffer[0]
|
||||
return buffer[1 + n:], buffer[1:n + 1]
|
||||
|
||||
|
||||
def compress_pubkey(uncompressed: bytes) -> bytes:
|
||||
"""65-byte `04 || X || Y` to 33-byte `02|03 || X`."""
|
||||
assert len(uncompressed) == 65 and uncompressed[0] == 0x04
|
||||
prefix = 0x02 if uncompressed[64] % 2 == 0 else 0x03
|
||||
return bytes([prefix]) + uncompressed[1:33]
|
||||
|
||||
|
||||
def unpack_get_app_name_response(response: bytes) -> str:
|
||||
return response.decode("ascii")
|
||||
|
||||
|
||||
def unpack_get_version_response(response: bytes) -> Tuple[int, int, int]:
|
||||
assert len(response) == 3
|
||||
major, minor, patch = unpack("BBB", response)
|
||||
return (major, minor, patch)
|
||||
|
||||
|
||||
def unpack_get_public_key_response(response: bytes) -> Tuple[bytes, bytes, str]:
|
||||
"""`pk_len(1) || pk(33) || cc_len(1) || cc(32) || addr_len(1) || addr`."""
|
||||
response, pubkey = _pop_len_prefixed(response)
|
||||
response, chaincode = _pop_len_prefixed(response)
|
||||
response, address = _pop_len_prefixed(response)
|
||||
|
||||
assert len(pubkey) == 33, f"expected a compressed pubkey, got {len(pubkey)} bytes"
|
||||
assert len(chaincode) == 32
|
||||
assert len(response) == 0
|
||||
return pubkey, chaincode, address.decode("ascii")
|
||||
|
||||
|
||||
def unpack_sign_input_response(response: bytes) -> Tuple[bytes, int, bytes]:
|
||||
"""`sig(64) || sighash_type(1) || pk_len(1) || pk(33)`."""
|
||||
response, signature = _pop_sized(response, 64)
|
||||
response, sighash = _pop_sized(response, 1)
|
||||
response, pubkey = _pop_len_prefixed(response)
|
||||
|
||||
assert len(pubkey) == 33
|
||||
assert len(response) == 0
|
||||
return signature, sighash[0], pubkey
|
||||
|
||||
|
||||
def unpack_sign_message_response(response: bytes) -> Tuple[int, bytes, bytes]:
|
||||
"""`recovery_id(1) || sig(64) || pk(33)`, 98 bytes."""
|
||||
assert len(response) == 98, f"expected 98 bytes, got {len(response)}"
|
||||
return response[0], response[1:65], response[65:]
|
||||
85
tests/application_client/handshake_sighash.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""An independent BIP-143-style sighash for Handshake, for cross-checking the
|
||||
device's signatures.
|
||||
|
||||
Deliberately a second implementation rather than a port of the device's: a test
|
||||
that reuses the code under test proves only that it is self-consistent. Field
|
||||
order and lengths follow `shd/Sources/Script/SigHash.swift::compute`.
|
||||
"""
|
||||
|
||||
from hashlib import blake2b
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
|
||||
def encode_varint(n: int) -> bytes:
|
||||
"""Deliberately duplicated rather than imported: this module is meant to be
|
||||
an independent check, and it keeps the cross-check importable without ragger."""
|
||||
if n < 0xFD:
|
||||
return bytes([n])
|
||||
if n <= 0xFFFF:
|
||||
return b"\xfd" + n.to_bytes(2, "little")
|
||||
if n <= 0xFFFFFFFF:
|
||||
return b"\xfe" + n.to_bytes(4, "little")
|
||||
return b"\xff" + n.to_bytes(8, "little")
|
||||
|
||||
|
||||
def blake2b_256(data: bytes) -> bytes:
|
||||
return blake2b(data, digest_size=32).digest()
|
||||
|
||||
|
||||
def blake2b_160(data: bytes) -> bytes:
|
||||
return blake2b(data, digest_size=20).digest()
|
||||
|
||||
|
||||
def p2wpkh_script_code(compressed_pubkey: bytes) -> bytes:
|
||||
"""OP_DUP OP_BLAKE160 <push20> <hash> OP_EQUALVERIFY OP_CHECKSIG.
|
||||
|
||||
Handshake uses OP_BLAKE160 (0xc0) where Bitcoin uses OP_HASH160 (0xa9).
|
||||
"""
|
||||
return (b"\x76\xc0\x14"
|
||||
+ blake2b_160(compressed_pubkey)
|
||||
+ b"\x88\xac")
|
||||
|
||||
|
||||
def serialize_output(value: int,
|
||||
addr_hash: bytes,
|
||||
addr_version: int = 0,
|
||||
covenant_kind: int = 0,
|
||||
covenant_items: Sequence[bytes] = ()) -> bytes:
|
||||
items = b"".join(encode_varint(len(i)) + i for i in covenant_items)
|
||||
return (value.to_bytes(8, "little")
|
||||
+ bytes([addr_version, len(addr_hash)])
|
||||
+ addr_hash
|
||||
+ bytes([covenant_kind])
|
||||
+ encode_varint(len(covenant_items))
|
||||
+ items)
|
||||
|
||||
|
||||
def sighash_all(version: int,
|
||||
inputs: List[Tuple[bytes, int, int]],
|
||||
outputs: List[bytes],
|
||||
locktime: int,
|
||||
index: int,
|
||||
script_code: bytes,
|
||||
value: int) -> bytes:
|
||||
"""SIGHASH_ALL only, which is the one type the device signs.
|
||||
|
||||
`inputs` are (txid, vout, sequence); `outputs` are pre-serialized.
|
||||
"""
|
||||
hash_prevouts = blake2b_256(b"".join(
|
||||
txid + vout.to_bytes(4, "little") for txid, vout, _ in inputs))
|
||||
hash_sequence = blake2b_256(b"".join(
|
||||
seq.to_bytes(4, "little") for _, _, seq in inputs))
|
||||
hash_outputs = blake2b_256(b"".join(outputs))
|
||||
|
||||
txid, vout, sequence = inputs[index]
|
||||
preimage = (version.to_bytes(4, "little")
|
||||
+ hash_prevouts
|
||||
+ hash_sequence
|
||||
+ txid + vout.to_bytes(4, "little")
|
||||
+ encode_varint(len(script_code)) + script_code
|
||||
+ value.to_bytes(8, "little")
|
||||
+ sequence.to_bytes(4, "little")
|
||||
+ hash_outputs
|
||||
+ locktime.to_bytes(4, "little")
|
||||
+ (0x01).to_bytes(4, "little"))
|
||||
return blake2b_256(preimage)
|
||||
118
tests/application_client/handshake_transaction.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Builders for the structured records SIGN_TX streams to the device.
|
||||
|
||||
Wire layouts are documented at the top of `src/handlers/sign_tx.rs`; the
|
||||
covenant item layouts mirror `shd/Sources/Covenants/CovenantData.swift`.
|
||||
"""
|
||||
|
||||
from hashlib import sha3_256
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
from ragger.bip import pack_derivation_path
|
||||
|
||||
SCRIPT_KIND_P2WPKH: int = 0x00
|
||||
SCRIPT_KIND_P2WSH: int = 0x01
|
||||
|
||||
SIGHASH_ALL: int = 0x01
|
||||
SIGHASH_NONE: int = 0x02
|
||||
SIGHASH_SINGLE: int = 0x03
|
||||
SIGHASH_SINGLEREVERSE: int = 0x04
|
||||
SIGHASH_NOINPUT: int = 0x40
|
||||
SIGHASH_ANYONECANPAY: int = 0x80
|
||||
|
||||
# Covenant types, matching shd/Sources/Protocol/CovenantType.swift.
|
||||
COVENANT_NONE: int = 0
|
||||
COVENANT_CLAIM: int = 1
|
||||
COVENANT_OPEN: int = 2
|
||||
COVENANT_BID: int = 3
|
||||
COVENANT_REVEAL: int = 4
|
||||
COVENANT_REDEEM: int = 5
|
||||
COVENANT_REGISTER: int = 6
|
||||
COVENANT_UPDATE: int = 7
|
||||
COVENANT_RENEW: int = 8
|
||||
COVENANT_TRANSFER: int = 9
|
||||
COVENANT_FINALIZE: int = 10
|
||||
COVENANT_REVOKE: int = 11
|
||||
|
||||
|
||||
def encode_varint(n: int) -> bytes:
|
||||
if n < 0xFD:
|
||||
return bytes([n])
|
||||
if n <= 0xFFFF:
|
||||
return b"\xfd" + n.to_bytes(2, "little")
|
||||
if n <= 0xFFFFFFFF:
|
||||
return b"\xfe" + n.to_bytes(4, "little")
|
||||
return b"\xff" + n.to_bytes(8, "little")
|
||||
|
||||
|
||||
def name_hash(name: bytes) -> bytes:
|
||||
"""`shd` NameRules.hashName: SHA3-256, not Keccak."""
|
||||
return sha3_256(name).digest()
|
||||
|
||||
|
||||
def begin_payload(n_in: int, n_out: int, version: int = 0, locktime: int = 0) -> bytes:
|
||||
return (version.to_bytes(4, "little")
|
||||
+ encode_varint(n_in)
|
||||
+ encode_varint(n_out)
|
||||
+ locktime.to_bytes(4, "little"))
|
||||
|
||||
|
||||
def input_payload(path: str,
|
||||
value: int,
|
||||
txid: bytes = b"\x11" * 32,
|
||||
vout: int = 0,
|
||||
sequence: int = 0xFFFFFFFF,
|
||||
sighash: int = SIGHASH_ALL,
|
||||
script_kind: int = SCRIPT_KIND_P2WPKH) -> bytes:
|
||||
assert len(txid) == 32
|
||||
return (txid
|
||||
+ vout.to_bytes(4, "little")
|
||||
+ sequence.to_bytes(4, "little")
|
||||
+ value.to_bytes(8, "little")
|
||||
+ bytes([sighash])
|
||||
+ pack_derivation_path(path)
|
||||
+ bytes([script_kind]))
|
||||
|
||||
|
||||
def output_payload(value: int,
|
||||
addr_hash: bytes = b"\x22" * 20,
|
||||
addr_version: int = 0,
|
||||
covenant_kind: int = COVENANT_NONE,
|
||||
covenant_items: Sequence[bytes] = ()) -> bytes:
|
||||
items = b"".join(encode_varint(len(i)) + i for i in covenant_items)
|
||||
return (value.to_bytes(8, "little")
|
||||
+ bytes([addr_version, len(addr_hash)])
|
||||
+ addr_hash
|
||||
+ bytes([covenant_kind])
|
||||
+ encode_varint(len(covenant_items))
|
||||
+ items)
|
||||
|
||||
|
||||
def uint32_le(n: int) -> bytes:
|
||||
return n.to_bytes(4, "little")
|
||||
|
||||
|
||||
def open_items(name: bytes) -> List[bytes]:
|
||||
"""OPEN: [nameHash, height, name] (CovenantData.makeOpen)."""
|
||||
return [name_hash(name), uint32_le(0), name]
|
||||
|
||||
|
||||
def bid_items(name: bytes, height: int = 0, blind: bytes = b"\x33" * 32) -> List[bytes]:
|
||||
"""BID: [nameHash, startHeight, name, blind] (CovenantData.makeBid)."""
|
||||
return [name_hash(name), uint32_le(height), name, blind]
|
||||
|
||||
|
||||
def reveal_items(name: bytes, height: int = 0, nonce: bytes = b"\x44" * 32) -> List[bytes]:
|
||||
"""REVEAL: [nameHash, startHeight, nonce]. Carries no plaintext name."""
|
||||
return [name_hash(name), uint32_le(height), nonce]
|
||||
|
||||
|
||||
def transfer_items(name: bytes,
|
||||
dest_hash: bytes,
|
||||
height: int = 0,
|
||||
dest_version: int = 0) -> List[bytes]:
|
||||
"""TRANSFER: [nameHash, startHeight, [version], addressHash].
|
||||
|
||||
`items[3]` is the address the name is handed to, which is the whole point
|
||||
of the covenant and appears nowhere else in the output.
|
||||
"""
|
||||
return [name_hash(name), uint32_le(height), bytes([dest_version]), dest_hash]
|
||||
0
tests/application_client/py.typed
Normal file
28
tests/setup.cfg
Normal file
@@ -0,0 +1,28 @@
|
||||
[tool:pytest]
|
||||
addopts = --strict-markers
|
||||
|
||||
[pylint]
|
||||
disable = C0114, # missing-module-docstring
|
||||
C0115, # missing-class-docstring
|
||||
C0116, # missing-function-docstring
|
||||
C0103, # invalid-name
|
||||
C0411, # wrong-import-order
|
||||
C0301, # line-too-long
|
||||
C0303, # trailing-whitespace
|
||||
C0415, # import-outside-toplevel
|
||||
R0801, # duplicate-code
|
||||
R0903, # too-few-public-methods
|
||||
R0913, # too-many-arguments
|
||||
W0511, # fixme (TODO comments)
|
||||
W0611, # unused-import
|
||||
W0612, # unused-variable
|
||||
W0613, # unused-argument
|
||||
W0621, # redefined-outer-name
|
||||
W1309, # f-string-without-interpolation
|
||||
W1510, # subprocess-run-check
|
||||
E0401 # import-error
|
||||
max-line-length=100
|
||||
extension-pkg-whitelist=hid
|
||||
|
||||
[pycodestyle]
|
||||
max-line-length = 100
|
||||
35
tests/standalone/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Standalone Functional Tests
|
||||
|
||||
This directory contains the **standalone functional test suite** for the Ledger application.
|
||||
It is intended to validate the 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
|
||||
```
|
||||
0
tests/standalone/__init__.py
Normal file
31
tests/standalone/conftest.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from ragger.conftest import configuration
|
||||
from ragger.navigator import NavInsID
|
||||
import pytest
|
||||
###########################
|
||||
### CONFIGURATION START ###
|
||||
###########################
|
||||
|
||||
# You can configure optional parameters by overriding the value of ragger.configuration.OPTIONAL_CONFIGURATION
|
||||
# Please refer to ragger/conftest/configuration.py for their descriptions and accepted values
|
||||
|
||||
#########################
|
||||
### CONFIGURATION END ###
|
||||
#########################
|
||||
|
||||
# Pull all features from the base ragger conftest using the overridden configuration
|
||||
pytest_plugins = ("ragger.conftest.base_conftest", )
|
||||
|
||||
# Notes :
|
||||
# 1. Remove this fixture once the pending review screen is removed from the app
|
||||
# 2. This fixture clears the pending review screen before each test
|
||||
# 3. The scope should be the same as the one configured by BACKEND_SCOPE in
|
||||
# ragger/conftest/configuration.py
|
||||
# @pytest.fixture(scope="class", autouse=True)
|
||||
# def clear_pending_review(firmware, navigator):
|
||||
# # Press a button to clear the pending review
|
||||
# if firmware.device.startswith("nano"):
|
||||
# print("Clearing pending review")
|
||||
# instructions = [
|
||||
# NavInsID.BOTH_CLICK,
|
||||
# ]
|
||||
# navigator.navigate(instructions,screen_change_before_first_instruction=False)
|
||||
4
tests/standalone/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
pytest
|
||||
ragger[speculos,ledgerwallet]>=1.21.1
|
||||
ecdsa>=0.16.1,<0.17.0
|
||||
tomli>=2.0.1
|
||||
37
tests/standalone/review_nav.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Review navigation that does not assume a post-approval status modal.
|
||||
|
||||
`scenario_navigator.review_approve()` appends `USE_CASE_STATUS_DISMISS` on touch
|
||||
devices, expecting an `NbglReviewStatus` screen after the confirm. This app
|
||||
deliberately returns straight to its home screen instead: over BLE that screen's
|
||||
sync wait can fail to auto-dismiss and block the next APDU, and the wallet shows
|
||||
its own success UI once the broadcast completes. See
|
||||
`show_status_and_home_if_needed` in `src/main.rs`.
|
||||
|
||||
So drive the navigation directly rather than changing the app to suit the
|
||||
framework's default.
|
||||
"""
|
||||
|
||||
from ragger.navigator import NavInsID
|
||||
|
||||
#: Text on the final review page. On touch devices this is NBGL's own hold
|
||||
#: button; on Nano it is the finish title passed to `NbglReview::titles`.
|
||||
TOUCH_FINISH = "^Hold to sign$"
|
||||
NANO_FINISH = r"^Sign (transaction|message)$"
|
||||
|
||||
|
||||
def approve_review(device, navigator, path, test_name, nano_text=NANO_FINISH):
|
||||
"""Page through a review and approve it."""
|
||||
if device.is_nano:
|
||||
navigate, validate, text = NavInsID.RIGHT_CLICK, [NavInsID.BOTH_CLICK], nano_text
|
||||
else:
|
||||
navigate, validate, text = (NavInsID.SWIPE_CENTER_TO_LEFT,
|
||||
[NavInsID.USE_CASE_REVIEW_CONFIRM],
|
||||
TOUCH_FINISH)
|
||||
navigator.navigate_until_text_and_compare(
|
||||
navigate_instruction=navigate,
|
||||
validation_instructions=validate,
|
||||
text=text,
|
||||
path=path,
|
||||
test_case_name=test_name,
|
||||
screen_change_after_last_instruction=True,
|
||||
)
|
||||
2
tests/standalone/setup.cfg
Normal file
@@ -0,0 +1,2 @@
|
||||
[tool:pytest]
|
||||
addopts = --strict-markers
|
||||
BIN
tests/standalone/snapshots/apex_p/test_app_mainmenu/00000.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
tests/standalone/snapshots/apex_p/test_app_mainmenu/00001.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
tests/standalone/snapshots/apex_p/test_app_mainmenu/00002.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
BIN
tests/standalone/snapshots/apex_p/test_sign_tx_refused/00000.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
tests/standalone/snapshots/apex_p/test_sign_tx_refused/00001.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
tests/standalone/snapshots/apex_p/test_sign_tx_refused/00002.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
tests/standalone/snapshots/apex_p/test_sign_tx_refused/00003.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
tests/standalone/snapshots/apex_p/test_sign_tx_refused/00004.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
BIN
tests/standalone/snapshots/flex/test_app_mainmenu/00000.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tests/standalone/snapshots/flex/test_app_mainmenu/00001.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
tests/standalone/snapshots/flex/test_app_mainmenu/00002.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_accepted/00000.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_accepted/00001.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_accepted/00002.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_accepted/00003.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_accepted/00004.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_refused/00000.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_refused/00001.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_refused/00002.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_refused/00003.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
tests/standalone/snapshots/flex/test_sign_tx_refused/00004.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
tests/standalone/snapshots/nanosp/test_app_mainmenu/00000.png
Normal file
|
After Width: | Height: | Size: 537 B |
BIN
tests/standalone/snapshots/nanosp/test_app_mainmenu/00001.png
Normal file
|
After Width: | Height: | Size: 407 B |
BIN
tests/standalone/snapshots/nanosp/test_app_mainmenu/00002.png
Normal file
|
After Width: | Height: | Size: 362 B |
|
After Width: | Height: | Size: 545 B |
|
After Width: | Height: | Size: 771 B |
|
After Width: | Height: | Size: 498 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 290 B |