"""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