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>
235 lines
9.7 KiB
Python
235 lines
9.7 KiB
Python
"""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
|