diff --git a/tests/device_tests/bitcoin/payment_req.py b/tests/device_tests/bitcoin/payment_req.py new file mode 100644 index 0000000000..c30e1e3003 --- /dev/null +++ b/tests/device_tests/bitcoin/payment_req.py @@ -0,0 +1,100 @@ +from collections import namedtuple +from hashlib import sha256 + +from ecdsa import SECP256k1, SigningKey + +from trezorlib import btc, messages + +TextMemo = namedtuple("TextMemo", "text") +RefundMemo = namedtuple("RefundMemo", "address_n") +CoinPurchaseMemo = namedtuple( + "CoinPurchaseMemo", "amount, coin_name, slip44, address_n" +) + +payment_req_signer = SigningKey.from_string( + b"?S\ti\x8b\xc5o{,\xab\x03\x194\xea\xa8[_:\xeb\xdf\xce\xef\xe50\xf17D\x98`\xb9dj", + curve=SECP256k1, +) + + +def hash_bytes_prefixed(hasher, data): + hasher.update(len(data).to_bytes(1, "little")) + hasher.update(data) + + +def make_payment_request( + client, recipient_name, outputs, change_addresses=None, memos=None, nonce=None +): + slip44 = 1 # Testnet + + h_pr = sha256(b"SL\x00\x24") + + if nonce: + hash_bytes_prefixed(h_pr, nonce) + else: + h_pr.update(b"\0") + + hash_bytes_prefixed(h_pr, recipient_name.encode()) + + if memos is None: + memos = [] + + h_pr.update(len(memos).to_bytes(1, "little")) + msg_memos = [] + for memo in memos: + if isinstance(memo, TextMemo): + msg_memo = messages.TextMemo(text=memo.text) + msg_memos.append(messages.PaymentRequestMemo(text_memo=msg_memo)) + memo_type = 1 + h_pr.update(memo_type.to_bytes(4, "little")) + hash_bytes_prefixed(h_pr, memo.text.encode()) + elif isinstance(memo, RefundMemo): + address_resp = btc.get_authenticated_address( + client, "Testnet", memo.address_n + ) + msg_memo = messages.RefundMemo( + address=address_resp.address, mac=address_resp.mac + ) + msg_memos.append(messages.PaymentRequestMemo(refund_memo=msg_memo)) + memo_type = 2 + h_pr.update(memo_type.to_bytes(4, "little")) + hash_bytes_prefixed(h_pr, address_resp.address.encode()) + elif isinstance(memo, CoinPurchaseMemo): + address_resp = btc.get_authenticated_address( + client, memo.coin_name, memo.address_n + ) + msg_memo = messages.CoinPurchaseMemo( + coin_type=memo.slip44, + amount=memo.amount, + address=address_resp.address, + mac=address_resp.mac, + ) + msg_memos.append(messages.PaymentRequestMemo(coin_purchase_memo=msg_memo)) + + memo_type = 3 + h_pr.update(memo_type.to_bytes(4, "little")) + h_pr.update(memo.slip44.to_bytes(4, "little")) + hash_bytes_prefixed(h_pr, memo.amount.encode()) + hash_bytes_prefixed(h_pr, address_resp.address.encode()) + else: + raise ValueError + + h_pr.update(slip44.to_bytes(4, "little")) + + change_address = iter(change_addresses or []) + h_outputs = sha256() + for txo in outputs: + h_outputs.update(txo.amount.to_bytes(8, "little")) + address = txo.address or next(change_address) + h_outputs.update(len(address).to_bytes(1, "little")) + h_outputs.update(address.encode()) + + h_pr.update(h_outputs.digest()) + + return messages.TxAckPaymentRequest( + recipient_name=recipient_name, + amount=sum(txo.amount for txo in outputs if txo.address), + memos=msg_memos, + nonce=nonce, + signature=payment_req_signer.sign_digest_deterministic(h_pr.digest()), + ) diff --git a/tests/device_tests/bitcoin/test_signtx_payreq.py b/tests/device_tests/bitcoin/test_signtx_payreq.py new file mode 100644 index 0000000000..48644ae8c3 --- /dev/null +++ b/tests/device_tests/bitcoin/test_signtx_payreq.py @@ -0,0 +1,303 @@ +# This file is part of the Trezor project. +# +# Copyright (C) 2020 SatoshiLabs and contributors +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . + +from collections import namedtuple + +import pytest + +from trezorlib import btc, messages, misc +from trezorlib.exceptions import TrezorFailure +from trezorlib.tools import parse_path + +from ...tx_cache import TxCache +from .payment_req import CoinPurchaseMemo, RefundMemo, TextMemo, make_payment_request + +TX_API = TxCache("Testnet") + +TXHASH_091446 = bytes.fromhex( + "09144602765ce3dd8f4329445b20e3684e948709c5cdcaf12da3bb079c99448a" +) + + +pytestmark = pytest.mark.skip_t1 + + +def case(id, *args, altcoin=False): + if altcoin: + marks = pytest.mark.altcoin + else: + marks = () + return pytest.param(*args, id=id, marks=marks) + + +inputs = [ + messages.TxInputType( + address_n=parse_path("84'/1'/0'/0/0"), + amount=12300000, + prev_hash=TXHASH_091446, + prev_index=0, + script_type=messages.InputScriptType.SPENDWITNESS, + ) +] + +outputs = [ + messages.TxOutputType( + address="2N4Q5FhU2497BryFfUgbqkAJE87aKHUhXMp", + amount=5000000, + script_type=messages.OutputScriptType.PAYTOADDRESS, + ), + messages.TxOutputType( + address="tb1q694ccp5qcc0udmfwgp692u2s2hjpq5h407urtu", + script_type=messages.OutputScriptType.PAYTOADDRESS, + amount=2000000, + ), + messages.TxOutputType( + # tb1qkvwu9g3k2pdxewfqr7syz89r3gj557l3uuf9r9 + address_n=parse_path("84h/1h/0h/0/0"), + amount=12300000 - 5000000 - 2000000 - 11000, + script_type=messages.OutputScriptType.PAYTOWITNESS, + ), +] + +memos1 = [ + CoinPurchaseMemo( + amount="15.9636 DASH", + coin_name="Dash", + slip44=5, + address_n=parse_path("44'/5'/0'/1/0"), + ), +] + +memos2 = [ + CoinPurchaseMemo( + amount="3.1896 DASH", + coin_name="Dash", + slip44=5, + address_n=parse_path("44'/5'/0'/1/0"), + ), + CoinPurchaseMemo( + amount="831.570802 GRS", + coin_name="Groestlcoin", + slip44=17, + address_n=parse_path("44'/17'/0'/0/3"), + ), +] + +memos3 = [TextMemo("Invoice #87654321."), RefundMemo(parse_path("44'/1'/0'/0/1"))] + +PaymentRequestParams = namedtuple( + "PaymentRequestParams", ["txo_indices", "memos", "get_nonce"] +) + + +@pytest.mark.parametrize( + "payment_request_params", + ( + case( + "out0", (PaymentRequestParams([0], memos1, get_nonce=True),), altcoin=True + ), + case( + "out1", (PaymentRequestParams([1], memos2, get_nonce=True),), altcoin=True + ), + case("out2", (PaymentRequestParams([2], [], get_nonce=True),)), + case( + "out0+out1", + ( + PaymentRequestParams([0], [], get_nonce=False), + PaymentRequestParams([1], [], get_nonce=True), + ), + ), + case( + "out01", + (PaymentRequestParams([0, 1], memos3, get_nonce=True),), + ), + case("out012", (PaymentRequestParams([0, 1, 2], [], get_nonce=True),)), + case("out12", (PaymentRequestParams([1, 2], [], get_nonce=True),)), + ), +) +def test_payment_request(client, payment_request_params): + for txo in outputs: + txo.payment_req_index = None + + payment_reqs = [] + for i, params in enumerate(payment_request_params): + request_outputs = [] + for txo_index in params.txo_indices: + outputs[txo_index].payment_req_index = i + request_outputs.append(outputs[txo_index]) + nonce = misc.get_nonce(client) if params.get_nonce else None + payment_reqs.append( + make_payment_request( + client, + recipient_name="trezor.io", + outputs=request_outputs, + change_addresses=["tb1qkvwu9g3k2pdxewfqr7syz89r3gj557l3uuf9r9"], + memos=params.memos, + nonce=nonce, + ) + ) + + _, serialized_tx = btc.sign_tx( + client, + "Testnet", + inputs, + outputs, + prev_txes=TX_API, + payment_reqs=payment_reqs, + ) + + assert ( + serialized_tx.hex() + == "010000000001018a44999c07bba32df1cacdc50987944e68e3205b4429438fdde35c76024614090000000000ffffffff03404b4c000000000017a9147a55d61848e77ca266e79a39bfc85c580a6426c98780841e0000000000160014d16b8c0680c61fc6ed2e407455715055e41052f528b4500000000000160014b31dc2a236505a6cb9201fa0411ca38a254a7bf10247304402204adea8ae600878c5912310f546d600359f6cde8087ebd23f20f8acc7ecb2ede70220603334476c8fb478d8c539f027f9bff5f126e4438df757f9b4ba528adcb56c48012103adc58245cf28406af0ef5cc24b8afba7f1be6c72f279b642d85c48798685f86200000000" + ) + + # Ensure that the nonce has been invalidated. + with pytest.raises(TrezorFailure, match="Invalid nonce in payment request"): + btc.sign_tx( + client, + "Testnet", + inputs, + outputs, + prev_txes=TX_API, + payment_reqs=payment_reqs, + ) + + +def test_payment_req_wrong_amount(client): + # Test wrong total amount in payment request. + outputs[0].payment_req_index = 0 + outputs[1].payment_req_index = 0 + outputs[2].payment_req_index = None + payment_req = make_payment_request( + client, + recipient_name="trezor.io", + outputs=outputs[:2], + nonce=misc.get_nonce(client), + ) + + # Decrease the total amount of the payment request. + payment_req.amount -= 1 + + with pytest.raises(TrezorFailure, match="Invalid amount in payment request"): + btc.sign_tx( + client, + "Testnet", + inputs, + outputs, + prev_txes=TX_API, + payment_reqs=[payment_req], + ) + + +def test_payment_req_wrong_mac_refund(client): + # Test wrong MAC in payment request memo. + memo = RefundMemo(parse_path("44'/1'/0'/1/0")) + outputs[0].payment_req_index = 0 + outputs[1].payment_req_index = 0 + outputs[2].payment_req_index = None + payment_req = make_payment_request( + client, + recipient_name="trezor.io", + outputs=outputs[:2], + memos=[memo], + nonce=misc.get_nonce(client), + ) + + # Corrupt the MAC value. + mac = bytearray(payment_req.memos[0].refund_memo.mac) + mac[0] ^= 1 + payment_req.memos[0].refund_memo.mac = mac + + with pytest.raises(TrezorFailure, match="Invalid address MAC"): + btc.sign_tx( + client, + "Testnet", + inputs, + outputs, + prev_txes=TX_API, + payment_reqs=[payment_req], + ) + + +@pytest.mark.altcoin +def test_payment_req_wrong_mac_purchase(client): + # Test wrong MAC in payment request memo. + memo = CoinPurchaseMemo( + amount="22.34904 DASH", + coin_name="Dash", + slip44=5, + address_n=parse_path("44'/5'/0'/1/0"), + ) + outputs[0].payment_req_index = 0 + outputs[1].payment_req_index = 0 + outputs[2].payment_req_index = None + payment_req = make_payment_request( + client, + recipient_name="trezor.io", + outputs=outputs[:2], + memos=[memo], + nonce=misc.get_nonce(client), + ) + + # Corrupt the MAC value. + mac = bytearray(payment_req.memos[0].coin_purchase_memo.mac) + mac[0] ^= 1 + payment_req.memos[0].coin_purchase_memo.mac = mac + + with pytest.raises(TrezorFailure, match="Invalid address MAC"): + btc.sign_tx( + client, + "Testnet", + inputs, + outputs, + prev_txes=TX_API, + payment_reqs=[payment_req], + ) + + +def test_payment_req_wrong_output(client): + # Test wrong output in payment request. + outputs[0].payment_req_index = 0 + outputs[1].payment_req_index = 0 + outputs[2].payment_req_index = None + payment_req = make_payment_request( + client, + recipient_name="trezor.io", + outputs=outputs[:2], + nonce=misc.get_nonce(client), + ) + + # Use a different address in the second output. + fake_outputs = [ + outputs[0], + messages.TxOutputType( + address="tb1qnspxpr2xj9s2jt6qlhuvdnxw6q55jvygcf89r2", + script_type=outputs[1].script_type, + amount=outputs[1].amount, + payment_req_index=outputs[1].payment_req_index, + ), + outputs[2], + ] + + with pytest.raises(TrezorFailure, match="Invalid signature in payment request"): + btc.sign_tx( + client, + "Testnet", + inputs, + fake_outputs, + prev_txes=TX_API, + payment_reqs=[payment_req], + ) diff --git a/tests/ui_tests/fixtures.json b/tests/ui_tests/fixtures.json index 7c95bbf43c..23f195dd67 100644 --- a/tests/ui_tests/fixtures.json +++ b/tests/ui_tests/fixtures.json @@ -310,6 +310,18 @@ "bitcoin-test_signtx_mixed_inputs.py::test_non_segwit_segwit_non_segwit_inputs": "6bbb1dc3e786d7ccc05fa62405d979d768b36753d8e4b18159e0bc9638d43596", "bitcoin-test_signtx_mixed_inputs.py::test_segwit_non_segwit_inputs": "34cbf0075c03f13db8285b0ca9fd3e32dc3380ef95116d873754ec10c9801b99", "bitcoin-test_signtx_mixed_inputs.py::test_segwit_non_segwit_segwit_inputs": "75b7f389048ad2f3124a60dd541e62718b38c079cee2aa76dfcb00cf2e31ae69", +"bitcoin-test_signtx_payreq.py::test_payment_req_wrong_amount": "1d0da9c044d6aa5523f94e91e882f8839457d51097093fc2e5938285341a1949", +"bitcoin-test_signtx_payreq.py::test_payment_req_wrong_mac_purchase": "1c100ce4b7c1e47e72428f390de0846c1ff933e9f07894872644a369a9422738", +"bitcoin-test_signtx_payreq.py::test_payment_req_wrong_mac_refund": "1c100ce4b7c1e47e72428f390de0846c1ff933e9f07894872644a369a9422738", +"bitcoin-test_signtx_payreq.py::test_payment_req_wrong_output": "887f26b4ddbb365d903b27fb9c2fab54760b2ba6f5f5252a82d8e5b4fc611fbf", +"bitcoin-test_signtx_payreq.py::test_payment_request[out0+out1]": "565607d0c63ec4777b08e271906f0ef69fc48c98b5bcb02d11d1fe6538dcfada", +"bitcoin-test_signtx_payreq.py::test_payment_request[out012]": "0ddf119ff7be991d6066b07f39a81423f77abf0e4f4fa39acd445b1cf8c6bd19", +"bitcoin-test_signtx_payreq.py::test_payment_request[out01]": "ea70f803eae59c3410798ac7d964829c9f8ed223a815c66835a48ff97663d901", +"bitcoin-test_signtx_payreq.py::test_payment_request[out0]": "90d2ea85361e790f4c9b74c691a8b66332346cd313b48d756381088fd4c1c39c", +"bitcoin-test_signtx_payreq.py::test_payment_request[out12]": "d97489a0d4d6b02e95fc1d282cbfd8d4c8a58db8e055ddbf95cde4488ca071c3", +"bitcoin-test_signtx_payreq.py::test_payment_request[out1]": "819651b20d2f23935cac3f6516e453b5c23eddfa29e601b16cdbc193049398ed", +"bitcoin-test_signtx_payreq.py::test_payment_request[out2]": "1f670f6afe51b1ab4d675f0abf0cecc351272e0cef9bcfe1396ca7b10468d03f", +"bitcoin-test_signtx_payreq.py::test_payment_request_details": "155a1355e819da04bbebba18be17f5da4dac128586a8eb960dfdab9ce465c032", "bitcoin-test_signtx_prevhash.py::test_invalid_prev_hash[]": "1c100ce4b7c1e47e72428f390de0846c1ff933e9f07894872644a369a9422738", "bitcoin-test_signtx_prevhash.py::test_invalid_prev_hash[hello world]": "1c100ce4b7c1e47e72428f390de0846c1ff933e9f07894872644a369a9422738", "bitcoin-test_signtx_prevhash.py::test_invalid_prev_hash[x]": "1c100ce4b7c1e47e72428f390de0846c1ff933e9f07894872644a369a9422738",