diff --git a/.gitignore b/.gitignore index d9280d15..5f3f31a4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ dep obj src/glyphs.c src/glyphs.h - +lib/ diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..f1acf72e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,26 @@ +[tool:pytest] +addopts = --strict-markers -x -v +markers = + manual: mark a test as manual i.e. UI actions not automated (yet) + btc: BTC app tests + zcash: ZCASH app tests + +[pylint] +max-line-length = 120 +disable = C0103, # invalid-name + C0114, # missing-module-docstring + C0115, # missing-class-docstring + C0116, # missing-function-docstring + R0902, # too-many-instance-attributes + R0913, # too-many-arguments + R0914, # too-many-locals + R0903, # too-few-public-methods + W0107, # unnecesary-pass + W0401, # wildcard-import + +[pycodestyle] +max-line-length = 120 +# continuation line over-indented for hanging indent +# line break before binary operator +# line break after binary operator +ignore = E127, W503, W504 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..ff4fa5cc --- /dev/null +++ b/tests/README.md @@ -0,0 +1,151 @@ +## Basic BTC app APDU-level tests + +This folder contains some examples of APDU-level tests for the BTC app. These tests are in no way exhaustive as they only cover a subset of the APDU commands supported by the app: +- Generation of trusted inputs from Segwit and Zcash inputs +- Signature of legacy, Segwit & Zcash tx +- Message signature +- Public key export + + +### Test environment +The tests are run with pytest and rely on a small, evolving framework located in the `helpers` folder. + +The tests are manual for now and require user interaction with the app to validate the signature operations. Automation for CI/CD is planned for later (see [WIP](#wip) section). + +Tests can be run: + - either with a real Ledger device loaded with the BTC and the Zcash apps + - or with the apps running under [Speculos](https://github.com/LedgerHQ/speculos) + + +### Launching the tests +Because tests are available for both the BTC app and the Zcash app (using the BTC app as a library), they require the appropriate app to be started and cannot be launched all at once. However, they are gathered categorized under two `pytest` markers: `btc` and `zcash` to allow for launching all the tests of a category at once. + +#### With a real Ledger Nano S or Blue device +###### BTC tests +The BTC app must be loaded on the device and started. +```shell script +cd $APP_BITCOIN_REPO_PATH/tests +pytest -x -v [-s] -m btc +``` + +###### Zcash tests +Both the BTC and the Zcash apps must be loaded on the device. Only the Zcash app must be started. +```shell script +cd $APP_BITCOIN_REPO_PATH/tests +pytest -x -v [-s] -m zcash +``` + +#### With Speculos +Procedure below assumes that the BTC and the Zcash app binaries are available. +###### BTC tests +```shell script +# Start speculos (assuming BTC app is bin/app.elf) +cd $SPECULOS_REPO_PATH +./speculos.py --ontop -m nanos -k 1.6 -s <24-word seed> /bin/app.elf + +# Launch tests +cd $APP_BITCOIN_REPO_PATH/tests +LEDGER_PROXY_ADDRESS=127.0.0.1 LEDGER_PROXY_PATH=9999 pytest -x -v [-s] -m btc +``` + +###### Zcash tests +```shell script +# Start speculos (assuming BTC app is lib/btc.elf and Zcash app is in bin/app.elf) +cd $SPECULOS_REPO_PATH +./speculos.py --ontop -m nanos -k 1.6 -s <24-word seed> -l Bitcoin:/lib/btc.elf /bin/app.elf + +# Launch tests +cd $APP_BITCOIN_REPO_PATH/tests +LEDGER_PROXY_ADDRESS=127.0.0.1 LEDGER_PROXY_PATH=9999 pytest -x -v [-s] -m zcash +``` + +**Note**: +- When provided, the `-s` parameter triggers the display of the APDUs exchanged between the test script and the device. +- Tests pass green as long as user confirms the transactions/message signatures. They fail if user rejects the signing operation. + + +### Automation +Very early work has started to add test automation with [Speculos](https://github.com/LedgerHQ/speculos), in order to enable integration in a CI/CD environment. This is still WIP at the moment. + +=== + +### Test framework details +The tests and framework are organized as described below: +``` +| +|-- tests: Contains the test scripts written in python (pytest). `btc`, `zcash` and `manual` pytest + | marks are available. + | + |-- basetest.py: Provides some base classes for BTC and Zcash test classes. They contain + | some methods that tests can call to either check the format of various data returned by + | the app (signatures, trusted inputs,...) or perform some specific actions (e.g. sending + | some raw APDUs extracted from Ledgerjs logs in Zcash tests). + | + |-- helpers: Abstraction layers to the app under test & to the BTC raw transaction data. + | + | + |-- txparser: In-house raw BTC tx parser, based on a dataclass + specific types. + | | Supports legacy & segwit BTC tx + Zcash tx. + | | + | |-- transaction.py: Implements the `TxParse` class + its `from_raw()` method + | | that parses a raw tx into named attributes of the `Tx` class. + | | + | |-- txtypes.py: Various types used by `TxParse`, notably `TxInt8` (resp. `16, `32`) + | and `TxVarInt` which store some of the tx fields as both integers and + | bytes buffer. + | + |-- deviceappproxy: Defines the `DeviceAppProxy` class that abstracts APDU-level + | communication between the app & the tests. + | + |-- apduabstract.py: Define the `CApdu` dataclass (abstract representation of an APDU) + | and the `ApduDict` class which is a collection of `CApdu`s supported by an app + | I.e. `CApdu` collects the values of CLA, INS, P1, P2 bytes for a command + | supported by an app and `ApduDict` gathers these `CApdu`s in one place. + | + |-- deviceappbtc.py: class derived from `DeviceAppProxy` that defines the `ApduDict` of + `CApdu`s supported by the BTC app (actually only the subset useful for the tests) + and "hides" them behind an higher-level API that the tests can call. That API + takes care of all the app-specific intricacies of sending data to the app (e.g. + payload chunking is often required to send big data to the app but is not well + documented, so `DeviceAppBtc` takes care of that in place of the tester). +``` + + === + +### Next steps/TODO +Below is a compilation of the various TODOs that can be done in order to structure and rationalize the test framework even more, so that it could easily be reused for testing another app than BTC (of course, provided the implementation of the appropriate APDU abstraction API in a `DeviceAppProxy`-derived class). +Entries with a checkmark have already been dealt with. + +- `helpers/basetests.py`: + - [ ] Replace the raw APDU from Ledgerjs logs (mostly some GetVersion-kind of APDUs) in Segwit/Zcash tests with a proper `DeviceAppBtc`-based implementation. + - [ ] Whether to leave the `LedgerjsApdu` class (in `conftest.py`) & the associated `BaseTestZcash.send_ljs_apdus()` method (but moved to `LedgerjsApdu` for consistency) available as utilities for potential reuse or to scrap them alltoghether is a decision left to the maintainers. + - [ ] Dataclass `BtcPublicKey` to be re-written and made more useful, as suggested by @onyb + +- `helpers/txparser/txtypes.py`: + - [ ] Class `Tx` is generic (as it describes most, if not all, blockchains transactions formats) and should be moved from `transaction.py` to `txtypes.py`. File `txtypes.py` should be in a separate folder/module at the same level as `txparser` since it can be reused other parsers than a BTC tx parser. + +- `helpers/txparser/tansaction.py + helpers/txparser/txtypes.py`: + Goal is to facilitate writing parsers for other blockchains transactions. So writing a new parser for e.g. ETH blockchain tx would be a matter of deriving the `TxParse` base class and implementing its `from_raw()` method. + - [ ] Class `TxParse` should be made into a base class with a pure virtual `from_raw()` method which raises `NotImplementedError`. And moved to a separate file too. + - [ ] Following that, the current BTC tx parser should be derived from that base class and renamed `BtcTxParse` or something. AndFile `transaction.py` should be renamed to properly reflect its BTC-inclined orientation. + - [ ] Additionally, a `to_bytes(parsed_tx: Tx) -> bytes` method which concatenates "anonymously" (by parsing recursively the class object, see `_recursive_hash_obj()` for an example of such parsing) all the fields of `parsed_tx` into a raw tx bytes buffer. + - [ ] The Weblue's `field()` method should be used to check fields size when possible at runtime. This will impact the definition of the `byte`, `bytes2`, `bytes4`, ...`bytes64` types in `txtypes.py` which would simply become based on the `bytes` type. + +- `helpers/deviceappproxy/deviceappproxy.py`: As an initial effort to add event automation support to the tests;, this fille contains the `run()` and `stop()` methods to launch/close the app being tested under speculos. Launching/closing an app by calling these methods work but are not enough to support automation. + - [ ] Implement all missing parts related to automation (e.g. listening to touch/click events & propagating them to the app). This will allow for deployement of the tests in a CI/CD environment. + - [ ] The hardware-related parameters of the `run()` method (i.e. `model`, `finger_port` (actually `event_port`), `deterministic_rng`, `rampage`) should be moved to `__init__()` instead, with sensible default values. + +- `conftest.py`: + - [ ] This file being pytest-specific, should dataclasses `SignTxTestData` and `TrustedInputTestData` be moved into a separate file, possibly `basetest.py`? + - Pro: it makes them reusable with other test environemnts than pytest but Cons: it creates a coupling between `conftest.py` and that other file. + +- Misc: + - [ ] Discuss & decide on the implementation of the [suggestions from @onyb](https://github.com/LedgerHQ/app-bitcoin/pull/157#pullrequestreview-443882538) + - [ ] Add support for Bitcoin Cash (potentially nothing to do?) + - [ ] Turn `deviceappproxy` and `txparser` folders into proper packages installable into any virtualenv through pip. Meaning they would have their own repo in LedgerHQ and evolve separately from the BTC app. + - Either: + - [ ] Add new `deviceapp.py` files in newly modularized `deviceappproxy` to support formatting APDus for other coins e.g. Eth, Xrp, etc + - [ ] Or move Bitcoin-specific `deviceappbtc.py` out of `deviceappproxy` module and put in at `helper` folder (other coins tests will define a similar `deviceapp.py` based on `deviceappproxy` module in their own repo) + - [X] Fix style warnings from `pylint` & `pycodestyle` + - [X] Replace `BytesOrStr` type with `AnyStr` built-in type + - [X] Rename `lbstr` type to something more verbose like `ByteOrder` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..2b6bb0ca --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,686 @@ +from dataclasses import dataclass, field +from typing import List, Optional +import pytest + + +@dataclass +class LedgerjsApdu: + commands: List[str] + expected_resp: Optional[str] = field(default=None) + expected_sw: Optional[str] = field(default=None) + check_sig_format: Optional[bool] = field(default=None) + + +@dataclass +class SignTxTestData: + tx_to_sign: bytes + utxos: List[bytes] + output_paths: List[bytes] + change_path: bytes + # expected_sig: List[bytes] + + +@dataclass +class TrustedInputTestData: + # Tx to compute a TrustedInput from. + tx: bytes + # List of the outputs values to be tested, as expressed in the raw tx. + prevout_amount: List[bytes] + # Optional, index (not offset!) in the tx of the output to compute the TrustedInput from. Ignored + # if num_outputs is set. + prevout_idx: Optional[int] = field(default=None) + # Optional, number of outputs in the tx. If set, all the tx outputs will be used to generate + # each a corresponding TrustedInput, prevout_idx is ignored and prevout_amount must contain the + # values of all the outputs of that tx, in order. If not set, then prevout_idx must be set. + num_outputs: Optional[int] = field(default=None) + + +# ----------------------- Test data for test_btc_get_trusted_input.py ----------------------- + + +# Test data definitions +def btc_gti_test_data() -> List[TrustedInputTestData]: + # BTC Testnet + # txid: 45a13dfa44c91a92eac8d39d85941d223e5d4d210e85c0d3acf724760f08fcfe + # VO_P2WPKH + standard_tx = TrustedInputTestData( + tx=bytes.fromhex( + "02000000" + "02" + "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab000000006b" + "483045022100ca145f0694ffaedd333d3724ce3f4e44aabc0ed5128113660d11" + "f917b3c5205302207bec7c66328bace92bd525f385a9aa1261b83e0f92310ea1" + "850488b40bd25a5d0121032006c64cdd0485e068c1e22ba0fa267ca02ca0c2b3" + "4cdc6dd08cba23796b6ee7fdffffff" + "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab010000006a" + "47304402202a5d54a1635a7a0ae22cef76d8144ca2a1c3c035c87e7cd0280ab4" + "3d3451090602200c7e07e384b3620ccd2f97b5c08f5893357c653edc2b8570f0" + "99d9ff34a0285c012102d82f3fa29d38297db8e1879010c27f27533439c868b1" + "cc6af27dd3d33b243decfdffffff" + "01" + "d7ee7c01000000001976a9140ea263ff8b0da6e8d187de76f6a362beadab781188ac" + "e3691900" + ), + prevout_idx=0, + prevout_amount=[bytes.fromhex("d7ee7c0100000000")] + ) + + segwit_tx = TrustedInputTestData( + tx=bytes.fromhex( + "020000000001" + "02" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90000000000fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90100000000fdffffff" + "01" + "01410f0000000000160014e4d3a1ec51102902f6bbede1318047880c9c7680" + "024730440220495838c36533616d8cbd6474842459596f4f312dce5483fe6507" + "91c82e17221c02200660520a2584144915efa8519a72819091e5ed78c52689b2" + "4235182f17d96302012102ddf4af49ff0eae1d507cc50c86f903cd6aa0395f32" + "39759c440ea67556a3b91b" + "0247304402200090c2507517abc7a9cb32452aabc8d1c8a0aee75ce63618ccd9" + "01542415f2db02205bb1d22cb6e8173e91dc82780481ea55867b8e753c35424d" + "a664f1d2662ecb1301210254c54648226a45dd2ad79f736ebf7d5f0fc03b6f8f" + "0e6d4a61df4e531aaca431" + "a7011900" + ), + prevout_idx=0, + prevout_amount=[bytes.fromhex("01410f0000000000")] + ) + + segwit_tx_2_outputs = TrustedInputTestData( + tx=bytes.fromhex( + "020000000001" + "01" + "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db10000000017" + "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846afeffffff" + "02" + "9b3242bf0100000017a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" + "102700000000000017a9141e852ac84f8385d76441c584e41f445aaf1624ea87" + "0247304402206e54747dabff52f5c88230a3036125323e21c6c950719f671332" + "cdd0305620a302204a2f2a6474f155a316505e2224eeab6391d5e6daf22acd76" + "728bf74bc0b48e1a0121033c88f6ef44902190f859e4a6df23ecff4d86a2114b" + "d9cf56e4d9b65c68b8121d" + "1f7f1900" + ), + num_outputs=2, + prevout_amount=[bytes.fromhex(amount) for amount in ("9b3242bf01000000", "1027000000000000")] + ) + + return [standard_tx, segwit_tx, segwit_tx_2_outputs] + + +# ----------------------- Test data for test_btc_rawtx_ljs.py ----------------------- + + +def ledgerjs_test_data() -> List[List[LedgerjsApdu]]: + # Test data below is extracted from ledgerjs repo, file "ledgerjs/packages/hw-app-btc/tests/Btc.test.js" + ljs_btc_get_wallet_public_key = [ + LedgerjsApdu( # GET PUBLIC KEY - on 44'/0'/0'/0 path + commands=["e040000011048000002c800000008000000000000000"], + # Response id seed-dependent, mening verification is possible only w/ speculos (test seed known). + # TODO: implement a simulator class a la DeviceAppSoft with BTC tx-related + # functions (seed derivation, signature, etc). + # expected_resp="410486b865b52b753d0a84d09bc20063fab5d8453ec33c215d4019a5801c9c6438b917770b2782e29a9ecc6edb" + # "67cd1f0fbf05ec4c1236884b6d686d6be3b1588abb2231334b453654666641724c683466564d36756f517a76735971357677657" + # "44a63564dbce80dd580792cd18af542790e56aa813178dc28644bb5f03dbd44c85f2d2e7a" + ) + ] + + ljs_btc3 = [ + LedgerjsApdu( # GET TRUSTED INPUT + commands=[ + "e042000009000000010100000001", + "e0428000254ea60aeac5252c14291d428915bd7ccd1bfc4af009f4d4dc57ae597ed0420b71010000008a", + "e042800032" + "47304402201f36a12c240dbf9e566bc04321050b1984cd6eaf6caee8f02bb0bfec08e3354b022012ee2aeadcbbfd1e92959f", + "e042800032" + "57c15c1c6debb757b798451b104665aa3010569b49014104090b15bde569386734abf2a2b99f9ca6a50656627e77de663ca7", + "e04280002a325702769986cf26cc9dd7fdea0af432c8e2becc867c932e1b9dd742f2a108997c2252e2bdebffffffff", + "e04280000102", + "e04280002281b72e00000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88ac", + "e042800022a0860100000000001976a9144533f5fb9b4817f713c48f0bfe96b9f50c476c9b88ac", + "e04280000400000000" + ], + expected_resp="3200" + "--" * 2 + + "c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f1001000000a086010000000000" + "--" * 8 + ), + LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - + commands=[ + "e0440000050100000001", + "e04480002600c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f100100000069", + "e044800032" + "52210289b4a3ad52a919abd2bdd6920d8a6879b1e788c38aa76f0440a6f32a9f1996d02103a3393b1439d1693b063482c04b", + "e044800032" + "d40142db97bdf139eedd1b51ffb7070a37eac321030b9a409a1e476b0d5d17b804fcdb81cf30f9b99c6f3ae1178206e08bc5", + "e04480000900639853aeffffffff" + ] + ), + LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL - prevout amount + output script + commands=["e04a80002301905f0100000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88ac"], + expected_resp="0000" + ), + LedgerjsApdu( # UNTRUSTED HASH SIGN - on 0'/0/0 path + commands=["e04800001303800000000000000000000000000000000001"], + check_sig_format=True + ) + ] + + ljs_btc4 = [ + LedgerjsApdu( # SIGN MESSAGE - part 1, on 44'/0'/0'/0 path + data to sign ("test") + commands=["e04e000117048000002c800000008000000000000000000474657374"], + expected_resp="0000" + ), + LedgerjsApdu( # SIGN MESSAGE - part 2, Null byte as end of msg + commands=["e04e80000100"], + check_sig_format=True + ) + ] + + ljs_sign_message = [ + LedgerjsApdu( # SIGN MESSAGE - on 44'/0'/0/0 path + data to sign (binary) + commands=["e04e00011d058000002c800000008000000000000000000000000006666f6f626172"], + expected_resp="0000" + ), + LedgerjsApdu( # SIGN MESSAGE - Null byte as end of message + commands=["e04e80000100"], + check_sig_format=True + ) + ] + + return [ljs_btc_get_wallet_public_key, ljs_btc3, ljs_btc4, ljs_sign_message] + + +# ----------------------- Test data for test_btc_rawtx_zcash.py ----------------------- + + +def zcash_prefix_cmds() -> List[List[LedgerjsApdu]]: + # Test data below is from a Zcash test log from Live team" + prefix_cmds = [ + LedgerjsApdu( # Get version + commands=["b001000000"], + # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) + ), + LedgerjsApdu( + commands=[ + "e040000015058000002c80000085800000000000000000000000", # GET PUBLIC KEY - on 44'/133'/0'/0/0 path + "e016000000", # Coin info + ], + expected_resp="1cb81cbd01055a63617368035a4543" # "Zcash" + "ZEC" + ), + LedgerjsApdu( + commands=[ + "e040000009028000002c80000085", # Get Public Key - on path 44'/133' + "e016000000", # Coin info + ], + expected_resp="1cb81cbd01055a63617368035a4543" + ), + LedgerjsApdu( + commands=[ + "e040000009028000002c80000085", # path 44'/133' + "e04000000d038000002c8000008580000000", # path 44'/133'/0' + "e04000000d038000002c8000008580000001", # path 44'/133'/1' + "b001000000" + ], + # expected_resp="01055a63617368--------------0102" + ), + LedgerjsApdu( + commands=[ + "e040000015058000002c80000085800000000000000000000004", # Get Public Key - on path 44'/133'/0'/0/4 + "e016000000", # Coin info + ], + expected_resp="1cb81cbd01055a63617368035a4543" + ), + LedgerjsApdu( + commands=["b001000000"], + # expected_resp="01055a63617368--------------0102" + ), + LedgerjsApdu( + commands=[ + "e040000015058000002c80000085800000000000000000000004", # Get Public Key - on path 44'/133'/0'/0/4 + "e016000000" + ], + expected_resp="1cb81cbd01055a63617368035a4543" + ), + LedgerjsApdu( + commands=["b001000000"], + # expected_resp="01055a63617368--------------0102" + ) + ] + return [prefix_cmds] + + +def zcash_ledgerjs_test_data() -> List[List[LedgerjsApdu]]: + zcash_tx_sign_gti = [ + LedgerjsApdu( # GET TRUSTED INPUT + commands=[ + "e042000009000000010400008001", + "e042800025edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857010000006b", + "e042800032" + "483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336dfa248aea9ccf022023b13e57595635452130", + "e042800032" + "1c91ed0fe7072d295aa232215e74e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f42d458da", + "e04280000b1100831dc4ff72ffffff00", + "e04280000102", + "e042800022a0860100000000001976a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac", + "e0428000224d949100000000001976a914b714c60805804d86eb72a38c65ba8370582d09e888ac", + "e04280000400000000", + ], + expected_resp="3200" + "--" * 2 + + "20b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "--" * 8 + ), + ] + + zcash_tx_to_sign_abandonned = [ + LedgerjsApdu( # GET PUBLIC KEY + commands=["e040000015058000002c80000085800000000000000100000001"], # on 44'/133'/0'/1/1 + ), + LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START + commands=[ + "e0440005090400008085202f8901", + "e04480053b" + "013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "45e1e144cb88d4d800", + "e044800504ffffff00", + ] + ), + LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL + commands=[ + "e04aff0015058000002c80000085800000000000000100000003", + # "e04a000032" + # "0240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" + "e04a0000230140420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + ], # tx aborted on 2nd command + expected_sw="6985" + ), + ] + + zcash_tx_sign_restart_prefix_cmds = [ + LedgerjsApdu( + commands=["b001000000"], + # expected_resp="01055a63617368--------------0102" + ), + LedgerjsApdu( + commands=[ + "e040000015058000002c80000085800000000000000000000004", + "e016000000", + ], + expected_resp="1cb81cbd01055a63617368035a4543" + ), + LedgerjsApdu( + commands=["b001000000"], + # expected_resp="01055a63617368--------------0102" + ) + ] + + zcash_tx_to_sign_finalized = zcash_tx_sign_gti + [ + LedgerjsApdu( # GET PUBLIC KEY + commands=["e040000015058000002c80000085800000000000000100000001"], # on 44'/133'/0'/1/1 + ), + LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START + commands=[ + "e0440005090400008085202f8901", + "e04480053b" + "013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "45e1e144cb88d4d800", + "e044800504ffffff00", + ] + ), + LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL + commands=[ + "e04aff0015058000002c80000085800000000000000100000003", + # "e04a000032" + # "0240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" + "e04a0000230140420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + "e04a8000045eb3f840" + ], + expected_resp="0000" + ), + + LedgerjsApdu( + commands=[ + "e0440085090400008085202f8901", + "e04480853b" + "013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "45e1e144cb88d4d819", + "e04480851d76a9140a146582553b2f5537e13cef6659e82ed8f69b8f88acffffff00", + "e048000015058000002c80000085800000000000000100000001" + ], + check_sig_format=True + ) + ] + + return [zcash_prefix_cmds, zcash_tx_sign_gti, zcash_tx_to_sign_abandonned, + zcash_tx_sign_restart_prefix_cmds, zcash_tx_to_sign_finalized] + + +@pytest.fixture +def zcash_utxo_single() -> bytes: + return bytes.fromhex( + # https://sochain.com/api/v2/tx/ZEC/ec9033381c1cc53ada837ef9981c03ead1c7c41700ff3a954389cfaddc949256 + # Zcash Sapling + "0400008085202f89" + "01" + "53685b8809efc50dd7d5cb0906b307a1b8aa5157baa5fc1bd6fe2d0344dd193a000000006b" + "483045022100ca0be9f37a4975432a52bb65b25e483f6f93d577955290bb7fb0" + "060a93bfc92002203e0627dff004d3c72a957dc9f8e4e0e696e69d125e4d8e27" + "5d119001924d3b48012103b243171fae5516d1dc15f9178cfcc5fdc67b0a8830" + "55c117b01ba8af29b953f6" + "ffffffff" + "01" + "40720700000000001976a91449964a736f3713d64283fd0018626ba50091c7e988ac" + "00000000" + "000000000000000000000000000000" + ) + + +@pytest.fixture +def zcash_sign_tx_test_data() -> SignTxTestData: + test_utxos = [ + # Considered a segwit tx - segwit flags couldn't be extracted from raw + # Get Trusted Input APDUs as they are not supposed to be sent w/ these APDUs. + bytes.fromhex( + # Zcash Sapling + "0400008085202f89" + "01" + "edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857010000006b" + "483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336d" + "fa248aea9ccf022023b13e575956354521301c91ed0fe7072d295aa232215e74" + "e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f" + "42d458da1100831dc4ff72" + "ffffff00" + "02" + "a0860100000000001976a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac" + "4d949100000000001976a914b714c60805804d86eb72a38c65ba8370582d09e888ac" + "00000000" + "000000000000000000000000000000" + ) + ] + + test_tx_to_sign = bytes.fromhex( + # Zcash Sapling + "0400008085202f89" + "01" + "d35f0793da27a5eacfe984c73b1907af4b50f3aa3794ba1bb555b9233addf33f0100000000" + "ffffff00" + "02" + "40420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + "2b518200000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + "5eb3f840" + "000000000000000000000000000000" + ) + + test_change_path = bytes.fromhex("058000002c80000085800000000000000100000003") # 44'/133'/0'/1/3 + test_output_paths = [bytes.fromhex("058000002c80000085800000000000000100000001")] # 44'/133'/0'/1/1 + + return SignTxTestData( + tx_to_sign=test_tx_to_sign, + utxos=test_utxos, + output_paths=test_output_paths, + change_path=test_change_path + ) + + +# ----------------------- Test data for test_btc_rawtx_zcash2.py ----------------------- + + +# Test data below is from a Zcash test log from Live team" +def zcash2_prefix_cmds() -> List[List[LedgerjsApdu]]: + prefix_cmds = [ + LedgerjsApdu( # Get version + commands=[ + "b001000000", + "b001000000" + ], + # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) + ), + LedgerjsApdu( + commands=[ + "e040000015058000002c80000085800000010000000000000007", # GET PUBLIC KEY - on 44'/133'/1'/0/7 path + "e016000000", # Coin info + ], + expected_resp="1cb81cbd01055a63617368035a4543" # "Zcash" + "ZEC" + ), + LedgerjsApdu( + commands=[ + "e040000015058000002c80000085800000010000000000000007", # GET PUBLIC KEY - on 44'/133'/1'/0/7 path + "e016000000", # Coin info + ], + expected_resp="1cb81cbd01055a63617368035a4543" # "Zcash" + "ZEC" + ), + LedgerjsApdu( # Get version + commands=[ + "b001000000" + ], + # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) + ), + LedgerjsApdu( + commands=[ + "e040000015058000002c80000085800000000000000000000002", # Get Public Key - on path 44'/133'/0'/0/2 + "e016000000", # Coin info + ], + expected_resp="1cb81cbd01055a63617368035a4543" + ), + LedgerjsApdu( # Get version + commands=[ + "b001000000" + ], + # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) + ) + ] + return [prefix_cmds] + + +@pytest.fixture +def zcash2_sign_tx_test_data() -> SignTxTestData: + test_utxos = [ + # Zcash Overwinter + bytes.fromhex( + "030000807082c403" + "03" + "f6959fbdd8cc614211e4db1ca287a766441dcda8d786f70d956ad19de03373a40100000069" + "46304302203dc5102d80e08cb8dee8e83894026a234d84ddd92da1605405a677" + "ead9fcb21a021f40bedfa4b5611fc00a6d43aedb6ea0769175c2eb4ce4f68963" + "c3a6103228080121028aceaa654c031435beb9bcf80d656a7519a6732f3da3c8" + "14559396131ea3532effffff00" + "5ae818ee42a08d5c335d850cacb4b5996e5d2bc1cd5f0c5b46733652771c23b9010000006b" + "483045022100df24e46115778a766068f1b744a7ffd2b0ae4e09b34259eecb2f" + "5871f5e3ff7802207c83c3c13c8113f904da3ea4d4ceedb0db4e8518fb43e9fb" + "8aeda64d1a69c76b012103e604d3cbc5c8aa4f9c53f84157be926d443054ba93" + "b60fbddf0aea053173f595ffffff00" + "6065c6c49cd132fc148f947b5aa5fd2a4e0ae4b5a884ccb3247b5ccbfa3ecc58010000006a" + "473044022064d92d88b8223f9e502214b2abf8eb72b91ad7ed69ae9597cb510a" + "3c94c7a2b00220327b4b852c2a81ad918bb341e7cd1c7e15903fc3e298663d75" + "675c4ab180be890121037dbc2659579d22c284a3ea2e3b5d0881f678583e2b4a" + "8b19dbd50f384d4b2535ffffff00" + "02" + "002d3101000000001976a914772b6723ec72c99f6a37009407006fe1c790733988ac" + "13b62400000000001976a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac" + "00000000" + "0000000000" + ) + ] + + test_tx_to_sign = bytes.fromhex( + # Zcash Sapling + "0400008085202f89" + "01" + "605d4c86ca4511e962dbd968ab6805deeff0f076f6a8c6069dadefb0378c72440100000019" + "76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388acffffff00" + "02" + "c05c1500000000001976a914130715c4e654cff3fced8a9d6876310083d44f2e88ac" + "e9540f00000000001976a91478dff3b7ed9dac8e9177c587375937f9d057769588ac" + "00000000" + "000000000000000000000000000000" + ) + + test_change_path = bytes.fromhex("058000002c80000085800000000000000100000007") # 44'/133'/0'/1/7 + test_output_paths = [bytes.fromhex("058000002c80000085800000000000000100000006")] # 44'/133'/0'/1/6 + + return SignTxTestData( + tx_to_sign=test_tx_to_sign, + utxos=test_utxos, + output_paths=test_output_paths, + change_path=test_change_path, + ) + + +# ----------------------- Test data for test_btc_segwit_tx_ljs.py ----------------------- + + +@pytest.fixture +def segwit_sign_tx_test_data() -> SignTxTestData: + test_utxos = [ + bytes.fromhex( + "02000000" + "0001" + "01" + "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db10000000017" + "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846afeffffff" + "02" + "9b3242bf0100000017a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" + "102700000000000017a9141e852ac84f8385d76441c584e41f445aaf1624ea87" + "0247" + "304402206e54747dabff52f5c88230a3036125323e21c6c950719f671332cdd0" + "305620a302204a2f2a6474f155a316505e2224eeab6391d5e6daf22acd76728b" + "f74bc0b48e1a0121033c88f6ef44902190f859e4a6df23ecff4d86a2114bd9cf" + "56e4d9b65c68b8121d" + "1f7f1900" + ), + bytes.fromhex( + "01000000" + "0001" + "02" + "7ab1cb19a44db08984031508ec97de727b32a8176cc00fce727065e86984c8df0000000017" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320ffffff00" + "78958127caf18fc38733b7bc061d10bca72831b48be1d6ac91e296b8880033270000000017" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320ffffff00" + "02" + "102700000000000017a91493520844497c54e709756c819afecfffaf28761187" + "c84b1a000000000017a9148f1f7cf3c847e4057be46990c4a00be4271f3cfa87" + "0247" + "3044022009116da9433c3efad4eaf5206a780115d6e4b2974152bdceba220c45" + "70e527a802202b06ca9eb93df1c9fc5b0e14dc1f6698adc8cbc15d3ec4d364b7" + "bef002c493d701210293137bc1a9b7993a1d2a462188efc45d965d135f53746b" + "6b146a3cec9905322602473044022034eceb661d9e5f777468089b262f6b25e1" + "41218f0ec9e435a98368d3f347944d02206041228b4e43a1e1fbd70ca15d3308" + "af730eedae9ec053afec97bd977be7685b01210293137bc1a9b7993a1d2a4621" + "88efc45d965d135f53746b6b146a3cec99053226" + "00000000" + ) + ] + + test_tx_to_sign = bytes.fromhex( + "01000000" + "0001" + # Inputs + "02" + "027a726f8aa4e81a45241099a9820e6cb7d8920a686701ad98000721101fa0aa0100000017" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320ffffff00" + "f0b7b7ad837b4d3535bea79a2fa355262df910873b7a51afa1e4279c6b6f6e6f0000000017" + "160014eee02beeb4a8f15bbe4926130c086bd47afe8dbcffffff00" + # Outputs + "02" + "102700000000000017a9142406cd1d50d3be6e67c8b72f3e430a1645b0d74287" + "0e2600000000000017a9143ae394774f1348be3a6bc2a55b67e3566d13408987" + # witnesses + "02483045022100f4d05565991d98573629c7f98c4f575a4915600a83a0057716" + "f1f4865054927f022010f30365e0685ee46d81586b50f5dd201ddedab39cfd7d" + "16d3b17f94844ae6d501210293137bc1a9b7993a1d2a462188efc45d965d135f" + "53746b6b146a3cec9905322602473044022030c4c770db75aa1d3ed877c6f995" + "a1e6055be00c88efefb2fb2db8c596f2999a02205529649f4366427e1d9ed3cf" + "8dc80fe25e04ce4ac19b71578fb6da2b5788d45b012103cfbca92ae924a3bd87" + "529956cb4f372a45daeafdb443e12a781881759e6f48ce03cfbca92ae924a3bd" + "87529956cb4f372a45daeafdb443e12a781881759e6f48ce03cfbca92ae924a3" + "bd87529956cb4f372a45daeafdb443e12a781881759e6f48ce" + "00000000" + ) + + # TODO: expected signature to be checked should be extracted from tx (when tx is confirmed). + # - Confirmed tx signature parsing should be added to helper tx parser + # - Pubkey from tx's scriptPubKey should be used to decrypt the signature for each input and + # resulting hash should be compared against recomputed tx's inputs hashes (WIP). + # test_expected_der_sig = [ + # ] + + test_output_paths = [ + bytes.fromhex("0580000031800000018000000000000000000001f6"), # 49'/1'/0'/0/502 + bytes.fromhex("0580000031800000018000000000000000000001f7") # 49'/1'/0'/0/503 + ] + test_change_path = bytes.fromhex("058000003180000001800000000000000100000045") # 49'/1'/0'/1/69 + + return SignTxTestData( + tx_to_sign=test_tx_to_sign, + utxos=test_utxos, + output_paths=test_output_paths, + change_path=test_change_path, + # expected_sig=test_expected_der_sig + ) + + +# ----------------------- Test data for test_btc_signature.py ----------------------- + + +# BTC Testnet segwit tx used as a "prevout" tx. +# Note: UTXO transactiopns must be ordered in this list in the same order as their +# matching hashes in the tx to sign. +# txid: 2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753 +# VO_P2WPKH +@pytest.fixture +def btc_sign_tx_test_data() -> SignTxTestData: + test_utxos = [ + bytes.fromhex( + "02000000" + "0001" + "02" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90000000000fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90100000000fdffffff" + "01" + "01410f0000000000160014e4d3a1ec51102902f6bbede1318047880c9c7680" + "0247" + "30440220495838c36533616d8cbd6474842459596f4f312dce5483fe650791c8" + "2e17221c02200660520a2584144915efa8519a72819091e5ed78c52689b24235" + "182f17d96302012102ddf4af49ff0eae1d507cc50c86f903cd6aa0395f323975" + "9c440ea67556a3b91b" + "0247" + "304402200090c2507517abc7a9cb32452aabc8d1c8a0aee75ce63618ccd90154" + "2415f2db02205bb1d22cb6e8173e91dc82780481ea55867b8e753c35424da664" + "f1d2662ecb1301210254c54648226a45dd2ad79f736ebf7d5f0fc03b6f8f0e6d" + "4a61df4e531aaca431" + "a7011900" + ), + ] + + # The tx we want to sign, referencing the hash of the prevout segwit tx above + # in its input. + test_tx_to_sign = bytes.fromhex( + "02000000" + "01" + "2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753000000001976a914e4d3a1ec" + "51102902f6bbede1318047880c9c768088acfdffffff" + "02" + "1027000000000000160014161d283ebbe0e6bc3d90f4c456f75221e1b3ca0f" + "64190f00000000001600144c5133c242683d33c61c4964611d82dcfe0d7a9a" + "a7011900" + ) + + # Expected signature (except last sigHashType byte) was extracted from raw tx at: + # https://tbtc.bitaps.com/raw/transaction/a9a7ffabd6629009488546eb1fafd5ae2c3d0008bc4570c20c273e51b2ce5abe + # TODO: expected signature to be checked should be extracted from tx (when tx is confirmed). See previous TODO. + # test_expected_der_sig = [ + # bytes.fromhex( # for output #1 + # "3044" + # "02202cadfbd881f592ea82e69038c7ada8f1ae50919e3be92ad1cd5fcc0bd142b2f5" + # "0220646a699b5532fcdf38b196157e216c8808ae7bde5e786b8f3cbf2502d0f14c13" + # "01"), + # ] + + test_output_paths = [bytes.fromhex("058000005480000001800000000000000000000000"), ] # 84'/1'/0'/0/0 + test_change_path = bytes.fromhex("058000005480000001800000000000000100000001") # 84'/1'/0'/1/1 + + return SignTxTestData( + tx_to_sign=test_tx_to_sign, + utxos=test_utxos, + output_paths=test_output_paths, + change_path=test_change_path, + # expected_sig=test_expected_der_sig + ) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index de101117..a1f1ffb8 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1 +1,9 @@ -import sys +""" +Helper package: + - deviceappproxy: send & receive APDUs to a Ledger device + - txparser: simplifies parsing of a raw unsigned BTC transaction +""" +from ledgerblue import comm, commException +from .deviceappproxy import apduabstract, deviceappproxy, deviceappbtc +from .txparser import transaction, txtypes +from . import basetest diff --git a/tests/helpers/apduabstract.py b/tests/helpers/apduabstract.py deleted file mode 100644 index ae72f8ba..00000000 --- a/tests/helpers/apduabstract.py +++ /dev/null @@ -1,166 +0,0 @@ -from typing import List, Dict, Union, Optional -from dataclasses import dataclass, field -from functools import reduce - - -# Type aliases -BytesOrStr = Union[bytes, str] - - -@dataclass -class CApdu: - @dataclass - class Type: - IN: int = 0 - OUT: int = 1 - INOUT: int = 2 - cla: BytesOrStr = field(default="00") - ins: BytesOrStr = field(default="00") - p1: BytesOrStr = field(default="00") - p2: BytesOrStr = field(default="00") - lc: BytesOrStr = field(default="00") - le: BytesOrStr = field(default="00") - data: BytesOrStr = field(default="") - typ: Type = field(default=Type.INOUT) - - -ApduDict = Dict[str, CApdu] - - -class ApduSet: - """Collects a set of CApdu objects and provides their raw version, ready - to be sent to a device/an app" - """ - _apdus: ApduDict = {} - - def __init__(self, apdus: Optional[ApduDict] = None, max_lc: int = 255 ) -> None: - self.apdus = apdus - self.max_lc = max_lc - - def _bytes(self, data: BytesOrStr) -> bytes: - if type(data) is bytes: - return data - if type(data) is int: - return bytes([data]) - if type(data) is str: - return bytes.fromhex(data) - raise TypeError(f"{data} cannot be converted to bytes") - - def _bytesbuf(self, apdu: CApdu, apdu_keys: List[str]) -> bytes: - """Concatenates all @apdu attributes whose names are provided in @apdu_keys, - into a single byte buffer. - """ - return reduce(lambda x, y: x + y, - [self._bytes(getattr(apdu, k)) for k in apdu.__dict__ if k in apdu_keys]) - - @property - def apdus(self) -> Optional[ApduDict]: - return ApduSet._apdus if len(ApduSet._apdus.keys()) > 0 else None - - @apdus.setter - def apdus(self, newApdus: ApduDict, overwrite: bool = False) -> None: - """Sets a new CApsu internal dictionary if it wasn't set at instanciation time, - unless overwrite is True.""" - if not self.apdus or overwrite is True: - if type(newApdus) is not dict: - raise ValueError("Attribute newApdus must be a dictionary containing CApdu instances as values") - ApduSet._apdus = newApdus - - def apdu(self, name: str, - p1: Optional[BytesOrStr] = None, - p2: Optional[BytesOrStr] = None, - data: Optional[BytesOrStr] = None, - le: Optional[BytesOrStr] = None) -> bytes: - """Returns the raw bytes for the APDU requested by name. - """ - if not self.apdus: - raise ValueError("ApduSet object is empty! Provide an ApduDict either at instanciation"\ - " or with the 'apdus' attribute.") - if name not in self.apdus: - raise KeyError(f"{name} APDU is not supported by this instance") - # Compose APDU depending on its type into a byte buffer - self.set_params(key=name, p1=p1, p2=p2, data=data, le=le) - return self._bytesbuf( - self.apdus[name], - ('cla', 'ins', 'p1', 'p2', - 'lc' if self._apdus[name].typ == CApdu.Type.IN or self._apdus[name].typ == CApdu.Type.INOUT - else 'le' if self._apdus[name].typ == CApdu.Type.OUT - else '00', - 'data' if self._apdus[name].typ == CApdu.Type.IN or self._apdus[name].typ == CApdu.Type.INOUT - else '' - ) - ) - - def __setitem__(self, key: str, value: CApdu) -> None: - """Change an existing APDU or add a new one to the APDU dict - """ - if type(value) is not CApdu: - raise ValueError(f"Syntax '{self.__class__.__name__}[{key}] = value' only accept CApdu instances as value") - self.apdus[key] = value - - def set_params(self, key: str, - p1: Optional[BytesOrStr] = None, - p2: Optional[BytesOrStr] = None, - data: Optional[BytesOrStr] = None, - le: Optional[BytesOrStr] = None) -> None: - """Set the parameters and payload of a specific APDU - """ - # Check all params - if self.apdus.keys() is None or key not in self.apdus: - raise KeyError(f"{key} APDU is not supported by this instance (or instance is empty?)") - params_valid = reduce(lambda x, y: x and y, - [True if type(param) in [str, bytes] else False for param in (p1, p2, data)]) - if not params_valid: - raise ValueError("Parameters must either be single byte (p1, p2),multiple bytes (data) or an hex string" - " adhering to these constraints") - # Set APDU parameters & payload - if (p1 is not None and len(self._bytes(p1)) > 1)\ - or (p2 is not None and len(self._bytes(p2)) > 1)\ - or (le is not None and len(self._bytes(le)) > 1): - raise ValueError("When provided, P1, P2 and Le parameters must be 1-byte long") - #Set default values for p1, p2 and le if they were not provided - self.apdus[key].p1 = self._bytes(p1) if p1 is not None else self._bytes("00") - self.apdus[key].p2 = self._bytes(p2) if p2 is not None else self._bytes("00") - self.apdus[key].le = self._bytes(le) if le is not None else self._bytes("00") - # Format the binary APD - if data is not None: - datalen = len(self.apdus[key].data) - self.apdus[key].data = self._bytes(data) - if self.apdus[key].typ in (CApdu.Type.IN, CApdu.Type.INOUT): - self.apdus[key].lc = bytes([datalen if datalen < self.max_lc else 0]) - elif self.apdus[key].typ == CApdu.Type.OUT: - self.apdus[key].le = bytes(le) - - - -### TODO: Not ready, to be completed later to replace list of chunks lengths -#@dataclass -#class Tx: -# @dataclass -# class Inputs: -# prevout_hash: bytes = field(default=bytes(32)) -# prevout_index: bytes = field(default=bytes(4)) -# script_sig_len: bytes # Varint -# script_sig: bytes -# sequence: bytes = field(default=bytes(4)) -# -# @dataclass -# class Outputs: -# value: bytes = field(default=bytes(8)) -# pubkey_script_len: bytes # Varint -# pubkey_script: bytes # Variable length -# -# version: bytes = field(default=bytes(4)) -# flag: Optional[bytes] = field(default=bytes(2)) -# inputs_count: bytes # Varint -# inputs: List[Inputs] # variable length -# outputs_count: bytes # Varint -# outputs: List[Outputs] # variable length -# witness_count: bytes # Varint -# witness: List[Witness] # VAriable length -# locktime: bytes = field(default=bytes(4)) -# -# @classmethod -# def parse(cls, rawtx: BytesOrStr) -> None: -# pass -# diff --git a/tests/helpers/basetest.py b/tests/helpers/basetest.py index d8d5aae4..5f8ad58f 100644 --- a/tests/helpers/basetest.py +++ b/tests/helpers/basetest.py @@ -1,12 +1,16 @@ -from dataclasses import dataclass, field -from typing import Optional, List +from dataclasses import dataclass +from typing import Optional, List, Any import hashlib import base58 +from ledgerblue.commException import CommException +from .deviceappproxy.deviceappproxy import DeviceAppProxy + class NID: MAINNET = bytes.fromhex("00") TESTNET = bytes.fromhex("6f") + class CONSENSUS_BRANCH_ID: OVERWINTER = bytes.fromhex("5ba81b19") SAPLING = bytes.fromhex("76b809bb") @@ -14,53 +18,38 @@ class CONSENSUS_BRANCH_ID: ZCASH = bytes.fromhex("2BB40E60") -@dataclass -class LedgerjsApdu: - commands: List[str] - expected_resp: Optional[str] = field(default=None) - expected_sw: Optional[str] = field(default=None) - check_sig_format: Optional[bool] = field(default=None) - -@dataclass -class TxData: - tx_to_sign: bytes - utxos: List[bytes] - output_paths: List[bytes] - change_path: bytes - expected_sig: List[bytes] - - @dataclass(init=False, repr=False) class BtcPublicKey: def __init__(self, apdu_response: bytes, network_id: NID = NID.TESTNET) -> None: - self.nid: bytes = network_id + self.nid: NID = network_id self.pubkey_len: int = apdu_response[0] - self.pubkey: bytes = apdu_response[1:1+self.pubkey_len] - self.pubkey_comp: bytes = (3 if self.pubkey[0] % 2 else 2).to_bytes(1, 'big') + self.pubkey[1:(self.pubkey_len) >> 1] # -1 not necessary w/ >> - self.pubkey_comp_len: bytes = len(self.pubkey_comp) - self.address_len: int = apdu_response[1+self.pubkey_len] - self.address: str = apdu_response[1+self.pubkey_len+1:1+self.pubkey_len+1+self.address_len].decode() - self.chaincode: bytes = apdu_response[1+self.pubkey_len+1+self.address_len:] + self.pubkey: bytes = apdu_response[1:1 + self.pubkey_len] + self.pubkey_comp: bytes = (3 if self.pubkey[0] % 2 else 2).to_bytes(1, 'big') \ + + self.pubkey[1:self.pubkey_len >> 1] # -1 not necessary w/ >> + self.pubkey_comp_len: int = len(self.pubkey_comp) + self.address_len: int = apdu_response[1 + self.pubkey_len] + self.address: str = apdu_response[1 + self.pubkey_len + 1:1 + self.pubkey_len + 1 + self.address_len].decode() + self.chaincode: bytes = apdu_response[1 + self.pubkey_len + 1 + self.address_len:] self.pubkey_hash: bytes = base58.b58decode(self.address) self.pubkey_hash_len: int = len(self.pubkey_hash) self.pubkey_hash = self.pubkey_hash[1:-4] # remove network id & hash checksum self.pubkey_hash_len = len(self.pubkey_hash) def __repr__(self) -> str: - return f"PublicKey ({self.pubkey_len} bytes) = {self.pubkey.hex()}\n"\ - f"PublicKey (compressed, {self.pubkey_comp_len} bytes) = {self.pubkey_comp.hex()}\n"\ - f"PublicKey hash ({self.pubkey_hash_len} bytes) = {self.pubkey_hash.hex()}\n"\ - f"Base58 address = {self.address}\n"\ - f"Chain code ({len(self.chaincode)} bytes) = {self.chaincode.hex()}\n" + return f" PublicKey ({self.pubkey_len} bytes) = {self.pubkey.hex()}\n"\ + f" PublicKey (compressed, {self.pubkey_comp_len} bytes) = {self.pubkey_comp.hex()}\n"\ + f" PublicKey hash ({self.pubkey_hash_len} bytes) = {self.pubkey_hash.hex()}\n"\ + f" Base58 address = {self.address}\n"\ + f" Chain code ({len(self.chaincode)} bytes) = {self.chaincode.hex()}\n" class BaseTestBtc: """ - Base class for tests of BTC app, contains data validators. + Base class for tests of BTC app, contains data validators. """ - def check_trusted_input(self, - trusted_input: bytes, - out_index: bytes, + @staticmethod + def check_trusted_input(trusted_input: bytes, + out_index: bytes, out_amount: bytes, out_hash: Optional[bytes] = None) -> None: print(f" Magic marker = {trusted_input[:2].hex()}") @@ -71,14 +60,14 @@ def check_trusted_input(self, print(f" SHA-256 HMAC = {trusted_input[48:].hex()}") # Note: Signature value can't be asserted since the HMAC key is secret in the device assert trusted_input[:2] == bytes.fromhex("3200") - assert trusted_input[36:40] == out_index - assert trusted_input[40:48] == out_amount + assert trusted_input[36:40] == out_index + assert trusted_input[40:48] == out_amount if out_hash: assert trusted_input[4:36] == out_hash - def check_signature(self, - resp: bytes, - expected_resp: Optional[bytes]=None) -> None: + @staticmethod + def check_signature(resp: bytes, + expected_resp: Optional[bytes] = None) -> None: # Signature is DER-encoded as: # 30|parity_bit zz 02 xx R 02 yy S sigHashType # with: # - parity_bit: a ledger extension to the BTC standard @@ -93,43 +82,47 @@ def check_signature(self, len_s = resp[offs_s - 1] print(f" OK, response = {resp.hex()}") print(f" - Parity = {'odd' if parity_bit else 'even'}") - print(f" - R = {resp[offs_r:offs_r+len_r].hex()} ({len_r} bytes)") + print(f" - R = {resp[offs_r:offs_r+len_r].hex()} ({len_r} bytes)") print(f" - S = {resp[offs_s:offs_s+len_s].hex()} ({len_s} bytes)") if resp[1] == len(resp) - 3: print(f" - sigHashType = {bytes([resp[-1]]).hex()}") # If no expected sig provided, check sig DER encoding & sigHashType byte only if expected_resp is None: assert resp[0] & 0xFE == 0x30 - assert resp[1] == len_r + len_s + 4 - assert resp[1] in (len(resp) - 3, len(resp) - 2) # "-2" for SignMessage APDU as it doesn't return sigHashType as last byte + assert resp[1] == len_r + len_s + 4 + # "-2" below for SignMessage APDU as it doesn't return sigHashType as last byte + assert resp[1] in (len(resp) - 3, len(resp) - 2) assert resp[offs_r - 2] == resp[offs_s - 2] == 0x02 if resp[1] == len(resp) - 3: assert resp[-1] == 1 else: assert resp == expected_resp - def check_raw_apdu_resp(self, expected: str, received: bytes) -> None: - # Not a very elegant way to skip sections of the received response that vary - # (marked with 2 '-' char per byte to skip in the expected response i.e. '--'), + @staticmethod + def check_raw_apdu_resp(expected: str, received: bytes) -> None: + # Not a very elegant way to skip sections of the received response that vary + # (marked with 2 '-' char per byte to skip in the expected response i.e. '--'), # but does the job. def expected_len(exp_str: str) -> int: tok = exp_str.split('-') dash_count = exp_str.count('-') >> 1 return dash_count + (len("".join([t for t in tok if len(tok)])) >> 1) - + assert len(received) == expected_len(expected) recv = received.hex() - for i in range(len(expected)): - if expected[i] != '-': - assert recv[i] == expected[i] + for exp_char, recv_char in zip(expected, recv): + if exp_char != '-': + assert recv_char == exp_char - def split_pubkey_data(self, data: bytes) -> BtcPublicKey: + @staticmethod + def split_pubkey_data(data: bytes) -> BtcPublicKey: """ Decompose the response from GetWalletPublicKey APDU into its constituents """ return BtcPublicKey(data) - def check_public_key_hash(self, key_data: BtcPublicKey) -> None: + @staticmethod + def check_public_key_hash(key_data: BtcPublicKey) -> None: """TBC""" sha256 = hashlib.new("sha256") ripemd = hashlib.new("ripemd160") @@ -138,3 +131,25 @@ def check_public_key_hash(self, key_data: BtcPublicKey) -> None: pubkey_hash = ripemd.digest() assert len(pubkey_hash) == 20 assert pubkey_hash == key_data.pubkey_hash + + +class BaseTestZcash(BaseTestBtc): + """ + Base class for BTX-derived Zcash tx tests + """ + def send_ljs_apdus(self, apdus: List[Any], device: DeviceAppProxy): + # Send the Get Version APDUs + for apdu in apdus: + try: + response: Optional[bytes] = None + for command in apdu.commands: + response: bytes = device.send_raw_apdu(bytes.fromhex(command)) + if response: + if apdu.expected_resp is not None: + self.check_raw_apdu_resp(apdu.expected_resp, response) + elif apdu.check_sig_format is not None and apdu.check_sig_format is True: + self.check_signature(response) # Only format is checked + except CommException as error: + if apdu.expected_sw is not None and error.sw.hex() == apdu.expected_sw: + continue + raise error diff --git a/tests/helpers/deviceappbtc.py b/tests/helpers/deviceappbtc.py deleted file mode 100644 index c74a7f25..00000000 --- a/tests/helpers/deviceappbtc.py +++ /dev/null @@ -1,84 +0,0 @@ -from typing import Optional, List -from .apduabstract import ApduSet, ApduDict, CApdu, BytesOrStr -from .deviceappproxy import DeviceAppProxy, dongle_connected, CommException - - -class BTC_P1: - # GetPublicKey - SHOW_ADDR = bytes.fromhex("00") - HIDE_ADDR = bytes.fromhex("01") - VAL_TOKEN = bytes.fromhex("02") - # GetTrustedInput, UntrustedHashTxInputStart - FIRST_BLOCK = bytes.fromhex("00") - NEXT_BLOCK = bytes.fromhex("80") - # UntrustedHashTxInputFinalize - MORE_BLOCKS = bytes.fromhex("00") - LAST_BLOCK = bytes.fromhex("80") - CHANGE_PATH = bytes.fromhex("ff") - - -class BTC_P2: - # GetPublicKey - LEGACY_ADDR = bytes.fromhex("00") - P2SH_P2WPKH_ADDR = bytes.fromhex("01") - BECH32_ADDR = bytes.fromhex("02") - # UntrustedHashTxInputStart - STD_INPUTS_ = bytes.fromhex("00") - SEGWIT_INPUTS = bytes.fromhex("02") - BCH_ADDR = bytes.fromhex("03") - OVW_RULES = bytes.fromhex("04") # Overwinter rules (Bitcoin Cash) - SPL_RULES = bytes.fromhex("05") # Sapling rules (Zcash, Komodo) - TX_NEXT_INPUT = bytes.fromhex("80") - - -class DeviceAppBtc(DeviceAppProxy): - - default_chunk_size = 50 - - default_mnemonic = "dose bike detect wedding history hazard blast surprise hundred ankle"\ - "sorry charge ozone often gauge photo sponsor faith business taste front"\ - "differ bounce chaos" - - apdus: ApduDict = { - "getWalletPublicKey": CApdu(cla='e0', ins='40', typ=CApdu.Type.INOUT), - "getTrustedInput": CApdu(cla='e0', ins='42', p2='00', typ=CApdu.Type.INOUT), - "untrustedHashTxInputStart": CApdu(cla='e0', ins='44', typ=CApdu.Type.IN), - "untrustedHashSign": CApdu(cla='e0', ins='48', p1='00', p2='00', typ=CApdu.Type.INOUT), - "untrustedHashTxInputFinalize": CApdu(cla='e0', ins='4a', p2='00', typ=CApdu.Type.INOUT), - # Other APDUs supported by the BTC app not needed for these tests - } - - def __init__(self, - mnemonic: str = default_mnemonic) -> None: - self.btc = ApduSet(DeviceAppBtc.apdus, - max_lc=DeviceAppBtc.default_chunk_size) - super().__init__(mnemonic=mnemonic, - chunk_size=DeviceAppBtc.default_chunk_size) - - - def getTrustedInput(self, - data: BytesOrStr, - chunks_len: Optional[List[int]] = None) -> bytes: - return self.sendApdu("getTrustedInput", "00", "00", data, chunks_lengths=chunks_len) - - def getWalletPublicKey(self, - data: BytesOrStr) -> bytes: - return self.sendApdu("getWalletPublicKey", "00", "00", data) - - def untrustedTxInputHashStart(self, - p1: BytesOrStr, - p2: BytesOrStr, - data: BytesOrStr, - chunks_len: Optional[List[int]] = None) -> bytes: - return self.sendApdu("untrustedHashTxInputStart", p1, p2, data, chunks_lengths=chunks_len) - - def untrustedTxInputHashFinalize(self, - p1: BytesOrStr, - data: BytesOrStr, - chunks_len: Optional[List[int]] = None ) -> bytes: - return self.sendApdu("untrustedHashTxInputFinalize", p1, "00", data) - - def untrustedHashSign(self, - data: BytesOrStr) -> bytes: - return self.sendApdu("untrustedHashSign", "00", "00", data) - diff --git a/tests/helpers/deviceappproxy.py b/tests/helpers/deviceappproxy.py deleted file mode 100644 index 086a2b54..00000000 --- a/tests/helpers/deviceappproxy.py +++ /dev/null @@ -1,118 +0,0 @@ -from typing import Optional, List -from .apduabstract import BytesOrStr -from ledgerblue.comm import getDongle, CommException - - -#decorator that try to connect to a physical dongle before executing a method -def dongle_connected(func: callable) -> callable: - def wrapper(self, *args, **kwargs): - if not hasattr(self, "dongle") or not hasattr(self.dongle, "opened") or not self.dongle.opened: - self.dongle = getDongle(False) - ret = func(self, *args, **kwargs) - self.close() - return ret - return wrapper - - -class DeviceAppProxy: - - def __init__(self, - mnemonic: str = "", - debug: bool = True, - delay_connect: bool = True, - chunk_size: int = 200 + 11) -> None: - self.chunk_size = chunk_size - self.mnemonic = mnemonic - if not delay_connect: - self.dongle = getDongle(debug) - - @dongle_connected - def sendApdu(self, - name: str, - p1: BytesOrStr, - p2: BytesOrStr, - data: Optional[BytesOrStr] = None, - le: Optional[BytesOrStr] = None, - chunks_lengths: Optional[List[int]] = None) -> bytes: - # Get the APDU as bytes & send them to device - apdu = self.btc.apdu(name, p1=p1, p2=p2, data=data, le=le) - hdr = apdu[0:4] - payload = apdu[5:] - - if chunks_lengths: - # Large APDU is split in chunks the lengths of which are provided in chunks_lengths param - offs = 0 - skip_bytes = 0 - for i in range(len(chunks_lengths)): - if i > 0: - hdr = hdr[:2] + (hdr[2] | 0x80).to_bytes(1, 'big') + hdr[3:] - chunk_len = chunks_lengths[i] - - if type(chunk_len) is tuple: - if len(chunk_len) not in (2, 3): - raise ValueError("Tuples in chunks_lengths must contain exactly 2 ou 3 integers e.g. (offset, len) or (len1, skip_len, len2)") - if len(chunk_len) == 2: # chunk_len = (offset, len) - offs = chunk_len[0] - chunk_len = chunk_len[1] - chunk = payload[offs:offs+chunk_len] - else: # chunk_len = (len1, skip_len, len2) - skip_bytes = chunk_len[1] - chunk = payload[offs:offs+chunk_len[0]] - start_chunk2 = offs + chunk_len[0] + skip_bytes - chunk += payload[start_chunk2:start_chunk2+chunk_len[2]] - chunk_len = chunk_len[0] + chunk_len[2] - elif chunk_len != -1: # type is int - chunk = payload[offs:offs+chunk_len] - - if chunk_len == -1: # inputs, total length is in last byte of previous chunks - total_len = int(payload[offs - 1]) + 4 - response = self._send_chunked_apdu(apdu=hdr, data=payload[offs:offs+total_len]) - offs += total_len - else: - capdu = hdr + chunk_len.to_bytes(1,'big') + chunk - print(f"[device <] {capdu.hex()}") - if not hasattr(self, "dongle") or not hasattr(self.dongle, "opened") or not self.dongle.opened: - self.dongle = getDongle(False) # in case a previous self.send_chunked_apdu() call closed it - response = self.dongle.exchange(capdu) - offs += chunk_len + skip_bytes - skip_bytes = 0 - _resp = response.hex() if len(response) else "OK" - print(f"[device >] {_resp}") - else: - # Auto splitting of large APDUs into chunks of equal length until payload is exhausted - response = self._send_chunked_apdu(apdu=hdr, data=payload) - return response - - @dongle_connected - def sendRawApdu(self, - apdu: bytes) -> bytes: - print(f"[device <] {apdu.hex()}") - response = self.dongle.exchange(apdu) - _resp = response.hex() if len(response) else "OK" - print(f"[device >] {_resp}") - return response - - @dongle_connected - def _send_chunked_apdu(self, - apdu: bytes, - data: bytes) -> bytes: - for chunk in [data[i:i + self.chunk_size] for i in range(0, len(data), self.chunk_size)]: - # tmp test overflow - # if len(chunk) < chunkSize: - # print("increasing virtually last apdu") - # chunk += b'\x99' - # 6A82 expected in this case - capdu = apdu + len(chunk).to_bytes(1,'big') + chunk - print(f"[device <] {capdu.hex()}") - response = self.dongle.exchange(bytes(capdu)) - _resp = response.hex() if len(response) else "OK" - print(f"[device >] {_resp}") - apdu = apdu[:2] + (apdu[2] | 0x80).to_bytes(1,'big') + apdu[3:] - - return response - - def close(self) -> None: - if hasattr(self, "dongle"): - if hasattr(self.dongle, "opened") and self.dongle.opened: - self.dongle.close() - self.dongle = None diff --git a/tests/helpers/deviceappproxy/__init__.py b/tests/helpers/deviceappproxy/__init__.py new file mode 100644 index 00000000..b6aebf29 --- /dev/null +++ b/tests/helpers/deviceappproxy/__init__.py @@ -0,0 +1,6 @@ +""" +Helper package that abstract communicating with a Ledger device through ISO-7816 +""" +from .apduabstract import ApduDictType, CApdu, ApduDict +from .deviceappbtc import DeviceAppBtc, BTC_P1, BTC_P2 +from .deviceappproxy import DeviceAppProxy diff --git a/tests/helpers/deviceappproxy/apduabstract.py b/tests/helpers/deviceappproxy/apduabstract.py new file mode 100644 index 00000000..92baf5db --- /dev/null +++ b/tests/helpers/deviceappproxy/apduabstract.py @@ -0,0 +1,159 @@ +from typing import List, Dict, Optional, Union, cast, NewType, Tuple, AnyStr +# from dataclasses import dataclass, field + +ApduType = NewType("ApduType", int) + + +# @dataclass +class CApdu: + """ + Dataclass representing the various parts of a Command APDU (C-APDU), as defined by + the ISO 7916-3 standard (see for details). + + The attributes of that class map the APDU fields defined by the standard as follow: + + | Attribute | ISO 7816-3 | Meaning | + | | field name | | + |:---------:|:----------:|-----------------------------------------------------------------------------| + | cla | CLA | CLAss byte: provides secure apps segregation. | + | ins | INS | INStruction byte: the action the secure app needs to perform | + | p1 | P1 | Parameter byte 1: allows to parameterize the selected action | + | p2 | P2 | Parameter byte 2: as above | + | lc | Lc | Length in bytes of the incoming payload data (0 if None) | + | data | Data | Optional incoming payload data related to action to perform | + | le | Le | Expected length (bytes) of optional outgoing response data (absent if None) | + + Attribute typ is the C-APDU type among: + - INcoming: command provides an Lc-byte long input data payload, expects no response data + - OUTgoing: command provides no input payload, but expects an Le-byte long response back + - INOUT: command is both incoming and outgoing + """ + class Type: + IN: ApduType = 0 + OUT: ApduType = 1 + INOUT: ApduType = 2 + + # Max length data that can be sent in a raw C-APDU payload is the same + # for all CApdu instances hence it is a class attribute + max_lc: int = 255 + + def __init__(self, + typ: ApduType = Type.INOUT, + cla: AnyStr = "00", + ins: AnyStr = "00", + p1: AnyStr = "00", + p2: AnyStr = "00", + data: Union[List[AnyStr], Tuple[AnyStr]] = (), + le: AnyStr = "00", + max_lc: int = 255) -> None: + self.data: List[bytes] = data if data else [] + self.cla: AnyStr = cla + self.ins: AnyStr = ins + self.p1: AnyStr = p1 + self.p2: AnyStr = p2 + self.lc: AnyStr = "00" + self.le: AnyStr = le + self.typ: ApduType = typ + CApdu.max_lc = max_lc + + @staticmethod + def _bytes(data: AnyStr) -> bytes: + if isinstance(data, (bytes, bytearray)): + return data + if isinstance(data, int): + return bytes([cast(int, data)]) + if isinstance(data, str): + return bytes.fromhex(data) + raise TypeError(f"{data} cannot be converted to bytes") + + def _bytesbuf(self, apdu_keys: List[str]) -> bytes: + """Concatenates all @apdu attributes whose names are provided in @apdu_keys, + into a single byte buffer. If an element of apdu_keys is not a CApdu attribute name, + then it must be a string representing an hex integer.""" + return b''.join(self._bytes(getattr(self, k)) if k in self.__dict__ else self._bytes(k) for k in apdu_keys) + + @classmethod + def set_max_lc(cls, max_lc): + cls.max_lc = max_lc + + def set_params(self, + p1: Optional[AnyStr] = None, + p2: Optional[AnyStr] = None, + data: Optional[List[AnyStr]] = None, + le: Optional[AnyStr] = None): + """Updates the p1, p2, data and le attributes a CApdu instance + """ + # Check all params + params_invalid: bool = any(bool(param and len(self._bytes(param)) > 1) for param in (p1, p2, le)) + if params_invalid: + raise ValueError("When provided, P1, P2 and Le parameters must be 1-byte long") + + # Set APDU parameters & payload + self.p1 = self._bytes(p1) if p1 else self._bytes("00") + self.p2 = self._bytes(p2) if p2 else self._bytes("00") + self.le = self._bytes(le) if le else self._bytes("00") + if data: + # Concatenate payload chunks to compute Lc + data_len: int = len(b''.join(data)) + self.data = [self._bytes(d) for d in data if d is not None] + if self.typ in (CApdu.Type.IN, CApdu.Type.INOUT): + self.lc = data_len.to_bytes(1, 'big') if data_len < CApdu.max_lc else b'\x00' + elif self.typ == CApdu.Type.OUT: + self.le = self._bytes(le) if le is not None else b'\x00' + + @property + def header(self) -> bytes: + # Determine APDU type + apdu_is_in_only_or_inout = (self.typ == CApdu.Type.IN or self.typ == CApdu.Type.INOUT) + apdu_is_out_only = (self.typ == CApdu.Type.OUT) + # Concatenate the individual C-APDU header fields into a 5-byte buffer + return self._bytesbuf( + ['cla', 'ins', 'p1', 'p2', 'lc' if apdu_is_in_only_or_inout else 'le' if apdu_is_out_only else '00'] + ) + + +ApduDictType = NewType("ApduDictType", Dict[str, CApdu]) + + +class ApduDict: + """ + Collects a set of CApdu objects and provides their raw byte version. + + Once the payload for an APDU defined in the stored CApdu object is provided, this class + computes the correct values of the Lc and Le bytes that are part of the C-APDU header + before returning the bytes buffer, containing the fully formatted C-APDU ready to be sent + to a secure app running on a Ledger Device or Speculos. + + This class doesn't manage the transport layer of ISO 7816-3 (i.e. T=0/T=1). This part + is delegated to the DeviceAppProxy class. + """ + def __init__(self, apdus: Optional[ApduDictType] = None, max_lc: int = 255) -> None: + # We expect an ApduDictType object which entries each associate a symbolic APDU command name + # to a CApdu instance containing the values of the various fields of that command. + self._apdus = apdus + # self._max_lc = max_lc + # Set CApdu class attribute max_lc through the 1st dict element + list(self._apdus.values())[0].set_max_lc(max_lc) + + @property + def apdus(self) -> Optional[ApduDictType]: + return self._apdus if len(self._apdus.keys()) > 0 else None + + def apdu(self, name: str, + p1: Optional[AnyStr] = None, + p2: Optional[AnyStr] = None, + data: Optional[List[AnyStr]] = None, + le: Optional[AnyStr] = None) -> Tuple[bytes, List[Optional[bytes]]]: + """ + Returns the raw bytes for the C-APDU header requested by name, as a tuple of elements + ready to be unpacked and passed as parameters to the DeviceAppProxy.send_apdu() method. + """ + # Set values provided by caller for p1, p2, data and le in internal ApduDict object. + # When building the full APDU later, the fields not provided will use the defaults set + # in the ApduDict's CApdu instances. + self._apdus[name].set_params(p1=p1, p2=p2, data=data, le=le) + # self.set_params(key=name, p1=p1, p2=p2, data=data, le=le) + + # Return a tuple composed of 2 byte buffers: the C-APDU header buffer (i.e. CLA || INS || P1 || P2 || Lc/Le) + # and the payload data buffer. + return self._apdus[name].header, self._apdus[name].data diff --git a/tests/helpers/deviceappproxy/deviceappbtc.py b/tests/helpers/deviceappproxy/deviceappbtc.py new file mode 100644 index 00000000..9a98b4ab --- /dev/null +++ b/tests/helpers/deviceappproxy/deviceappbtc.py @@ -0,0 +1,331 @@ +from typing import Optional, List, cast, Union, AnyStr +from .apduabstract import ApduDict, ApduDictType, CApdu +from .deviceappproxy import DeviceAppProxy +# Dependency to txparser could be avoided but at the expense of a more complex design +# which I don't have time for. +from ..txparser.transaction import Tx, TxType, TxVarInt, TxHashMode, ZcashExtHeader, ZcashExtFooter, byteorder, TxInput + + +class BTC_P1: + # GetPublicKey + SHOW_ADDR = bytes.fromhex("00") + HIDE_ADDR = bytes.fromhex("01") + VAL_TOKEN = bytes.fromhex("02") + # GetTrustedInput, UntrustedHashTxInputStart + FIRST_BLOCK = bytes.fromhex("00") + NEXT_BLOCK = bytes.fromhex("80") + # UntrustedHashTxInputFinalize + MORE_BLOCKS = bytes.fromhex("00") + LAST_BLOCK = bytes.fromhex("80") + CHANGE_PATH = bytes.fromhex("ff") + + +class BTC_P2: + # GetPublicKey + LEGACY_ADDR = bytes.fromhex("00") + P2SH_P2WPKH_ADDR = bytes.fromhex("01") + BECH32_ADDR = bytes.fromhex("02") + # UntrustedHashTxInputStart + STD_INPUTS = bytes.fromhex("00") + SEGWIT_INPUTS = bytes.fromhex("02") + BCH_ADDR = bytes.fromhex("03") + OVW_RULES = bytes.fromhex("04") # Overwinter rules (Bitcoin Cash) + SPL_RULES = bytes.fromhex("05") # Sapling rules (Zcash, Komodo) + TX_NEXT_INPUT = bytes.fromhex("80") + + +class DeviceAppBtc(DeviceAppProxy): + default_chunk_size = 50 + default_mnemonic = "dose bike detect wedding history hazard blast surprise hundred ankle" \ + "sorry charge ozone often gauge photo sponsor faith business taste front" \ + "differ bounce chaos" + + apdus: ApduDictType = { + "GetWalletPublicKey": CApdu(cla='e0', ins='40', data=[], typ=CApdu.Type.INOUT), + "GetTrustedInput": CApdu(cla='e0', ins='42', p2='00', data=[], typ=CApdu.Type.INOUT), + "UntrustedHashTxInputStart": CApdu(cla='e0', ins='44', data=[], typ=CApdu.Type.IN), + "UntrustedHashSign": CApdu(cla='e0', ins='48', p1='00', p2='00', data=[], typ=CApdu.Type.INOUT), + "UntrustedHashTxInputFinalize": CApdu(cla='e0', ins='4a', p2='00', data=[], typ=CApdu.Type.INOUT), + # Other APDUs supported by the BTC app not needed for these tests but can be added along with the + # appropriate interface API method + } + + def __init__(self, + mnemonic: str = default_mnemonic) -> None: + self.btc = ApduDict(DeviceAppBtc.apdus, max_lc=DeviceAppBtc.default_chunk_size) + self._tx_endianness: str = 'little' + super().__init__(mnemonic=mnemonic, chunk_size=DeviceAppBtc.default_chunk_size) + + @staticmethod + def _get_input_index(tx: Tx, _input: bytes, endianness: byteorder = 'little'): + # Extract prev tx output idx from given input + standard_idx_offset = 33 + trusted_input_idx_offset = 38 + if _input[0] in (0x00, 0x02): # legacy or segwit BTC tx input + out_idx: int = int.from_bytes( + _input[standard_idx_offset:standard_idx_offset + 4], endianness) + elif (_input[0], _input[1]) == (0x01, 0x38): # TrustedInput + out_idx: int = int.from_bytes( + _input[trusted_input_idx_offset:trusted_input_idx_offset + 4], endianness) + else: + raise ValueError("Invalid input format, must begin with a 0x00, 0x01 or 0x02 byte") + # search in the parsed tx inputs the one w/ the out_index found + for inp in tx.inputs: + if inp.prev_tx_out_index.val == out_idx: + return tx.inputs.index(inp) + return None + + @staticmethod + def _get_utxo_from_input(tx_input: TxInput, utxos: List[Tx]) -> Tx: + # For now, test must order UTXOs in the same order as their matching hash in the tx to sign + # Nice to have for later?: utxos = [{"1st four bytes of utxo_tx hash" = utxo_tx}, ...]. + utxo = [utxo for utxo in utxos if tx_input.prev_tx_hash.hex() == utxo.hash] + if len(utxo) > 1: + raise ValueError("The UTXO list used in this test contains several UTXOs with an identical hash") + return utxo[0] + + def _get_formatted_inputs(self, + mode: TxHashMode, + parsed_tx: Tx, + parsed_utxos: List[Tx], + tx_inputs: Optional[List[bytes]]) -> List[bytes]: + """ + Returns a list of inputs formatted as either relaxed, Segwit or trusted inputs, up to + but not including the input script length byte + """ + if mode.is_relaxed_input_hash: + # Inputs from untrusted legacy BTC tx + # 00||input from tx (i.e. prevout hash||prevout index) + formatted_input = [ + bytes.fromhex("00") + _input.prev_tx_hash + _input.prev_tx_out_index.buf + for _input in parsed_tx.inputs + ] + elif mode.is_trusted_input_hash: + # TrustedInputs from legacy BTC, Segwit BTC or Zcash Ovw/Sapling txs + assert tx_inputs is not None + # 01||len(trusted_input)||trusted_input + formatted_input = [ + bytes.fromhex("01") + bytes([len(_input)]) + _input + for _input in tx_inputs + ] + elif mode.is_segwit_input_hash or mode.is_sapling_input_hash: + # Inputs from non-trusted Segwit or Zcash Sapling tx + assert parsed_utxos is not None + # 02||input from tx (i.e. prevout hash||prevout index)||prevout_amount + # with prev_amount in a utxo + formatted_input: List = [] + for _input in parsed_tx.inputs: + utxo: Tx = self._get_utxo_from_input(tx_input=_input, utxos=parsed_utxos) + amount: bytes = utxo.outputs[_input.prev_tx_out_index.val].value.buf + formatted_input.append(bytes.fromhex("02") + _input.prev_tx_hash + + _input.prev_tx_out_index.buf + amount) + elif mode.is_bcash_input_hash: + # TODO: write code for Bitcoin cash inputs hash + raise NotImplementedError("Support for Bcash tx in tests not yet active") + else: + raise ValueError(f"Invalid hash mode '{mode}'") + return formatted_input + + # Class API reflects app APDU interface + def get_trusted_input(self, + prev_out_index: int, + parsed_tx: Tx) -> bytes: + """ + Computes the lengths of the chunks that will be sent as APDU payloads. Depending on the APDU + the BTC app accepts payloads (composed from the tx and other data) of specific lengths + See https://blog.ledger.com/btchip-doc/bitcoin-technical-beta.html#_get_trusted_input. + See also https://github.com/zcash/zips/blob/master/protocol/protocol.pdf p. 81 for Zcash tx description + """ + prevout_idx_be: bytes = prev_out_index.to_bytes(4, 'big') + # APDU accepts chunks in the order below: + # 1. desired prevout index (BE) || tx version (|| VersionGroupId if Zcash) || tx input count + payload_chunks: List[AnyStr] = [ + prevout_idx_be + parsed_tx.version.buf + cast(ZcashExtHeader, parsed_tx.header.ext).version_group_id.buf + + parsed_tx.input_count.buf + if parsed_tx.type in (TxType.Zcash, TxType.ZcashSapling) + else prevout_idx_be + parsed_tx.version.buf + parsed_tx.input_count.buf + ] + # 2. For each input: + # prevout hash || prevout index || input script length || input script (if present) || input sequence + for _input in parsed_tx.inputs: + payload_chunks.append(_input.prev_tx_hash + _input.prev_tx_out_index.buf + _input.script_len.buf + + _input.script + _input.sequence_nb.buf) + # 3. tx output count + payload_chunks.append(parsed_tx.output_count.buf) + # 3. For each output: + # output value || output script length || output script (if present) + for _output in parsed_tx.outputs: + payload_chunks.append(_output.value.buf + _output.script_len.buf + _output.script) + # 4. tx locktime & Zcash data if present + if parsed_tx.type in (TxType.Zcash, TxType.ZcashSapling): + # BTC app inner protocol requires that a length varint be present before the zcash data from the tx + # (although this length byte doesn't exist in the Zcash tx). + footer: ZcashExtFooter = cast(ZcashExtFooter, parsed_tx.footer.ext) + footer_buf: bytes = b''.join(v.buf if hasattr(v, 'buf') else v for v in list(footer.__dict__.values()) if v) + payload_chunks.extend([parsed_tx.lock_time.buf + TxVarInt.to_bytes(len(footer_buf), 'little'), footer_buf]) + else: + payload_chunks.append(parsed_tx.lock_time.buf) + + return self.send_apdu(*self.btc.apdu("GetTrustedInput", p1=BTC_P1.FIRST_BLOCK, p2="00", data=payload_chunks)) + + def get_wallet_public_key(self, + data: AnyStr) -> bytes: + return self.send_apdu( + *self.btc.apdu("GetWalletPublicKey", p1=BTC_P1.SHOW_ADDR, p2=BTC_P2.LEGACY_ADDR, data=[data])) + + # pylint: disable=too-many-branches + def untrusted_hash_tx_input_start(self, + parsed_tx: Tx, + parsed_utxos: List[Tx], + inputs: Optional[List[bytes]] = None, + input_num: Optional[int] = None, + mode: TxHashMode = TxHashMode(TxHashMode.LegacyBtc | TxHashMode.Trusted + | TxHashMode.WithScript), + endianness: byteorder = 'little') -> bytes: + """Hash the inputs of the tx data""" + def _get_p2() -> AnyStr: + if mode.is_hash_with_script: + return BTC_P2.TX_NEXT_INPUT + if mode.is_segwit_input_hash: + return BTC_P2.SEGWIT_INPUTS + if mode.is_bcash_input_hash: + return BTC_P2.BCH_ADDR + if mode.is_zcash_input_hash: + return BTC_P2.OVW_RULES + if mode.is_sapling_input_hash: + return BTC_P2.SPL_RULES + raise ValueError(f"Invalid hash mode requested") + + def pubkey_hash_from_script(pubkey_script: bytes) -> bytes: + idx: int = 0 + slen: int = len(pubkey_script[idx:]) + if slen < 20: + raise ValueError("scriptPubkey length cannot be < 20 bytes") + while slen > 20 and pubkey_script[idx] != 20: # length of pubkey hash, always 20 + idx += 1 + slen = len(pubkey_script[idx:]) + return pubkey_script[idx + 1:idx + 1 + 20] + + if mode.is_trusted_input_hash and not inputs: + raise ValueError("Argument 'inputs' cannot be None when the mode argument's 'Trusted' bit is set") + if mode.is_btc_or_bcash_input_hash and not input_num: + raise ValueError("Argument 'input_num' cannot be None when either of the mode argument's 'Bitcoin' or" + "BitcoinCash bits are set") + + # Format all inputs in the tx according to their nature (relaxed, trusted or legacy segwit) + formatted_inputs: List[bytes] = self._get_formatted_inputs( + mode=mode, + parsed_tx=parsed_tx, + parsed_utxos=parsed_utxos, + tx_inputs=inputs if mode.is_trusted_input_hash else None) + + scripts: List[bytes] = [] + inputs_iter = parsed_tx.inputs if input_num is None else [parsed_tx.inputs[input_num]] + for cur_input_num, _input in enumerate(inputs_iter): + utxo_tx = self._get_utxo_from_input(tx_input=_input, utxos=parsed_utxos) + script_pubkey = utxo_tx.outputs[_input.prev_tx_out_index.val].script + + if mode.is_btc_or_bcash_input_hash: + # From btc.asc: "The input scripts shall be prepared by the host for the transaction signing process as + # per bitcoin rules: the current input script being signed shall be the previous output script (or the + # redeeming script when consuming a P2SH output, or the scriptCode when consuming a BIP 143 output), + # and other input script shall be null." + scripts.append(script_pubkey if cur_input_num == input_num else None) + elif mode.is_segwit_zcash_or_sapling_input_hash: + # From btc.asc: "When using Segregated Witness Inputs or Overwinter/Sapling, the signing mechanism + # differs slightly : + # - The transaction shall be processed first with all inputs having a null script length + # - Then each input to sign shall be processed as part of a pseudo transaction with a single input + # and no outputs. + if mode.is_segwit_input_hash and mode.is_hash_with_script \ + and script_pubkey[0:3] != bytes.fromhex("76a914") and script_pubkey[-2:] != bytes.fromhex("88ac"): + # Segwit consensus rules state that if an input from the tx to sign refers to a Segwit prev_tx, + # then the input script to hash with that input shall be: + # 19 || 76a914 || 20-byte pubkey hash from prev_tx's requested output || 88ac + scripts.append(bytes.fromhex("76a914") + pubkey_hash_from_script(script_pubkey) + + bytes.fromhex("88ac")) + else: + scripts.append(script_pubkey) + else: + raise NotImplementedError(f"Unsupported hashing mode provided: {mode}") + + # version || input count + # Note: input_count is set to 1 when sending inputs individually with their script + version_chunk = parsed_tx.version.buf + cast(ZcashExtHeader, parsed_tx.header.ext).version_group_id.buf \ + if mode.is_zcash_input_hash or mode.is_sapling_input_hash \ + else parsed_tx.version.buf + payload_chunks = [ + version_chunk + bytes.fromhex("01") + if mode.is_hash_with_script and mode.is_segwit_zcash_or_sapling_input_hash + else version_chunk + parsed_tx.input_count.buf + ] + # Compose a list of: input || script_len (possibly 0) || script (possibly None) || sequence_nb + for f_input, script in zip(formatted_inputs, scripts): + input_idx = self._get_input_index(parsed_tx, f_input, endianness) + # add input with or without input script, depending on hashing phase and coin type: + # - Legacy BTC, Bcash (TBC for the latter): app needs only 1 hashing phase so hash input with its script + # - Segwit BTC, Zcash: app needs 2 hashing phases for inputs, one w/o scripts and 1 with scripts + with_script = (mode.is_btc_or_bcash_input_hash and script is not None) \ + or (mode.is_segwit_zcash_or_sapling_input_hash and mode.is_hash_with_script) + if with_script: + payload_chunks.extend( + [ # [input||script_len, script||sequence] + f_input + TxVarInt.to_bytes(len(script), 'little'), + script + parsed_tx.inputs[input_idx].sequence_nb.buf + ]) + else: # Hash inputs w/o scripts + payload_chunks.extend( + [ # [input||0 (no script), sequence] + f_input + b'\x00', parsed_tx.inputs[input_idx].sequence_nb.buf + ]) + + p2 = _get_p2() + return self.send_apdu( + *self.btc.apdu("UntrustedHashTxInputStart", p1=BTC_P1.FIRST_BLOCK, p2=p2, data=payload_chunks)) + + def untrusted_hash_tx_input_finalize(self, + p1: AnyStr, + data: Union[AnyStr, Tx]) -> bytes: + """ + Submit either tx outputs or change path to hashing, depending on value of p1 argument + """ + param1: bytes = bytes.fromhex(p1) if isinstance(p1, str) else p1 + if param1 in [b'\x00', b'\x80']: + # Tx outputs path submission + parsed_tx: Tx = data + # output_count||repeated(output_amount||scriptPubkey) + payload_chunks = [parsed_tx.output_count.buf] + payload_chunks.extend([ + _output.value.buf + _output.script_len.buf + _output.script + for _output in parsed_tx.outputs + ]) + payload_chunks = [b''.join(payload_chunks)] + elif param1 == b'\xFF': + payload_chunks = [data] + else: + raise ValueError(f"Invalid value for parameter p1: {p1}") + return self.send_apdu(*self.btc.apdu("UntrustedHashTxInputFinalize", p1=p1, p2="00", data=payload_chunks)) + + def untrusted_hash_sign(self, + parsed_tx: Tx, + output_path: Optional[bytes] = None) -> bytes: + """ + Perform hash signature with following payload: + Num_derivs||Dest output path||User validation code length (0x00)||tx locktime||sigHashType(always 0x01) + Supports Zcash app-specific intermediate signing on an empty ouput path/expiry_height by passing + output_path = None + """ + if (parsed_tx.type is TxType.Zcash and cast(ZcashExtHeader, parsed_tx.header.ext).overwintered_flag is True) \ + or parsed_tx.type is TxType.ZcashSapling: # See Zcash consensus rules + _output_path = bytes.fromhex("0000") if output_path is None else output_path + exp_height = bytes.fromhex("00000000") if (output_path is None or parsed_tx.footer.ext is None) \ + else cast(ZcashExtFooter, parsed_tx.footer.ext).expiry_height.buf[::-1] # big endian, as per BTC doc + else: + _output_path = output_path + exp_height = None + data = _output_path + bytes.fromhex("00") + parsed_tx.lock_time.buf + bytes.fromhex("01") + if exp_height: + data += exp_height + + return self.send_apdu(*self.btc.apdu("UntrustedHashSign", p1="00", p2="00", data=[data]), + p1_msb_means_next=False) diff --git a/tests/helpers/deviceappproxy/deviceappproxy.py b/tests/helpers/deviceappproxy/deviceappproxy.py new file mode 100644 index 00000000..5145fec1 --- /dev/null +++ b/tests/helpers/deviceappproxy/deviceappproxy.py @@ -0,0 +1,157 @@ +import subprocess +import time +from typing import Optional, Union, List, AnyStr, cast +from ledgerblue.comm import getDongle +from ledgerblue.commTCP import DongleServer +from .apduabstract import CApdu + + +# decorator that try to connect to a physical dongle before executing a method +def dongle_connected(func: callable) -> callable: + def wrapper(self, *args, **kwargs): + if not hasattr(self, "dongle") or not hasattr(self.dongle, "opened") or not self.dongle.opened: + self.dongle: DongleServer = cast(DongleServer, getDongle(False)) + ret = func(self, *args, **kwargs) + self.close() + return ret + return wrapper + + +class DeviceAppProxy: + + def __init__(self, + mnemonic: str = "", + debug: bool = True, + delay_connect: bool = True, + chunk_size: int = 200 + 11) -> None: + self.dongle: Optional[DongleServer] = None + self.chunk_size = chunk_size + self.mnemonic = mnemonic + self.process = None + if not delay_connect: + self.dongle = getDongle(debug) + + @dongle_connected + def send_apdu(self, + apdu: Union[CApdu, bytes], + data: Optional[List[AnyStr]] = None, + p1_msb_means_next: bool = True) -> AnyStr: + """Send APDUs to a Ledger device.""" + def _bytes(str_bytes: AnyStr) -> bytes: + ret = str_bytes if isinstance(str_bytes, bytes) \ + else bytes([cast(int, str_bytes)]) if isinstance(str_bytes, int) \ + else bytes.fromhex(str_bytes) if isinstance(str_bytes, str) else None + if ret: + return ret + raise TypeError(f"{str_bytes} cannot be converted to bytes") + + def _set_p1(header: bytearray, + data_chunk: bytes, + chunks_list: List[bytes], + p1_msb_is_next_blk: bool) -> None: + if p1_msb_is_next_blk: + header[2] |= 0x80 # Set "Next block" signalization bit after 1st chunk. + elif data_chunk == chunks_list[-1]: # And p1 msb means "last block" + header[2] |= 0x80 # Set "Last block" signalization bit for last chunk + + def _send_chunked_apdu(apdu_header: bytearray, + apdu_payload: bytes, + p1_msb_is_next: bool) -> bytes: + resp: Optional[bytes] = None + chunks = [apdu_payload[i:i + self.chunk_size] for i in range(0, len(apdu_payload), self.chunk_size)] + for chunk in chunks: + c_apdu = apdu_header + len(chunk).to_bytes(1, 'big') + chunk + print(f"[device <] {c_apdu.hex()}") + resp = self.dongle.exchange(bytes(c_apdu)) + chunk_resp = resp.hex() if len(resp) > 0 else "OK" + print(f"[device >] {chunk_resp}") + _set_p1(apdu_header, chunk, chunks, p1_msb_is_next) + return resp + + # Get the APDU as bytes & send them to device + hdr: bytearray = bytearray(apdu[0:4]) + response: Optional[AnyStr] = None + + if data and len(data) > 1: + # Payload already split in chunks of the appropriate lengths + payload_chunks = [_bytes(d) for d in data] + for _chunk in payload_chunks: + capdu = bytearray(hdr + len(_chunk).to_bytes(1, 'big') + _chunk) + print(f"[device <] {capdu.hex()}") + if not hasattr(self, "dongle") or not hasattr(self.dongle, "opened") or not self.dongle.opened: + # In case a previous _send_chunked_apdu() call closed the dongle + self.dongle = getDongle(False) + response = self.dongle.exchange(capdu) + _resp = response.hex() if len(response) > 0 else "OK" + print(f"[device >] {_resp}") + _set_p1(hdr, _chunk, payload_chunks, p1_msb_means_next) + else: + # Payload is a single chunk. Perform auto splitting, if necessary, of large payloads into chunks of + # equal length until payload is exhausted + response = _send_chunked_apdu(apdu_header=hdr, + apdu_payload=data[0], + p1_msb_is_next=p1_msb_means_next) + return response + + @dongle_connected + def send_raw_apdu(self, + apdu: bytes) -> AnyStr: + print(f"[device <] {apdu.hex()}") + response: AnyStr = self.dongle.exchange(apdu) + _resp: Union[bytes, str] = response.hex() if len(response) > 0 else "OK" + print(f"[device >] {_resp}") + return response + + def close(self) -> None: + if hasattr(self, "dongle"): + if hasattr(self.dongle, "opened") and self.dongle.opened: + cast(DongleServer, self.dongle).close() + self.dongle = None + + def run(self, + speculos_path: str, + app_path: str, + library_path: Optional[str] = None, + model: Union[str, str] = 'nanos', + sdk: str = '1.6', + args: Optional[List] = None, + headless: bool = True, + finger_port: int = 0, + deterministic_rng: str = "", + rampage: str = ""): + """Launch an app within Speculos""" + + # if the app is already running, do nothing + if self.process: + return + + cmd = [speculos_path, '--seed', self.mnemonic, '--model', model, '--sdk', sdk] + if args: + cmd += args + if headless: + cmd += ['--display', 'headless'] + if finger_port: + cmd += ['--finger-port', str(finger_port)] + if deterministic_rng: + cmd += ['--deterministic-rng', deterministic_rng] + if rampage: + cmd += ['--rampage', rampage] + if library_path: + cmd += ['-l', 'Bitcoin:' + library_path] + cmd += [app_path] + + print('[*]', cmd) + self.process = subprocess.Popen(cmd) + time.sleep(1) + + def stop(self): + # if the app is already running, do nothing + if not self.process: + return + + if self.process.poll() is None: + self.process.terminate() + time.sleep(0.2) + if self.process.poll() is None: + self.process.kill() + self.process.wait() diff --git a/tests/helpers/txparser/__init__.py b/tests/helpers/txparser/__init__.py new file mode 100644 index 00000000..1245f92d --- /dev/null +++ b/tests/helpers/txparser/__init__.py @@ -0,0 +1,5 @@ +""" +Helper package to parse raw unsigned Bitcoin or Zcash transactions +""" +from .txtypes import * +from .transaction import Tx, TxParse diff --git a/tests/helpers/txparser/transaction.py b/tests/helpers/txparser/transaction.py new file mode 100644 index 00000000..4ae92b9f --- /dev/null +++ b/tests/helpers/txparser/transaction.py @@ -0,0 +1,405 @@ +from io import BytesIO, SEEK_CUR, SEEK_END +from copy import deepcopy +from dataclasses import dataclass +from typing import Optional, List, Union, cast, Any, Tuple # >= 3.6 +from hashlib import sha256 +# Shorter list of unused types from wildcard import than the actually used ones +# pylint: disable=unused-wildcard-import +from .txtypes import * + + +@dataclass +class SegwitExtHeader(TxExtension): + """Stores the Marker & Flag bytes of a segwit-enabled Bitcoin transaction""" + marker: u8 + flag: u8 + + +@dataclass +class SegwitExtFooter(TxExtension): + """Stores the witness data of a Segwit-enabled Bitcoin transaction""" + class WitnessData: + class Sig: + r: bytes + s: bytes + sig: Sig + other: bytes + witness_count: varint # Bytes representation not needed so varint as type here instead of TxVarInt is fine + witness_len: varint # Same as above + witness: List[WitnessData] + + +@dataclass +class ZcashExtHeader(TxExtension): + """Stores the transaction fields secific to Zcash added to the header of the BTC raw tx""" + overwintered_flag: bool + version_group_id: TxInt4 + + +@dataclass +class ZcashExtFooter(TxExtension): + """Stores the transaction fields secific to Zcash appended to the end of the raw BTC tx""" + expiry_height: TxInt4 + value_balance: TxInt8 + shielded_spend_count: TxVarInt # Number of SpendDescription + shielded_spend: bytes # 384 bytes per SpendDescription + shielded_output_count: TxVarInt # Number of OutputDescription + shielded_output: bytes # 648 bytes per OutputDescription + join_split_count: TxVarInt # Number of JoinSplit desc + join_split: bytes # 1698 bytes if tx version >= 4 else 1802 bytes if 2 <= version < 4 + join_split_pubkey: bytes32 + join_split_sig: bytes64 + binding_sig: bytes32 + + +# BTC transaction description dictionaries +@dataclass +class TxInput: + prev_tx_hash: bytes32 + prev_tx_out_index: TxInt4 + script_len: TxVarInt + script: bytes + sequence_nb: TxInt4 + + +@dataclass +class TxOutput: + value: TxInt8 + script_len: TxVarInt + script: bytes + + +@dataclass +class Tx: + """ + Helper class that eases the parsing of a raw unsigned Bitcoin, Bitcoin segwit or Zcash transaction. + """ + type: txtype + hash: Optional[str] + version: TxInt4 + header: Optional[TxHeader] + input_count: TxVarInt + inputs: List[TxInput] + output_count: TxVarInt + outputs: List[TxOutput] + lock_time: TxInt4 + footer: Optional[TxFooter] + + +class TxParse: + """ + Bitcoin and Bitcoin-derived raw transaction parser. + + Usage: + + - Parse the raw tx into a Python TypedDict object: + ``parsed_tx = TxParse.from_raw(raw_btc_tx)`` + """ + + # pylint: disable=too-many-statements + @classmethod + def from_raw(cls, + raw_tx: Union[bytes, str], + endianness: byteorder = 'little') -> Tx: + """ + Returns a TX object with members initialized from the parsing of the rawTx parameter + + :param raw_tx: The raw transaction to parse. Supported transactions types are: + Bitcoin, Bitcoin Segwit, Zcash + + :param endianness: The endianness of values in the raw tx among 'little' or 'big'. + Defaults to 'little' (i.e. BTC & derivatives). + + :return: A Tx class (of type TypedDict) with all members initialized. + :raise ValueError: If the transaction is malformed or is of an unsupported type. + """ + + # Internal utilities + def _hash(tx: Union[Tx, bytes], show_hashed_items: bool = False) -> str: + """Double SHA-256 hash a raw tx or a parsed tx. """ + def _recursive_hash_obj(obj: Any, + hasher: Any, + ignored_fields: Union[List, Tuple], + path: list, + show_path: bool = False) -> None: + """Recursive hashing of all significant items of a composite object. + This inner function is written in a way could be made to an independent one, + able to hash the content of any composite dataclass or dict object.""" + if obj and not isinstance(obj, (bytes, bytearray)): + # Each items in a list of objects must be parsed entirely + if isinstance(obj, list): + for i, item in enumerate(obj): + path.append(str(i + 1)) # Display the item rank in the list + _recursive_hash_obj(item, hasher, ignored_fields, path, show_path) + path.pop() + else: + # Recursively descend into object + attrs = list(obj.__dict__.items()) + for key, value in attrs: + # Ignore fields that shan't be hashed => explicitly test for segwit types + # pylint: disable=C0123 + if key not in ignored_fields and value is not None and \ + type(value) not in (SegwitExtHeader, SegwitExtFooter): # Precise type check + tmp = path[:] + tmp.append(key) + _recursive_hash_obj(getattr(obj, key), hasher, ignored_fields, tmp, show_path) + elif isinstance(obj, (bytes, bytearray)): + # Terminal byte object, add it to the hash + if show_path: + print(f"Adding to hash: {'/'.join(path)} = {cast(bytes, obj).hex()}") + hasher.update(cast(bytes, obj)) + # else return without hashing a None object (not supported by sha256()) + + h1, h2 = (sha256(), sha256()) + if isinstance(tx, (bytes, bytearray)): + # Raw tx => hash everything in one go. /!\ Should not be used with a Segwit tx, + # use a parsed tx object instead for the hash to be correctly computed. + h1.update(tx) + elif tx.type == TxType.Segwit: + # Parsed tx => Recursively hash the items in the tx, ignoring the ones that should not + # be included in the hash, among which the Segwit marker, flag & witnesses. Change "show_path" + # argument to True to display the data that is being hashed. + _recursive_hash_obj(obj=tx, hasher=h1, ignored_fields=('type', 'hash', 'val'), + path=[], show_path=show_hashed_items) + h2.update(h1.digest()) + tx_hash: str = h2.hexdigest() + print(f"=> Computed tx hash = {tx_hash}\n") + return tx_hash + + def _read_varint(buf: BytesIO, + prefix: Optional[bytes] = None, + bytes_order: byteorder = 'little') -> TxVarInt: + """Returns the size encoded as a varint in the next 1 to 9 bytes of buf.""" + return TxVarInt.from_raw(buf, prefix, bytes_order) + + def _read_bytes(buf: BytesIO, size: int) -> bytes: + """Returns the next 'size' bytes read from 'buf'.""" + b: bytes = buf.read(size) + + if len(b) < size: + raise IOError(f"Cant read {size} bytes in buffer!") + return b + + def _read_uint(buf: BytesIO, + bytes_len: int, + bytes_order: byteorder = 'little') -> int: + """Returns the arbitrary-length integer value encoded in the next 'bytes_len' bytes of 'buf'.""" + b: bytes = buf.read(bytes_len) + if len(b) < bytes_len: + raise ValueError(f"Can't read next u{bytes_len * 8} from raw tx!") + return int.from_bytes(b, bytes_order) + + def _read_u8(buf: BytesIO) -> u8: + """Returns the next byte in 'buf'.""" + return cast(u8, _read_uint(buf, 1)) + + def _read_u16(buf: BytesIO, bytes_order: byteorder = 'little') -> u16: + """Returns the integer value encoded in the next 2 bytes of 'buf'.""" + return cast(u16, _read_uint(buf, 2, bytes_order)) + + def _read_u32(buf: BytesIO, bytes_order: byteorder = 'little') -> u32: + """Returns the integer value encoded in the next 4 bytes of 'buf'.""" + return cast(u32, _read_uint(buf, 4, bytes_order)) + + def _read_tx_int(buf: BytesIO, count: int, bytes_order: byteorder) -> (int, bytes): + tmp: bytes = _read_bytes(buf, count) + return int.from_bytes(tmp, bytes_order), deepcopy(tmp) + + def _parse_inputs(buf: BytesIO, + in_count: int, + bytes_order: byteorder = 'little') -> List[TxInput]: + """Returns a list of TxInputs containing the raw tx's input fields.""" + _inputs: List[TxInput] = [] + for _ in range(in_count): + prev_tx_hash: bytes32 = cast(bytes32, _read_bytes(buf, 32)) + + int_val, bytes_val = _read_tx_int(buf, 4, bytes_order) + prev_tx_out_index: TxInt4 = TxInt4( + val=cast(u32, int_val), + buf=cast(bytes4, bytes_val) + ) + # TODO: if present, for non-segwit tx, parse into a signatures (r, s, pubkey) object? + in_script_len: TxVarInt = _read_varint(buf) + in_script: bytes = _read_bytes(buf, in_script_len.val) + + int_val, bytes_val = _read_tx_int(buf, 4, bytes_order) + sequence_nb: TxInt4 = TxInt4( + val=cast(u32, int_val), + buf=cast(bytes4, bytes_val) + ) + _inputs.append( + TxInput( + prev_tx_hash=prev_tx_hash, + prev_tx_out_index=prev_tx_out_index, + script_len=in_script_len, + script=in_script, + sequence_nb=sequence_nb)) + return _inputs + + def _parse_outputs(buf: BytesIO, + out_count: int, + bytes_order: byteorder = 'little') -> List[TxOutput]: + """Returns a list of TxOutputs containing the raw tx's output fields.""" + _outputs: List[TxOutput] = [] + for _ in range(out_count): + int_val, bytes_val = _read_tx_int(buf, 8, bytes_order) + value: TxInt8 = TxInt8( + val=cast(u64, int_val), + buf=cast(bytes8, bytes_val) + ) + out_script_len: TxVarInt = _read_varint(buf) + out_script: bytes = _read_bytes(buf, out_script_len.val) + _outputs.append( + TxOutput( + value=value, + script_len=out_script_len, + script=out_script)) + return _outputs + + def _parse_zcash_footer(buf: BytesIO, bytes_order: byteorder = 'little') -> Optional[ZcashExtFooter]: + expiry_height: Optional[TxInt4] = None + value_balance: Optional[TxInt8] = None + shielded_spend_count: Optional[TxVarInt] = None + shielded_spend: Optional[bytes] = None + shielded_output_count: Optional[TxVarInt] = None + shielded_output: Optional[bytes] = None + join_split_count: Optional[TxVarInt] = None + join_split: Optional[bytes] = None + join_split_pubkey: Optional[bytes32] = None + join_split_sig: Optional[bytes64] = None + binding_sig: Optional[bytes32] = None + + if version.val >= 3: + iv, bv = _read_tx_int(buf, 4, bytes_order) + expiry_height = TxInt4(val=cast(u32, iv), buf=cast(bytes4, bv)) + if version.val >= 4: + iv, bv = _read_tx_int(buf, 8, bytes_order) + value_balance = TxInt8(val=cast(u64, iv), buf=cast(bytes8, bv)) + shielded_spend_count = _read_varint(buf, bytes_order=bytes_order) + shielded_spend = _read_bytes(buf, 384 * shielded_spend_count.val) \ + if shielded_spend_count.val > 0 else None + shielded_output_count = _read_varint(buf, bytes_order=bytes_order) + shielded_output = _read_bytes(buf, 948 * shielded_output_count.val) \ + if shielded_output_count.val > 0 else None + if version.val >= 2: + join_split_count = _read_varint(buf, bytes_order=bytes_order) + join_split = _read_bytes(buf, (1698 if version.val >= 4 else 1802) * shielded_output_count.val) \ + if join_split_count.val > 0 else None + if version.val >= 2 and join_split_count.val > 0: + join_split_pubkey = cast(bytes32, _read_bytes(buf, 32)) + join_split_sig = cast(bytes64, _read_bytes(buf, 64)) + if version.val >= 4 and shielded_spend_count.val + shielded_output_count.val > 0: + binding_sig = cast(bytes32, _read_bytes(buf, 32)) + + return ZcashExtFooter( + expiry_height=expiry_height, + value_balance=value_balance, + shielded_spend_count=shielded_spend_count, + shielded_spend=shielded_spend, + shielded_output_count=shielded_output_count, + shielded_output=shielded_output, + join_split_count=join_split_count, + join_split=join_split, + join_split_pubkey=join_split_pubkey, + join_split_sig=join_split_sig, + binding_sig=binding_sig) + + def _tx_type(buf: BytesIO) -> txtype: + """Test if special bytes are present, marking the BTC tx as either a segwit tx or + a tx for a Bitcoin-derived currency (e.g. Zcash)""" + typ: txtype = TxType.Btc + stream_pos: int = buf.tell() + buf.seek(4) # Reset stream position to right afer tx version + + byte0: Optional[u8] = _read_u8(buf) + byte1: Optional[u8] = _read_u8(buf) + + if (byte0, byte1) == (0x00, 0x01): + # Either segwit tx or legacy coinbase tx =>if coinbase, byte1 is the output count (1 output) + buf.seek(8, SEEK_CUR) # If coinbase tx, skip coinbase output value + coinb_num_bytes_to_end = _read_u8(buf) + 4 # Compute theoretical remaining bytes to end of tx + pos_cur = buf.tell() + pos_end = buf.seek(0, SEEK_END) + if pos_end - pos_cur != coinb_num_bytes_to_end: + typ = TxType.Segwit + elif (byte0, byte1) == (0x70, 0x82): # 1st two bytes of pre-Sapling (OVW) versionGroupId little endian + bytes2_3: Optional[u16] = _read_u16(buf, 'big') + if bytes2_3 == 0xc403: + typ = TxType.Zcash + elif (byte0, byte1) == (0x85, 0x20): # 1st two bytes of Sapling versionGroupId, little endian + bytes2_3: Optional[u16] = _read_u16(buf, 'big') + if bytes2_3 == 0x2f89: + typ = TxType.ZcashSapling + + buf.seek(stream_pos) + return typ + + # + # Transaction parsing code starts here + # + raw_tx_bytes: bytes = bytes.fromhex(raw_tx) if isinstance(raw_tx, str) else raw_tx + io_buf: BytesIO = BytesIO(raw_tx_bytes) + ivers, bvers = _read_tx_int(io_buf, 4, endianness) + version: TxInt4 = TxInt4( + val=cast(u32, ivers & ~0x80000000), # Remove overwinter flag is present + buf=cast(bytes4, bvers) + ) + tx_type: txtype = _tx_type(io_buf) + + marker: Optional[u8] = None + flag: Optional[u8] = None + version_group_id: Optional[TxInt4] = None + overwintered_flag: bool = False + + if tx_type == TxType.Segwit: + marker = _read_u8(io_buf) + flag = _read_u8(io_buf) + elif tx_type in (TxType.Zcash, TxType.ZcashSapling): + ival, bval = _read_tx_int(io_buf, 4, endianness) + version_group_id = TxInt4( + val=cast(u32, ival), + buf=cast(bytes4, bval) + ) + overwintered_flag = bool(ivers & 0x80000000) + + input_count: TxVarInt = _read_varint(io_buf) + inputs: List[TxInput] = _parse_inputs(io_buf, input_count.val) + output_count: TxVarInt = _read_varint(io_buf) + outputs: List[TxOutput] = _parse_outputs(io_buf, output_count.val) + if tx_type == TxType.Segwit: + # TODO: If present read witnesses & parse into a signatures (r, s, pubkey) object + io_buf.seek(-4, SEEK_END) # For now, skip all witnesses to access locktime + ival, bval = _read_tx_int(io_buf, 4, endianness) + lock_time: TxInt4 = TxInt4( + val=cast(u32, ival), + buf=cast(bytes4, bval) + ) + + zcash_footer: Optional[ZcashExtFooter] = None + if tx_type in (TxType.Zcash, TxType.ZcashSapling): + zcash_footer: ZcashExtFooter = _parse_zcash_footer(io_buf, endianness) + + parsed_tx = Tx( + type=tx_type, + hash=None, # Will be set just before returning + version=version, + header=TxHeader( + ext=SegwitExtHeader( + marker=marker, + flag=flag) if tx_type == TxType.Segwit + else ZcashExtHeader( + overwintered_flag=overwintered_flag, + version_group_id=version_group_id) if tx_type in (TxType.Zcash, TxType.ZcashSapling) + else None + ), + input_count=input_count, + inputs=inputs, + output_count=output_count, + outputs=outputs, + lock_time=lock_time, + footer=TxFooter( + ext=zcash_footer if tx_type in (TxType.Zcash, TxType.ZcashSapling) else None + ) + ) + parsed_tx.hash = _hash(parsed_tx) if parsed_tx.type == TxType.Segwit else _hash(raw_tx_bytes) + return parsed_tx diff --git a/tests/helpers/txparser/txtypes.py b/tests/helpers/txparser/txtypes.py new file mode 100644 index 00000000..c1683a90 --- /dev/null +++ b/tests/helpers/txparser/txtypes.py @@ -0,0 +1,205 @@ +from io import BytesIO +from sys import version_info +from dataclasses import dataclass +assert version_info.major >= 3, "Python 3 required!" +if version_info.minor >= 8: + # pylint: disable=no-name-in-module + from typing import NewType, Optional, cast, Literal +elif version_info.minor <= 6: # TypedDict & Literal not yet standard in 3.6 + from typing import NewType, Optional, cast + from typing_extensions import Literal + +# Types of the transaction fields, used to check fields lengths +u8 = NewType("u8", int) # 1-byte int +u16 = NewType("u16", int) # 2-byte int +u32 = NewType("u32", int) # 4-byte int +u64 = NewType("u64", int) # 8-byte int +i64 = NewType("i64", int) # 8-byte int, signed +varint = NewType("varint", int) # 1-9 bytes +byte = NewType("byte", bytes) # 1 byte +bytes2 = NewType("bytes2", bytes) # 2 bytes +bytes4 = NewType("bytes4", bytes) # 4 bytes +bytes8 = NewType("bytes8", bytes) # 8 bytes +bytes16 = NewType("bytes16", bytes) # 16 bytes +bytes32 = NewType("bytes32", bytes) # 32 bytes +bytes64 = NewType("bytes64", bytes) # 64 bytes +txtype = NewType("txtype", int) +byteorder = Literal['big', 'little'] + + +# Types for the supported kinds of transactions. Extend as needed. +class TxType: + Btc: txtype = 0 + Segwit: txtype = 1 + Bch: txtype = 2 + Zcash: txtype = 3 + ZcashSapling: txtype = 4 + + +class TxHashMode: + """Hash modes for the BTC app. Encoded on 5 bits: + + ``` + | 0 | 0 | 0 | i | i | i | s | t | + ``` + + With: + + - t: 0 = Hash an untrusted input / 1 = Hash a trusted input + - s: 0 = Hash input w/o its script / 1 = Hash input with its script + - iii: Input origin + - 000 (0): Legacy BTC tx + - 010 (2): Segwit BTC tx + - 011 (3): Zcash tx (for tx version >=2 and < 4) + - 100 (4): Zcash Sapling tx (for tx version >= 4) + - 101 (5): BCH (Bitcoin Cash) tx (not supported in tests for now?) + """ + Untrusted: int = 0b00000000 + Trusted: int = 0b00000001 + NoScript: int = 0b00000000 + WithScript: int = 0b00000010 + + LegacyBtc: int = (0x00 << 2) + SegwitBtc: int = (0x02 << 2) + Zcash: int = (0x03 << 2) + ZcashSapling: int = (0x04 << 2) + BitcoinCash: int = (0x05 << 2) + + def __init__(self, hash_mode: int): + self._hash_mode = hash_mode + + @property + def is_trusted_input_hash(self) -> bool: + return self._hash_mode & self.Trusted == self.Trusted + + @property + def is_hash_with_script(self) -> bool: + return self._hash_mode & self.WithScript == self.WithScript + + @property + def is_hash_no_script(self): + return not self.is_hash_with_script + + @property + def is_btc_input_hash(self) -> bool: + return self._hash_mode & 0x1C == 0x00 + + @property + def is_segwit_input_hash(self) -> bool: + return self._hash_mode & self.SegwitBtc == self.SegwitBtc + + @property + def is_zcash_input_hash(self) -> bool: + return self._hash_mode & self.Zcash == self.Zcash + + @property + def is_sapling_input_hash(self) -> bool: + return self._hash_mode & self.ZcashSapling == self.ZcashSapling + + @property + def is_bcash_input_hash(self) -> bool: + return self._hash_mode & self.BitcoinCash == self.BitcoinCash + + @property + def is_zcash_or_sapling_input_hash(self) -> bool: + return self.is_zcash_input_hash or self.is_sapling_input_hash + + @property + def is_segwit_zcash_or_sapling_input_hash(self) -> bool: + return self.is_segwit_input_hash or self.is_zcash_or_sapling_input_hash + + @property + def is_btc_or_bcash_input_hash(self) -> bool: + return self.is_btc_input_hash or self.is_bcash_input_hash + + @property + def is_relaxed_input_hash(self) -> bool: + return not (self.is_trusted_input_hash or self.is_segwit_input_hash or + self.is_zcash_input_hash or self.is_sapling_input_hash or + self.is_bcash_input_hash) + + +# Definitions useful for type hints and lengths handling +# Store an integer value and its byte representation, while allowing type checking +class TxInt: + pass + + +@dataclass +class TxInt1(TxInt): + val: u8 + buf: byte + + +@dataclass +class TxInt2(TxInt): + val: u16 + buf: bytes2 + + +@dataclass +class TxInt4(TxInt): + val: u32 + buf: bytes4 + + +@dataclass +class TxInt8(TxInt): + val: u64 + buf: bytes8 + + +@dataclass +class TxVarInt(TxInt): + val: varint + buf: bytes + + @classmethod + def to_bytes(cls, value: Optional[int], endianness: str = 'big'): + int_value: int = value if value is not None else cls.val if cls.val is not None else 0 + if int_value < 0xfd: + return int_value.to_bytes(1, endianness) + if int_value <= 0xffff: + bval = int_value.to_bytes(2, endianness) + return b'\xfd' + bval if endianness == 'big' else bval + b'\xfd' + if int_value <= 0xffffffff: + bval = int_value.to_bytes(4, endianness) + return b'\xff' + bval if endianness == 'big' else bval + b'\xfd' + raise ValueError(f"Value {int_value} too big to be encoded as a varint") + + @staticmethod + def from_raw(buf: BytesIO, + prefix: Optional[bytes] = None, + endianness: byteorder = 'big'): + """Returns the size encoded as a varint in the next 1 to 9 bytes of buf.""" + b: bytes = prefix if prefix else buf.read(1) + n: int = {b"\xfd": 2, b"\xfe": 4, b"\xff": 8}.get(b, 1) # default to 1 + b = buf.read(n) if n > 1 else b + + if len(b) != n: + raise ValueError("Can't read varint!") + return TxVarInt( + val=cast(varint, int.from_bytes(b, endianness)), + buf=b) + + +# Dictionaries holding special values introduced by BTC protocol evolution or +# BTC-derivative currencies +@dataclass +class TxExtension: + """ + Base dictionaries holding the extension values that can be added to the base + raw transaction by BTC protocol evolution or by BTC-derivative currencies + Common base class, do not use except as a base class or in a type comparison. + """ + pass + + +@dataclass +class TxHeader: + ext: TxExtension + + +@dataclass +class TxFooter: + ext: TxExtension diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 4eada1e3..00000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,8 +0,0 @@ -[pytest] -addopts = --strict-markers -markers = - manual: mark a test as manual (i.e. UI actions not yet automated) - btc: BTC app tests - zcash: ZCASH app tests - - diff --git a/tests/test_btc_get_trusted_input.py b/tests/test_btc_get_trusted_input.py index 95d7f986..e66a1596 100644 --- a/tests/test_btc_get_trusted_input.py +++ b/tests/test_btc_get_trusted_input.py @@ -1,261 +1,49 @@ -# -# Note on 'chunks_len' values used in tests: -# ----------------------------------------- -# The BTC app tx parser requires the tx data to be sent in chunks. For some tx fields +# Note on APDU payload chunks splitting: +# -------------------------------------- +# The BTC app tx parser requires the tx data to be sent in chunks. For some tx fields # it doesn't matter where the field is cut but for others it does and the rule is unclear. # -# Until I get a simple to use and working Tx parser class done, a workaround is -# used to split the tx in chunks of specific lengths, as done in ledgerjs' Btc.test.js -# file. Tx chunks lengths are gathered in a list, following the grammar below: -# -# chunks_lengths := list_of(chunk_desc,) i.e. [chunk_desc, chunk_desc,...] -# chunk_desc := offs_len_tuple | length | -1 -# offs_len_tuple := (offset, length) | (length1, skip_length, length2) -# -# with: -# offset: -# the offset of the 1st byte in the tx for the data chunk to be sent. Allows to skip some -# parts of the tx which should not be sent to the tx parser. -# length: -# the length of the chunk to be sent -# length1, length2: -# the lengths of 2 non-contiguous chunks of data in the tx separated by a block of -# skip_length bytes. The 2 non-contiguous blocks are concatenated together and the bloc -# of skip_length bytes is ignored. This is used when 2 non-contiguous parts of the tx -# must be sent in the same APDU but without the in-between bytes. -# -1: -# the length of the chunk to be sent is the last byte of the previous chunk + 4. This is -# used to send input/output scripts + their following 4-byte sequence_number in chunks. -# Sequence_number can't be sent separately from its output script as it puts the -# BTC app's tx parser in an invalid state (sw 0x6F01 returned, not clear why). This implicit -# +4 is to work around that limitation (but design-wise, it introduces knowledge of the tx -# format in the _sendApdu() method used by the tests :/). - +# The tx data splitting into the appropriate payload chunks is now delegated to the +# APDU-level DeviceAppBtc class. + import pytest -from dataclasses import dataclass, field -from typing import Optional, List from helpers.basetest import BaseTestBtc -from helpers.deviceappbtc import DeviceAppBtc, BTC_P1, BTC_P2 - - -@dataclass -class TrustedInputTestData: - # Tx to compute a TrustedInput from. - tx: bytes - # List of lengths of the chunks that will be sent as APDU payloads. Depending on the APDU - # the APDU, the BTC app accepts payloads (composed from the tx and other data) of specific - # sizes. See https://blog.ledger.com/btchip-doc/bitcoin-technical-beta.html#_get_trusted_input. - chunks_len: List[int] - # List of the outputs values to be tested, as expressed in the raw tx. - prevout_amount: List[bytes] - # Optional, index (not offset!) in the tx of the output to compute the TrustedInput from. Ignored - # if num_outputs is set. - prevout_idx: Optional[int] = field(default=None) - # Optional, number of outputs in the tx. If set, all the tx outputs will be used to generate - # each a corresponding TrustedInput, prevout_idx is ignored and prevout_amount must contain the - # values of all the outputs of that tx, in order. If not set, then prevout_idx must be set. - num_outputs: Optional[int] = field(default=None) - - -# Test data definition +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from helpers.txparser.transaction import Tx, TxParse +from conftest import btc_gti_test_data, TrustedInputTestData -# BTC Testnet -# txid: 45a13dfa44c91a92eac8d39d85941d223e5d4d210e85c0d3acf724760f08fcfe -# VO_P2WPKH -standard_tx = TrustedInputTestData( - tx=bytes.fromhex( - # Version - "02000000" - # Input count - "02" - # Input #1's prevout hash - "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab" - # Input #1's prevout index - "00000000" - # Input #1's prevout scriptSig len (107 bytes) - "6b" - # Input #1's prevout scriptSig - "483045022100ca145f0694ffaedd333d3724ce3f4e44aabc0ed5128113660d11" - "f917b3c5205302207bec7c66328bace92bd525f385a9aa1261b83e0f92310ea1" - "850488b40bd25a5d0121032006c64cdd0485e068c1e22ba0fa267ca02ca0c2b3" - "4cdc6dd08cba23796b6ee7" - # Input #1 sequence number - "fdffffff" - # Input #2's prevout hash - "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab" - # Input #2's prevout index - "01000000" - # Input #2's prevout scriptSsig len (106 bytes) - "6a" - # Input #2's prevout scriptSsig - "47304402202a5d54a1635a7a0ae22cef76d8144ca2a1c3c035c87e7cd0280ab4" - "3d3451090602200c7e07e384b3620ccd2f97b5c08f5893357c653edc2b8570f0" - "99d9ff34a0285c012102d82f3fa29d38297db8e1879010c27f27533439c868b1" - "cc6af27dd3d33b243dec" - # Input #2 sequence number - "fdffffff" - # Output count - "01" - # Amount (0.24964823 BTC) - "d7ee7c0100000000" - # Output scriptPubKey - "1976a9140ea263ff8b0da6e8d187de76f6a362beadab781188ac" - # Locktime - "e3691900" - ), - # The GetTrustedInput payload is (|| meaning concatenation): output_index (4B, BE) || tx - # Lengths below account for this 4B prefix (see file comment for more explanation on values below) - chunks_len=[ - 9, # len(output_index(4B)||version||input_count) - 37, # len(input1_prevout_hash||input1_prevout_index||input1_scriptSig_len) - -1, # get len(input1_scriptSig) from last byte of previous chunk, add len(input1_sequence) - 37, # len(input2_prevout_hash||input2_prevout_index||input2_scriptSig_len) - -1, # get len(input2_scriptSig) from last byte of previous chunk, add len(input2_sequence) - 1, # len(output_count) - 34, # len(output_amount||output_scriptPubkey) - 4 # len(locktime) - ], - prevout_idx=0, - prevout_amount=[bytes.fromhex("d7ee7c0100000000")] -) - -segwit_tx = TrustedInputTestData( - tx=bytes.fromhex( - # Version no (4 bytes) - "02000000" - # Marker + Flag (optional 2 bytes, 0001 indicates the presence of witness data) - # /!\ Remove flag for `GetTrustedInput` - "0001" - # In-counter (varint 1-9 bytes) - "02" - # Previous Transaction hash 1 (32 bytes) - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9" - # Previous Txout-index 1 (4 bytes) - "00000000" - # Txin-script length 1 (varint 1-9 bytes) - "00" - # /!\ no Txin-script (a.k.a scriptSig) because P2WPKH - # sequence_no (4 bytes) - "fdffffff" - # Previous Transaction hash 2 (32 bytes) - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9" - # Previous Txout-index 2 (4 bytes) - "01000000" - # Tx-in script length 2 (varint 1-9 bytes) - "00" - # sequence_no (4 bytes) - "fdffffff" - # Out-counter (varint 1-9 bytes) - "01" - # value in satoshis (8 bytes) - "01410f0000000000" # 999681 satoshis = 0,00999681 BTC - # Txout-script length (varint 1-9 bytes) - "16" # 22 - # Txout-script (a.k.a scriptPubKey) - "0014e4d3a1ec51102902f6bbede1318047880c9c7680" - # Witnesses (1 for each input if Flag=0001) - # /!\ remove witnesses for `GetTrustedInput` - "0247" - "30440220495838c36533616d8cbd6474842459596f4f312dce5483fe650791c8" - "2e17221c02200660520a2584144915efa8519a72819091e5ed78c52689b24235" - "182f17d96302012102ddf4af49ff0eae1d507cc50c86f903cd6aa0395f323975" - "9c440ea67556a3b91b" - "0247" - "304402200090c2507517abc7a9cb32452aabc8d1c8a0aee75ce63618ccd90154" - "2415f2db02205bb1d22cb6e8173e91dc82780481ea55867b8e753c35424da664" - "f1d2662ecb1301210254c54648226a45dd2ad79f736ebf7d5f0fc03b6f8f0e6d" - "4a61df4e531aaca431" - # lock_time (4 bytes) - "a7011900" - ), - # First tuple in list below is used to concatenate output_idx||version||input_count while - # skip the 2-byte segwit-specific flag ("0001") in between. - # Value 341 = locktime offset in APDU payload (i.e. skip all witness data). - # Finally, tx contains no scriptSig, so no "-1" trick is necessary. - chunks_len= [(4+4, 2, 1), 37, 4, 37, 4, 1, 31, (335+4, 4)], - prevout_idx=0, - prevout_amount=[bytes.fromhex("01410f0000000000")] -) - -segwwit_tx_2_outputs = TrustedInputTestData( - tx=bytes.fromhex( - # Version no (4 bytes) - "02000000" - # Marker + Flag (optional 2 bytes, 0001 indicates the presence of witness data) - # /!\ Remove flag for `GetTrustedInput` - "0001" - # In-counter (varint 1-9 bytes) - "01" - # 1st Previous Transaction hash (32 bytes) - "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db1" - # 1st Previous Txout-index (4 bytes) - "00000000" - # 1st Txin-script length (varint 1-9 bytes) - "17" - # Txin-script (a.k.a scriptSig) because P2SH - "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846a" - # sequence_no (4 bytes) - "feffffff" - # Out-counter (varint 1-9 bytes) - "02" - # value in satoshis (8 bytes) - "9b3242bf01000000" # 999681 satoshis = 0,00999681 BTC - # Txout-script length (varint 1-9 bytes) - "17" - # Txout-script (a.k.a scriptPubKey) - "a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" - # value in satoshis (8 bytes) - "1027000000000000" # 999681 satoshis = 0,00999681 BTC - # Txout-script length (varint 1-9 bytes) - "17" - # Txout-script (a.k.a scriptPubKey) - "a9141e852ac84f8385d76441c584e41f445aaf1624ea87" - # Witnesses (1 for each input if Marker+Flag=0001) - # /!\ remove witnesses for `GetTrustedInput` - "0247" - "304402206e54747dabff52f5c88230a3036125323e21c6c950719f671332" - "cdd0305620a302204a2f2a6474f155a316505e2224eeab6391d5e6daf22a" - "cd76728bf74bc0b48e1a0121033c88f6ef44902190f859e4a6df23ecff4d" - "86a2114bd9cf56e4d9b65c68b8121d" - # lock_time (4 bytes) - "1f7f1900" - ), - chunks_len=[(8, 2, 1), 37, -1, 1, 32, 32, (253, 4)], - num_outputs=2, - prevout_amount=[bytes.fromhex(amount) for amount in ("9b3242bf01000000", "1027000000000000")] -) @pytest.mark.btc class TestBtcTxGetTrustedInput(BaseTestBtc): """ Tests of the GetTrustedInput APDU """ - test_data = [ standard_tx, segwit_tx ] + # test_data = [standard_tx, segwit_tx] - @pytest.mark.parametrize("testdata", test_data) + # def test_get_trusted_input(self, testdata: TrustedInputTestData) -> None: + @pytest.mark.parametrize("testdata", btc_gti_test_data()) def test_get_trusted_input(self, testdata: TrustedInputTestData) -> None: """ Perform a GetTrustedInput for a non-segwit tx on Nano device. """ btc = DeviceAppBtc() - - prevout_idx = [idx for idx in range(testdata.num_outputs)] \ - if testdata.num_outputs is not None else [testdata.prevout_idx] + tx: Tx = TxParse.from_raw(raw_tx=testdata.tx) # Get TrustedInputs for all requested outputs in the tx + prevout_idx = [idx for idx in range(testdata.num_outputs)] if testdata.num_outputs is not None \ + else [testdata.prevout_idx] + trusted_inputs = [ - btc.getTrustedInput( - data=idx.to_bytes(4, 'big') + testdata.tx, - chunks_len=testdata.chunks_len - ) - for idx in prevout_idx - ] + btc.get_trusted_input( + prev_out_index=idx, + parsed_tx=tx) + for idx in prevout_idx] # Check each TrustedInput content - for (trusted_input, idx, amount) in zip(trusted_inputs, prevout_idx, testdata.prevout_amount): + prevout_amounts = [output.value for output in tx.outputs] + for (trusted_input, idx, amount) in zip(trusted_inputs, prevout_idx, prevout_amounts): self.check_trusted_input( - trusted_input, - out_index=idx.to_bytes(4, 'little'), - out_amount=amount + trusted_input, + out_index=idx.to_bytes(4, 'little'), + out_amount=amount.buf ) - diff --git a/tests/test_btc_rawtx_ljs.py b/tests/test_btc_rawtx_ljs.py index 483d498a..71ae61a0 100644 --- a/tests/test_btc_rawtx_ljs.py +++ b/tests/test_btc_rawtx_ljs.py @@ -1,183 +1,8 @@ -import pytest -from dataclasses import dataclass, field from typing import List, Optional -from helpers.basetest import BaseTestBtc, LedgerjsApdu -from helpers.deviceappbtc import DeviceAppBtc - - -# Test data below is extracted from ledgerjs repo, file "ledgerjs/packages/hw-app-btc/tests/Btc.test.js" -test_btc_get_wallet_public_key = [ - LedgerjsApdu( # GET PUBLIC KEY - on 44'/0'/0'/0 path - commands=["e040000011048000002c800000008000000000000000"], - # Response id seed-dependent, mening verification is possible only w/ speculos (test seed known). - # TODO: implement a simulator class a la DeviceAppSoft with BTC tx-related - # functions (seed derivation, signature, etc). - #expected_resp="410486b865b52b753d0a84d09bc20063fab5d8453ec33c215d4019a5801c9c6438b917770b2782e29a9ecc6edb67cd1f0fbf05ec4c1236884b6d686d6be3b1588abb2231334b453654666641724c683466564d36756f517a7673597135767765744a63564dbce80dd580792cd18af542790e56aa813178dc28644bb5f03dbd44c85f2d2e7a" - ) -] - -test_btc2 = [ - LedgerjsApdu( # GET TRUSTED INPUT - commands=[ - "e042000009000000010100000001", - "e0428000254ea60aeac5252c14291d428915bd7ccd1bfc4af009f4d4dc57ae597ed0420b71010000008a", - "e04280003247304402201f36a12c240dbf9e566bc04321050b1984cd6eaf6caee8f02bb0bfec08e3354b022012ee2aeadcbbfd1e92959f", - "e04280003257c15c1c6debb757b798451b104665aa3010569b49014104090b15bde569386734abf2a2b99f9ca6a50656627e77de663ca7", - "e04280002a325702769986cf26cc9dd7fdea0af432c8e2becc867c932e1b9dd742f2a108997c2252e2bdebffffffff", - "e04280000102", - "e04280002281b72e00000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88ac", - "e042800022a0860100000000001976a9144533f5fb9b4817f713c48f0bfe96b9f50c476c9b88ac", - "e04280000400000000", - ], - expected_resp="3200" + "--"*2 + "c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f1001000000a086010000000000" + "--"*8 - ), - LedgerjsApdu( # GET PUBLIC KEY - commands=["e04000000d03800000000000000000000000"], - #expected_resp="41046666422d00f1b308fc7527198749f06fedb028b979c09f60d0348ef79c985e4138b86996b354774c434488d61c7fb20a83293ef3195d422fde9354e6cf2a74ce223137383731457244716465764c544c57424836577a6a556331454b4744517a434d41612d17bc55b7aa153ae07fba348692c2976e6889b769783d475ba7488fb54770" - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - commands=[ - "e0440000050100000001", - "e04480003b013832005df4c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f1001000000a086010000000000b890da969aa6f31019", - "e04480001d76a9144533f5fb9b4817f713c48f0bfe96b9f50c476c9b88acffffffff", - ] - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL - commands=[ - "e04a80002301905f0100000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88ac", - "e04800001303800000000000000000000000000000000001" - ], - expected_resp="0000" - ), - LedgerjsApdu( # UNTRUSTED HASH SIGN - output will be different than ledgerjs test - commands=["e04800001303800000000000000000000000000000000001"], - check_sig_format=True # Only check DER format - ) -] - -test_btc3 = [ - LedgerjsApdu( # GET TRUSTED INPUT - commands=[ - "e042000009000000010100000001", - "e0428000254ea60aeac5252c14291d428915bd7ccd1bfc4af009f4d4dc57ae597ed0420b71010000008a", - "e04280003247304402201f36a12c240dbf9e566bc04321050b1984cd6eaf6caee8f02bb0bfec08e3354b022012ee2aeadcbbfd1e92959f", - "e04280003257c15c1c6debb757b798451b104665aa3010569b49014104090b15bde569386734abf2a2b99f9ca6a50656627e77de663ca7", - "e04280002a325702769986cf26cc9dd7fdea0af432c8e2becc867c932e1b9dd742f2a108997c2252e2bdebffffffff", - "e04280000102", - "e04280002281b72e00000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88ac", - "e042800022a0860100000000001976a9144533f5fb9b4817f713c48f0bfe96b9f50c476c9b88ac", - "e04280000400000000" - ], - expected_resp="3200" + "--"*2 + "c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f1001000000a086010000000000" + "--"*8 - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - - commands= [ - "e0440000050100000001", - "e04480002600c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f100100000069", - "e04480003252210289b4a3ad52a919abd2bdd6920d8a6879b1e788c38aa76f0440a6f32a9f1996d02103a3393b1439d1693b063482c04b", - "e044800032d40142db97bdf139eedd1b51ffb7070a37eac321030b9a409a1e476b0d5d17b804fcdb81cf30f9b99c6f3ae1178206e08bc5", - "e04480000900639853aeffffffff" - ] - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL - prevout amount + output script - commands=["e04a80002301905f0100000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88ac"], - expected_resp="0000" - ), - LedgerjsApdu( # UNTRUSTED HASH SIGN - on 0'/0/0 path - commands=["e04800001303800000000000000000000000000000000001"], - check_sig_format=True - ) -] - -test_btc4 = [ - LedgerjsApdu( # SIGN MESSAGE - part 1, on 44'/0'/0'/0 path + data to sign ("test") - commands=["e04e000117048000002c800000008000000000000000000474657374"], - expected_resp="0000" - ), - LedgerjsApdu( # SIGN MESSAGE - part 2, Null byte as end of msg - commands=["e04e80000100"], - check_sig_format=True - ) -] - -test_btc_seg_multi = [ - LedgerjsApdu( # GET PUBLIC KEY - commands=[ - "e040000015058000003180000001800000050000000000000000", - "e040000015058000003180000001800000050000000000000000", - ] - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - Inputs + prevout amounts, no scripts - commands=[ - "e0440002050100000002", - "e04480022e02f5f6920fea15dda9c093b565cecbe8ba50160071d9bc8bc3474e09ab25a3367d00000000c03b47030000000000", - "e044800204ffffffff", - "e04480022e023b9b487a91eee1293090cc9aba5acdde99e562e55b135609a766ffec4dd1100a0000000080778e060000000000", - "e044800204ffffffff", - ] - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL - Output 1 - commands=["e04a80002101ecd3e7020000000017a9142397c9bb7a3b8a08368a72b3e58c7bb85055579287"], - expected_resp="0000" - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - Continue w/ pseudo tx w/ input 1 + script + seq - commands=[ - "e0440080050100000001", - "e04480802e02f5f6920fea15dda9c093b565cecbe8ba50160071d9bc8bc3474e09ab25a3367d00000000c03b47030000000019", - "e04480801d76a9140a146582553b2f5537e13cef6659e82ed8f69b8f88acffffffff" - ] - ), - LedgerjsApdu( # UNTRUSTED HASH SIGN - for input 1 - commands=["e04800001b058000003180000001800000050000000000000000000000000001"], - check_sig_format=True - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - Continue w/ pseudo tx w/ input 2 + script + seq - commands=[ - "e0440080050100000001", - "e04480802e023b9b487a91eee1293090cc9aba5acdde99e562e55b135609a766ffec4dd1100a0000000080778e060000000019" - "e04480801d76a9140a146582553b2f5537e13cef6659e82ed8f69b8f88acffffffff" - ] - ), - LedgerjsApdu( # UNTRUSTED HASH SIGN - for input 2 - commands=["e04800001b058000003180000001800000050000000000000000000000000001"], - check_sig_format=True - ) -] - -test_btc_sig_p2sh_seg = [ - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - Input 1 + prevout amount, no script - commands=[ - "e0440002050100000001", - "e04480022e021ba3852a59cded8d2760434fa75af58a617b21e4fbe1cf9c826ea2f14f80927d00000000102700000000000000", - ] - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL - Output 1 - commands=["e04a8000230188130000000000001976a9140ae1441568d0d293764a347b191025c51556cecd88ac"], - expected_resp="0000" - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - Pseudo tx w/ input 1 + p2sh script - commands=[ - "e04480802e021ba3852a59cded8d2760434fa75af58a617b21e4fbe1cf9c826ea2f14f80927d00000000102700000000000047", - "e0448080325121026666422d00f1b308fc7527198749f06fedb028b979c09f60d0348ef79c985e41210384257cf895f1ca492bbee5d748", - "e0448080195ae0ef479036fdf59e15b92e37970a98d6fe7552aeffffffff" - ] - ), - LedgerjsApdu( # UNTRUSTED HASH SIGN - on 0'/0/0 path - commands=["e04800001303800000000000000000000000000000000001"], - check_sig_format=True - ) -] - -test_sign_message = [ - LedgerjsApdu( # SIGN MESSAGE - on 44'/0'/0/0 path + data to sign (binary) - commands=["e04e00011d058000002c800000008000000000000000000000000006666f6f626172"], - expected_resp="0000" - ), - LedgerjsApdu( # SIGN MESSAGE - Null byte as end of message - commands=["e04e80000100"], - check_sig_format=True - ) -] +import pytest +from helpers.basetest import BaseTestBtc +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from conftest import ledgerjs_test_data, LedgerjsApdu @pytest.mark.manual @@ -185,23 +10,24 @@ class TestLedgerjsBtcTx(BaseTestBtc): # Some test data deactivated as they pre-date the last version of the btc tx parser - ledgerjs_test_data = [ test_btc_get_wallet_public_key, test_btc3, test_btc4, - test_sign_message,] - # test_btc_sig_p2sh_seg, test_btc_seg_multi, test_btc2] + # ledgerjs_test_data = [ test_btc_get_wallet_public_key, test_btc3, test_btc4, + # test_sign_message,] + # # test_btc_sig_p2sh_seg, test_btc_seg_multi, test_btc2] - @pytest.mark.parametrize('test_data', ledgerjs_test_data) + @pytest.mark.parametrize('test_data', ledgerjs_test_data()) def test_replay_ledgerjs_tests(self, test_data: List[LedgerjsApdu]) -> None: """ - Verify the Btc app with test Tx extracted from the ledjerjs package + Verify the Btc app with test Tx extracted from the ledjerjs package that are supposedly known to work. """ apdus = test_data btc = DeviceAppBtc() + response: Optional[bytes] = None # All apdus shall return 9000 + potentially some data - for apdu in apdus: + for apdu in apdus: for command in apdu.commands: - response = btc.sendRawApdu(bytes.fromhex(command)) + response = btc.send_raw_apdu(bytes.fromhex(command)) if apdu.expected_resp is not None: self.check_raw_apdu_resp(apdu.expected_resp, response) - elif apdu.check_sig_format is not None and apdu.check_sig_format == True: + elif apdu.check_sig_format is not None and apdu.check_sig_format is True: self.check_signature(response) # Only format is checked diff --git a/tests/test_btc_rawtx_zcash.py b/tests/test_btc_rawtx_zcash.py index 0dbee3b7..292f338e 100644 --- a/tests/test_btc_rawtx_zcash.py +++ b/tests/test_btc_rawtx_zcash.py @@ -1,545 +1,165 @@ +from typing import List import pytest -from dataclasses import dataclass, field -from functools import reduce -from typing import List, Optional -from helpers.basetest import BaseTestBtc, LedgerjsApdu, TxData, CONSENSUS_BRANCH_ID -from helpers.deviceappbtc import DeviceAppBtc, CommException +from helpers.basetest import BaseTestZcash +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc, BTC_P1 +from helpers.txparser.transaction import Tx, TxHashMode, TxParse +from conftest import zcash_ledgerjs_test_data, zcash_prefix_cmds, SignTxTestData, LedgerjsApdu -# Test data below is from a Zcash test log from Live team" -test_zcash_prefix_cmds = [ - LedgerjsApdu( # Get version - commands=["b001000000"], - # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) - ), - LedgerjsApdu( - commands=[ - "e040000015058000002c80000085800000000000000000000000", # GET PUBLIC KEY - on 44'/133'/0'/0/0 path - "e016000000", # Coin info - ], - expected_resp="1cb81cbd01055a63617368035a4543" # "Zcash" + "ZEC" - ), - LedgerjsApdu( - commands=[ - "e040000009028000002c80000085", # Get Public Key - on path 44'/133' - "e016000000", # Coin info - ], - expected_resp="1cb81cbd01055a63617368035a4543" - ), - LedgerjsApdu( - commands=[ - "e040000009028000002c80000085", # path 44'/133' - "e04000000d038000002c8000008580000000", # path 44'/133'/0' - "e04000000d038000002c8000008580000001", # path 44'/133'/1' - "b001000000" - ], - # expected_resp="01055a63617368--------------0102" - ), - LedgerjsApdu( - commands=[ - "e040000015058000002c80000085800000000000000000000004", # Get Public Key - on path 44'/133'/0'/0/4 - "e016000000", # Coin info - ], - expected_resp="1cb81cbd01055a63617368035a4543" - ), - LedgerjsApdu( - commands=["b001000000"], - # expected_resp="01055a63617368--------------0102" - ), - LedgerjsApdu( - commands=[ - "e040000015058000002c80000085800000000000000000000004", # Get Public Key - on path 44'/133'/0'/0/4 - "e016000000" - ], - expected_resp="1cb81cbd01055a63617368035a4543" - ), - LedgerjsApdu( - commands=["b001000000"], - # expected_resp="01055a63617368--------------0102" - ) -] - -test_zcash_tx_sign_gti = [ - LedgerjsApdu( # GET TRUSTED INPUT - commands=[ - "e042000009000000010400008001", - "e042800025edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857010000006b", - "e042800032483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336dfa248aea9ccf022023b13e57595635452130", - "e0428000321c91ed0fe7072d295aa232215e74e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f42d458da", - "e04280000b1100831dc4ff72ffffff00", - "e04280000102", - "e042800022a0860100000000001976a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac", - "e0428000224d949100000000001976a914b714c60805804d86eb72a38c65ba8370582d09e888ac", - "e04280000400000000", - ], - expected_resp="3200" + "--"*2 + "20b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "--"*8 - ), -] - -test_zcash_tx_to_sign_abandonned = [ - LedgerjsApdu( # GET PUBLIC KEY - commands=["e040000015058000002c80000085800000000000000100000001"], # on 44'/133'/0'/1/1 - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - commands=[ - "e0440005090400008085202f8901", - "e04480053b013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d9491000000000045e1e144cb88d4d800", - "e044800504ffffff00", - ] - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL - commands=[ - "e04aff0015058000002c80000085800000000000000100000003", - # "e04a0000320240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" - "e04a0000230140420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" - ], # tx aborted on 2nd command - expected_sw="6985" - ), -] - -test_zcash_tx_sign_restart_prefix_cmds = [ - LedgerjsApdu( - commands=["b001000000"], - # expected_resp="01055a63617368--------------0102" - ), - LedgerjsApdu( - commands=[ - "e040000015058000002c80000085800000000000000000000004", - "e016000000", - ], - expected_resp="1cb81cbd01055a63617368035a4543" - ), - LedgerjsApdu( - commands=["b001000000"], - # expected_resp="01055a63617368--------------0102" - ) -] - -test_zcash_tx_to_sign_finalized = test_zcash_tx_sign_gti + [ - LedgerjsApdu( # GET PUBLIC KEY - commands=["e040000015058000002c80000085800000000000000100000001"], # on 44'/133'/0'/1/1 - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - commands=[ - "e0440005090400008085202f8901", - "e04480053b""013832004d""0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51""01000000""4d94910000000000""45e1e144cb88d4d8""00", - "e044800504ffffff00", - ] - ), - LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL - commands=[ - "e04aff0015058000002c80000085800000000000000100000003", - # "e04a0000320240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" - - "e04a0000230140420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" - "e04a8000045eb3f840" - ], - expected_resp="0000" - ), - - LedgerjsApdu( - commands=[ - "e044008509""0400008085202f8901", - "e04480853b""013832004d04""20b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51""01000000""4d94910000000000""45e1e144cb88d4d8""19", - "e04480851d""76a9140a146582553b2f5537e13cef6659e82ed8f69b8f88ac""ffffff00", - - "e048000015""058000002c80000085800000000000000100000001" - ], - check_sig_format=True - ) -] - - -ledgerjs_test_data = [ - test_zcash_prefix_cmds, test_zcash_tx_sign_gti, test_zcash_tx_to_sign_abandonned, - test_zcash_tx_sign_restart_prefix_cmds, test_zcash_tx_to_sign_finalized -] - - -utxo_single = bytes.fromhex( - # https://sochain.com/api/v2/tx/ZEC/ec9033381c1cc53ada837ef9981c03ead1c7c41700ff3a954389cfaddc949256 - # Version @offset 0 - "04000080" - # versionGroupId @offset 4 - "85202f89" - # Input count @offset 8 - "01" - # Input prevout hash @offset 9 - "53685b8809efc50dd7d5cb0906b307a1b8aa5157baa5fc1bd6fe2d0344dd193a" - # Input prevout idx @offset 41 - "00000000" - # Input script length @offset 45 - "6b" - # Input script (107 bytes) @ offset 46 - "483045022100ca0be9f37a4975432a52bb65b25e483f6f93d577955290bb7fb0" - "060a93bfc92002203e0627dff004d3c72a957dc9f8e4e0e696e69d125e4d8e27" - "5d119001924d3b48012103b243171fae5516d1dc15f9178cfcc5fdc67b0a8830" - "55c117b01ba8af29b953f6" - # Input sequence @offset 151 - "ffffffff" - # Output count @offset 155 - "01" - # Output #1 value @offset 156 - "4072070000000000" - # Output #1 script length @offset 164 - "19" - # Output #1 script (25 bytes) @offset 165 - "76a91449964a736f3713d64283fd0018626ba50091c7e988ac" - # Locktime @offset 190 - "00000000" - # Extra payload (size of everything remaining, specific to btc app inner protocol @offset 194 - "0F" - # Expiry @offset 195 - "00000000" - # valueBalance @offset 199 - "0000000000000000" - # vShieldedSpend @offset 207 - "00" - # vShieldedOutput @offset 208 - "00" - # vJoinSplit @offset 209 - "00" -) - - -utxos = [ - # Considered a segwit tx - segwit flags couldn't be extracted from raw - # Get Trusted Input APDUs as they are not supposed to be sent w/ these APDUs. - bytes.fromhex( - # Version @offset 0 - "04000080" - # versionGroupId @offset 4 - "85202f89" - # Input count @offset 8 - "01" - # Input prevout hash @offset 9 - "edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857" - # Input prevout idx @offset 41 - "01000000" - # Input script length @offset 45 - "6b" - # Input script (107 bytes) @ offset 46 - "483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336d" - "fa248aea9ccf022023b13e575956354521301c91ed0fe7072d295aa232215e74" - "e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f" - "42d458da1100831dc4ff72" - # Input sequence @offset 153 - "ffffff00" - # Output count @offset 157 - "02" - # Output #1 value @offset 160 - "a086010000000000" - # Output #1 script length @offset 168 - "19" - # Output #1 script (25 bytes) @offset 167 - "76a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac" - # Output #2 value @offset 192 - "4d94910000000000" # 9 540 685 units of ZEC smallest currency available - # Output #2 script length @offset 200 - "19" - # Output #2 script (25 bytes) @offset 201 - "76a914b714c60805804d86eb72a38c65ba8370582d09e888ac" - # Locktime @offset 226 - "00000000" - # Extra payload (size of everything remaining, specific to btc app inner protocol @offset 230 - "0F" - # Expiry @offset 231 - "00000000" - # valueBalance @offset 235 - "0000000000000000" - # vShieldedSpend @offset 243 - "00" - # vShieldedOutput @offset 244 - "00" - # vJoinSplit @offset 245 - "00" - ) -] - -tx_to_sign = bytes.fromhex( - # version @offset 0 - "04000080" - # Some Zcash flags (?) @offset 4 - "85202f89" - # Input count @offset 8 - "01" - # Input's prevout hash @offset 9 - "d35f0793da27a5eacfe984c73b1907af4b50f3aa3794ba1bb555b9233addf33f" - # Prevout idx @offset 41 - "01000000" - # input sequence @offset 45 - "ffffff00" - # Output count @offset 49 - "02" - # Output #1 value @offset 50 - "40420f0000000000" # 1 000 000 units of available balance spent - # Output #1 script (26 bytes) @offset 58 - "1976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" - # Output #2 value @offset 84 - "2b51820000000000" - # Output #2 scritp (26 bytes) @offset 92 - "1976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" - # Locktime @offset 118 - "5eb3f840" -) - -change_path = bytes.fromhex("058000002c80000085800000000000000100000003") # 44'/133'/0'/1/3 -output_paths = [ - bytes.fromhex("058000002c80000085800000000000000100000001"), # 44'/133'/0'/1/1 - bytes.fromhex("058000002c80000085800000000000000000000004") # 44'/133'/0'/0/4 -] - @pytest.mark.zcash -class TestLedgerjsZcashTx(BaseTestBtc): - - def _send_raw_apdus(self, apdus: List[LedgerjsApdu], device: DeviceAppBtc): - # Send the Get Version APDUs - for apdu in apdus: - try: - for command in apdu.commands: - response = device.sendRawApdu(bytes.fromhex(command)) - if apdu.expected_resp is not None: - self.check_raw_apdu_resp(apdu.expected_resp, response) - elif apdu.check_sig_format is not None and apdu.check_sig_format == True: - self.check_signature(response) # Only format is checked - except CommException as error: - if apdu.expected_sw is not None and error.sw.hex() == apdu.expected_sw: - continue - raise error +class TestLedgerjsZcashTx(BaseTestZcash): - - @pytest.mark.skip(reason="Hardcoded TrustedInput can't be replayed on a different device than the one that generated it") + @pytest.mark.skip(reason="Hardcoded TrustedInput can't be replayed on a different device than the " + "one that generated it") @pytest.mark.manual - @pytest.mark.parametrize('test_data', ledgerjs_test_data) - def test_replay_zcash_test(self, test_data: List[LedgerjsApdu]) -> None: + @pytest.mark.parametrize('test_data', zcash_ledgerjs_test_data()) + def test_replay_ljs_zcash_test(self, test_data: List[LedgerjsApdu]) -> None: """ - Replay of raw apdus from @gre. - + Replay of raw apdus from @gre. + First time an output is presented for validation, it must be rejected by user - Then tx will be restarted and on 2nd presentation of outputs they have to be + Then tx will be restarted and on 2nd presentation of outputs they have to be accepted. """ - apdus = test_data btc = DeviceAppBtc() - self._send_raw_apdus(apdus, btc) - - @pytest.mark.manual - def test_get_single_trusted_input(self) -> None: + self.send_ljs_apdus(test_data, btc) + def test_get_trusted_input_from_zec_sap_tx(self, zcash_utxo_single) -> None: + """Test GetTrustedInput from a Zcash utxo tx""" btc = DeviceAppBtc() + parsed_utxo_single = TxParse.from_raw(raw_tx=zcash_utxo_single) # 1. Get Trusted Input print("\n--* Get Trusted Input - from utxos") - input_datum = bytes.fromhex("00000000") + utxo_single - utxo_chunk_len = [ - 4 + 5 + 4, # len(prevout_index (BE)||version||input_count||versionGroupId) - 37, # len(prevout_hash||prevout_index||len(scriptSig)) - -1, # len(scriptSig, from last byte of previous chunk) + len(input_sequence) - 1, # len(output_count) - 34, # len(output_value #1||len(scriptPubkey #1)||scriptPubkey #1) - 4 + 1, # len(locktime || extra_data) - 4+16+1+1+1 # len(Expiry||valueBalance||vShieldedSpend||vShieldedOutput||vJoinSplit) - ] - - trusted_input = btc.getTrustedInput(data=input_datum, chunks_len=utxo_chunk_len) - + prevout_index = 0 + trusted_input = btc.get_trusted_input( + prev_out_index=prevout_index, + parsed_tx=parsed_utxo_single + ) self.check_trusted_input( trusted_input, out_index=bytes.fromhex("00000000"), out_amount=bytes.fromhex("4072070000000000"), out_hash=bytes.fromhex("569294dcadcf8943953aff0017c4c7d1ea031c98f97e83da3ac51c1c383390ec") ) - print(" OK") @pytest.mark.manual - def test_replay_zcash_test2(self) -> None: + @pytest.mark.parametrize("use_trusted_inputs", [True, False]) + @pytest.mark.parametrize("prefix_cmds", zcash_prefix_cmds()) + def test_sign_zcash_tx_with_trusted_zec_sap_inputs(self, + zcash_sign_tx_test_data: SignTxTestData, + use_trusted_inputs: bool, + prefix_cmds: List[List[LedgerjsApdu]]) -> None: """ - Adapted version to work around some hw limitations + Replay of real Zcash tx with inputs from a Zcash tx, trusted inputs on """ - # Send the Get Version raw apdus - apdus = test_zcash_prefix_cmds - btc = DeviceAppBtc() - self._send_raw_apdus(apdus, btc) - - # 1. Get Trusted Input - print("\n--* Get Trusted Input - from utxos") - output_indexes = [ - tx_to_sign[41+4-1:41-1:-1], # out_index in tx_to_sign input must be passed BE as prefix to utxo tx - ] - input_data = [out_idx + utxo for out_idx, utxo in zip(output_indexes, utxos)] - utxos_chunks_len = [ - [ # utxo #1 - 4+5+4, # len(prevout_index (BE)||version||input_count||versionGroupId) - 37, # len(prevout_hash||prevout_index||len(scriptSig)) - -1, # len(scriptSig, from last byte of previous chunk) + len(input_sequence) - 1, # len(output_count) - 34, # len(output_value #1||len(scriptPubkey #1)||scriptPubkey #1) - 34, # len(output_value #2||len(scriptPubkey #2)||scriptPubkey #2) - 4 + 1, # len(locktime) - 4 + 16 + 1 + 1 + 1 # len(Expiry||valueBalance||vShieldedSpend||vShieldedOutput||vJoinSplit) - ] - ] - trusted_inputs = [ - btc.getTrustedInput( - data=input_datum, - chunks_len=chunks_len - ) - for (input_datum, chunks_len) in zip(input_data, utxos_chunks_len) - ] - print(" OK") + tx_to_sign = zcash_sign_tx_test_data.tx_to_sign + utxos = zcash_sign_tx_test_data.utxos + output_paths = zcash_sign_tx_test_data.output_paths + change_path = zcash_sign_tx_test_data.change_path - out_amounts = [utxos[0][192:192+8]] # UTXO tx's 2nd output's value - prevout_hashes = [tx_to_sign[9:9+32]] - for trusted_input, out_idx, out_amount, prevout_hash in zip( - trusted_inputs, output_indexes, out_amounts, prevout_hashes - ): - self.check_trusted_input( - trusted_input, - out_index=out_idx[::-1], # LE for comparison w/ out_idx in trusted_input - out_amount=out_amount, # utxo output #1 is requested in tx to sign input - out_hash=prevout_hash # prevout hash in tx to sign - ) + btc = DeviceAppBtc() + parsed_tx: Tx = TxParse.from_raw(raw_tx=tx_to_sign) + parsed_utxos: List[Tx] = [TxParse.from_raw(raw_tx=utxo) for utxo in utxos] + + # 0. Send the Get Version raw apdus + self.send_ljs_apdus(apdus=prefix_cmds, device=btc) + + if use_trusted_inputs: + hash_mode_1 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.WithScript) + + # 1. Get Trusted Input + print("\n--* Get Trusted Input - from utxos") + output_indexes = [_input.prev_tx_out_index for _input in parsed_tx.inputs] + tx_inputs = [ + btc.get_trusted_input( + prev_out_index=out_idx.val, + parsed_tx=parsed_utxo) + for (out_idx, parsed_utxo, utxo) in zip(output_indexes, parsed_utxos, utxos)] + print(" OK") + + out_amounts = [_output.value.buf for parsed_utxo in parsed_utxos for _output in parsed_utxo.outputs] + requested_amounts = [out_amounts[out_idx.val] for out_idx in output_indexes] + prevout_hashes = [_input.prev_tx_hash for _input in parsed_tx.inputs] + for trusted_input, out_idx, req_amount, prevout_hash \ + in zip(tx_inputs, output_indexes, requested_amounts, prevout_hashes): + self.check_trusted_input( + trusted_input, + out_index=out_idx.buf, # LE for comparison w/ out_idx in trusted_input + out_amount=req_amount, # utxo output #1 is requested in tx to sign input + out_hash=prevout_hash # prevout hash in tx to sign + ) + else: + hash_mode_1 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Untrusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Untrusted | TxHashMode.WithScript) + tx_inputs = parsed_tx.inputs # 2.0 Get public keys for output paths & compute their hashes print("\n--* Get Wallet Public Key - for each tx output path") - wpk_responses = [btc.getWalletPublicKey(output_path) for output_path in output_paths] + wpk_responses = [btc.get_wallet_public_key(output_path) for output_path in output_paths] print(" OK") pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] for pubkey in pubkeys_data: print(pubkey) # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. - print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs having a null script length") - input_sequences = [tx_to_sign[45:45+4]] - ptx_to_hash_part1 = [tx_to_sign[:9]] - for trusted_input, input_sequence in zip(trusted_inputs, input_sequences): - ptx_to_hash_part1.extend([ - bytes.fromhex("01"), # TrustedInput marker byte, triggers the TrustedInput's HMAC verification - bytes([len(trusted_input)]), - trusted_input, - bytes.fromhex("00"), # Input script length = 0 (no sigScript) - input_sequence - ]) - ptx_to_hash_part1 = reduce(lambda x, y: x+y, ptx_to_hash_part1) # Get a single bytes object - - ptx_to_hash_part1_chunks_len = [ - 9 # len(version||flags||input_count) - skip segwit version+flag bytes - ] - for trusted_input in trusted_inputs: - ptx_to_hash_part1_chunks_len.extend([ - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||len(scriptSig) == 0) - 4 # len(input_sequence) - ]) - - btc.untrustedTxInputHashStart( - p1="00", - p2="05", # Value used for Zcash - data=ptx_to_hash_part1, - chunks_len=ptx_to_hash_part1_chunks_len - ) + print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs " + "having a null script length") + btc.untrusted_hash_tx_input_start( + mode=hash_mode_1, + parsed_tx=parsed_tx, + inputs=tx_inputs, + parsed_utxos=parsed_utxos) print(" OK") # 2.2 Finalize the input-centric-, pseudo-tx hash with the remainder of that tx # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") - ptx_to_hash_part2 = change_path - ptx_to_hash_part2_chunks_len = [len(ptx_to_hash_part2)] - - btc.untrustedTxInputHashFinalize( - p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.CHANGE_PATH, # to derive BIP 32 change address + data=change_path) print(" OK") # 2.2.2 Continue w/ tx to sign outputs & scripts print("\n--* Untrusted Transaction Input Hash Finalize Full - Continue w/ hash of tx output") - ptx_to_hash_part3 = tx_to_sign[49:118] # output_count||repeated(output_amount||scriptPubkey) - ptx_to_hash_part3_chunks_len = [len(ptx_to_hash_part3)] - - response = btc.untrustedTxInputHashFinalize( - p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) + response = btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.MORE_BLOCKS, + data=parsed_tx) assert response == bytes.fromhex("0000") print(" OK") # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. - # 2.2.3. Zcash-specific: "When using Overwinter/Sapling, UNTRUSTED HASH SIGN is - # called with an empty authorization and nExpiryHeight following the first + # 2.2.3. Zcash-specific: "When using Overwinter/Sapling, UNTRUSTED HASH SIGN is + # called with an empty authorization and nExpiryHeight following the first # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL" print("\n--* Untrusted Has Sign - with empty Auth & nExpiryHeight") - branch_id_data = [ - bytes.fromhex( - "00" # Number of derivations (None) - "00" # Empty validation code - ), - tx_to_sign[-4:], # locktime - bytes.fromhex("01"), # SigHashType - always 01 - bytes.fromhex("00000000") # Empty nExpiryHeight - ] - response = btc.untrustedHashSign( - data = reduce(lambda x, y: x+y, branch_id_data) - ) - + _ = btc.untrusted_hash_sign( + parsed_tx=parsed_tx, + output_path=None) # For untrusted_hash_sign() to behave as described in above comment - # 3. Sign each input individually. Because inputs are segwit, hash each input with its scriptSig - # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. + # 3. Sign each input individually. Because tx to sign is Zcash Sapling, hash each input with its scriptSig + # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. print("\n--* Untrusted Transaction Input Hash Start, step 2 - Hash again each input individually (only 1)") - # Inputs are P2WPKH, so use 0x1976a914{20-byte-pubkey-hash}88ac from utxo as scriptSig in this step. - # - # From btc.asc: "The input scripts shall be prepared by the host for the transaction signing process as - # per bitcoin rules : the current input script being signed shall be the previous output script (or the - # redeeming script when consuming a P2SH output, or the scriptCode when consuming a BIP 143 output), and - # other input script shall be null." - input_scripts = [utxos[0][196:196 + utxos[0][196] + 1]] - # input_scripts = [tx_to_sign[45:45 + tx_to_sign[45] + 1]] - # input_scripts = [bytes.fromhex("1976a914") + pubkey.pubkey_hash + bytes.fromhex("88ac") - # for pubkey in pubkeys_data] - ptx_for_inputs = [ - [ tx_to_sign[:8], # Tx version||zcash flags - bytes.fromhex("0101"), # Input_count||TrustedInput marker byte - bytes([len(trusted_input)]), - trusted_input, - input_script, - input_sequence - ] for trusted_input, input_script, input_sequence in zip(trusted_inputs, input_scripts, input_sequences) - ] - - ptx_chunks_lengths = [ - [ - 9, # len(version||zcash flags||input_count) - segwit flag+version not sent - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||scriptSig_len == 0x19) - -1 # get len(scripSig) from last byte of previous chunk + len(input_sequence) - ] for trusted_input in trusted_inputs - ] - - # Hash & sign each input individually - for ptx_for_input, ptx_chunks_len, output_path in zip(ptx_for_inputs, ptx_chunks_lengths, output_paths): + for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( - p1="00", - p2="80", # to continue previously started tx hash, be it BTc or other BTC-like coin - data=reduce(lambda x,y: x+y, ptx_for_input), - chunks_len=ptx_chunks_len - ) + btc.untrusted_hash_tx_input_start( + # continue prev. started tx hash + mode=hash_mode_2, + parsed_tx=parsed_tx, + parsed_utxos=parsed_utxos, + input_num=idx, + inputs=[tx_input]) print(" Final hash OK") # 3.2 Sign tx at last. Param is: - # Num_derivs||Dest output path||RFU (0x00)||tx locktime||sigHashType(always 0x01)||Branch_id for overwinter (4B) + # Num_derivs || output path || User validation code len (0x00) || tx locktime|| sigHashType (always 0x01) print("\n--* Untrusted Transaction Hash Sign") - tx_to_sign_data = output_path \ - + bytes.fromhex("00") \ - + tx_to_sign[-4:] \ - + bytes.fromhex("01") \ - + bytes.fromhex("00000000") + response = btc.untrusted_hash_sign( + output_path=output_path, + parsed_tx=parsed_tx) - response = btc.untrustedHashSign( - data = tx_to_sign_data - ) - self.check_signature(response) # Check sig format only - # self.check_signature(response, expected_der_sig) # Can't test sig value as it depends on signing device seed + self.check_signature(response) + # self.check_signature(response, expected_der_sig) # Not supported yet print(" Signature OK\n") - diff --git a/tests/test_btc_rawtx_zcash2.py b/tests/test_btc_rawtx_zcash2.py index f26e6725..b3e9dd06 100644 --- a/tests/test_btc_rawtx_zcash2.py +++ b/tests/test_btc_rawtx_zcash2.py @@ -1,527 +1,135 @@ +from typing import List import pytest -from dataclasses import dataclass, field -from functools import reduce -from typing import List, Optional -from helpers.basetest import BaseTestBtc, LedgerjsApdu, TxData, CONSENSUS_BRANCH_ID -from helpers.deviceappbtc import DeviceAppBtc, CommException +from helpers.basetest import BaseTestZcash +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc, BTC_P1 +from helpers.txparser.transaction import Tx, TxHashMode, TxParse +from conftest import zcash2_prefix_cmds, SignTxTestData, LedgerjsApdu -# Test data below is from a Zcash test log from Live team" -test_zcash_prefix_cmds = [ - LedgerjsApdu( # Get version - commands=[ - "b001000000", - "b001000000" - ], - # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) - ), - LedgerjsApdu( - commands=[ - "e040000015058000002c80000085800000010000000000000007", # GET PUBLIC KEY - on 44'/133'/1'/0/7 path - "e016000000", # Coin info - ], - expected_resp="1cb81cbd01055a63617368035a4543" # "Zcash" + "ZEC" - ), - LedgerjsApdu( - commands=[ - "e040000015058000002c80000085800000010000000000000007", # GET PUBLIC KEY - on 44'/133'/1'/0/7 path - "e016000000", # Coin info - ], - expected_resp="1cb81cbd01055a63617368035a4543" # "Zcash" + "ZEC" - ), - LedgerjsApdu( # Get version - commands=[ - "b001000000" - ], - # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) - ), - LedgerjsApdu( - commands=[ - "e040000015058000002c80000085800000000000000000000002", # Get Public Key - on path 44'/133'/0'/0/2 - "e016000000", # Coin info - ], - expected_resp="1cb81cbd01055a63617368035a4543" - ), - LedgerjsApdu( # Get version - commands=[ - "b001000000" - ], - # expected_resp="01055a63617368--------------0102" # i.e. "Zcash" + "1.3.23" (not checked) - ) -] +@pytest.mark.zcash +class TestLedgerjsZcashTx2(BaseTestZcash): -ledgerjs_test_data = [ - test_zcash_prefix_cmds -] - - -utxos = [ - bytes.fromhex( - # Version @offset 0 - "04000080" - # Input count @offset 4 - "03" - # Input #1 prevout hash @offset 5 - "f6959fbdd8cc614211e4db1ca287a766441dcda8d786f70d956ad19de03373a4" - # Input #1 prevout idx @offset 37 - "01000000" - # Input #1 script length @offset 41 - "69" - # Input #1 script (105 bytes) @ offset 42 - "46304302203dc5102d80e08cb8dee8e83894026a234d84ddd92da1605405a677" - "ead9fcb21a021f40bedfa4b5611fc00a6d43aedb6ea0769175c2eb4ce4f68963" - "c3a6103228080121028aceaa654c031435beb9bcf80d656a7519a6732f3da3c8" - "14559396131ea3532e" - # Input #1 sequence @offset 147 - "ffffff00" - # Input #3 prevout hash @offset 151 - "5ae818ee42a08d5c335d850cacb4b5996e5d2bc1cd5f0c5b46733652771c23b9" - # Input #2 prevout idx @offset 183 - "01000000" - # Input #2 script length @offset 187 - "6b" - # Input #2 script (107 bytes) @ offset 188 - "483045022100df24e46115778a766068f1b744a7ffd2b0ae4e09b34259eecb2f" - "5871f5e3ff7802207c83c3c13c8113f904da3ea4d4ceedb0db4e8518fb43e9fb" - "8aeda64d1a69c76b012103e604d3cbc5c8aa4f9c53f84157be926d443054ba93" - "b60fbddf0aea053173f595" - # Input #2 sequence @offset 295 - "ffffff00" - # Input #3 prevout hash @offset 299 - "6065c6c49cd132fc148f947b5aa5fd2a4e0ae4b5a884ccb3247b5ccbfa3ecc58" - # Input #3 prevout idx @offset 331 - "01000000" - # Input #3 script length @offset 335 - "6a" - # Input #3 script (106 bytes) @ offset 336 - "473044022064d92d88b8223f9e502214b2abf8eb72b91ad7ed69ae9597cb510a" - "3c94c7a2b00220327b4b852c2a81ad918bb341e7cd1c7e15903fc3e298663d75" - "675c4ab180be890121037dbc2659579d22c284a3ea2e3b5d0881f678583e2b4a" - "8b19dbd50f384d4b2535" - # Input #3 sequence @offset 442 - "ffffff00" - # Output count @offset 446 - "02" - # Output #1 value @offset 447 - "002d310100000000" - # Output #1 script length @offset 455 - "19" - # Output #1 script (25 bytes) @offset 456 - "76a914772b6723ec72c99f6a37009407006fe1c790733988ac" - # Output #2 value @offset 481 - "13b6240000000000" - # Output #2 script length @offset 489 - "19" - # Output #2 script (25 bytes) @offset 490 - "76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac" - # Locktime @offset 515 - "00000000" - ) -] - -tx_to_sign = bytes.fromhex( - # version @offset 0 - "04000080" - # Some Zcash flags (?) @offset 4 - "85202f89" - # Input count @offset 8 - "01" - # Input's prevout hash @offset 9 - "bf86afb1ac362f58d07a2c23ed65eb0cf19e6d1743bd1f6a482c665cb874e174" - # Prevout idx @offset 41 - "01000000" - # input script length byte @offset 45 - "19" - # Input script (25 bytes) @offset 46 - "76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac" - # input sequence @offset 71 - "ffffff00" - # Output count @offset 75 - "02" - # Output #1 value @offset 76 - "c05c150000000000" - # Output #1 script (26 bytes) @offset 84 - "1976a914130715c4e654cff3fced8a9d6876310083d44f2e88ac" - # Output #2 value @offset 110 - "e9540f0000000000" - # Output #2 scritp (26 bytes) @offset 118 - "1976a91478dff3b7ed9dac8e9177c587375937f9d057769588ac" - # Locktime @offset 144 - "00000000" -) - -change_path = bytes.fromhex("058000002c80000085800000000000000100000007") # 44'/133'/0'/1/7 -output_paths = [bytes.fromhex("058000002c80000085800000000000000100000006")] # 44'/133'/0'/1/6 - - -class TestLedgerjsZcashTx2(BaseTestBtc): - - def _send_raw_apdus(self, apdus: List[LedgerjsApdu], device: DeviceAppBtc): - # Send the Get Version APDUs - for apdu in apdus: - try: - for command in apdu.commands: - response = device.sendRawApdu(bytes.fromhex(command)) - if apdu.expected_resp is not None: - self.check_raw_apdu_resp(apdu.expected_resp, response) - elif apdu.check_sig_format is not None and apdu.check_sig_format == True: - self.check_signature(response) # Only format is checked - except CommException as error: - if apdu.expected_sw is not None and error.sw.hex() == apdu.expected_sw: - continue - raise error - - - @pytest.mark.zcash @pytest.mark.manual - def test_replay_zcash_with_trusted_inputs(self) -> None: + @pytest.mark.parametrize('use_trusted_inputs', [True, False]) + @pytest.mark.parametrize('prefix_cmds', zcash2_prefix_cmds()) + def test_sign_zcash_tx_with_trusted_zec_ovw_inputs(self, + zcash2_sign_tx_test_data: SignTxTestData, + use_trusted_inputs: bool, + prefix_cmds: List[List[LedgerjsApdu]]) -> None: """ - Replay of real Zcash tx from @ArnaudU's log, trusted inputs on + Replay of real Zcash tx with inputs from a standard tx, trusted inputs on """ - # Send the Get Version raw apdus - apdus = test_zcash_prefix_cmds - btc = DeviceAppBtc() - self._send_raw_apdus(apdus, btc) - - # 1. Get Trusted Input - print("\n--* Get Trusted Input - from utxos") - output_indexes = [ - tx_to_sign[41+4-1:41-1:-1], # out_index in tx_to_sign input must be passed BE as prefix to utxo tx - ] - input_data = [out_idx + utxo for out_idx, utxo in zip(output_indexes, utxos)] - utxos_chunks_len = [ - [ # utxo #1 - 4+5, # len(prevout_index (BE)||version||input_count) - 37, # len(prevout1_hash||prevout1_index||len(scriptSig1)) - -1, # len(scriptSig1, from last byte of previous chunk) + len(input_sequence1) - 37, # len(prevout2_hash||prevout2_index||len(scriptSig2)) - -1, # len(scriptSig2, from last byte of previous chunk) + len(input_sequence2) - 37, # len(prevout3_hash||prevout3_index||len(scriptSig3)) - -1, # len(scriptSig3, from last byte of previous chunk) + len(input_sequence3) - 1, # len(output_count) - 34, # len(output_value #1||len(scriptPubkey #1)||scriptPubkey #1) - 34, # len(output_value #2||len(scriptPubkey #2)||scriptPubkey #2) - 4 # len(locktime) - ] - ] - trusted_inputs = [ - btc.getTrustedInput( - data=input_datum, - chunks_len=chunks_len - ) - for (input_datum, chunks_len) in zip(input_data, utxos_chunks_len) - ] - print(" OK") + tx_to_sign = zcash2_sign_tx_test_data.tx_to_sign + utxos = zcash2_sign_tx_test_data.utxos + output_paths = zcash2_sign_tx_test_data.output_paths + change_path = zcash2_sign_tx_test_data.change_path - out_amounts = [utxos[0][481:481+8]] # UTXO tx's 2nd output's value - prevout_hashes = [tx_to_sign[9:9+32]] - for trusted_input, out_idx, out_amount, prevout_hash in zip( - trusted_inputs, output_indexes, out_amounts, prevout_hashes - ): - self.check_trusted_input( - trusted_input, - out_index=out_idx[::-1], # LE for comparison w/ out_idx in trusted_input - out_amount=out_amount, # utxo output #1 is requested in tx to sign input - out_hash=prevout_hash # prevout hash in tx to sign - ) + btc = DeviceAppBtc() + parsed_tx: Tx = TxParse.from_raw(raw_tx=tx_to_sign) + parsed_utxos: List[Tx] = [TxParse.from_raw(raw_tx=utxo) for utxo in utxos] + + # 0. Send the Get Version raw apdus (apdus from LedgerJS logs) + self.send_ljs_apdus(apdus=prefix_cmds, device=btc) + + if use_trusted_inputs: + hash_mode_1 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.WithScript) + + # 1. Get Trusted Input (if required by the test) + print("\n--* Get Trusted Input - from utxos") + output_indexes = [_input.prev_tx_out_index for _input in parsed_tx.inputs] + tx_inputs = [ + btc.get_trusted_input( + prev_out_index=out_idx.val, + parsed_tx=parsed_utxo) + for (out_idx, parsed_utxo, utxo) in zip(output_indexes, parsed_utxos, utxos)] + print(" OK") + + out_amounts = [_output.value.buf for parsed_utxo in parsed_utxos for _output in parsed_utxo.outputs] + requested_amounts = [out_amounts[out_idx.val] for out_idx in output_indexes] + prevout_hashes = [_input.prev_tx_hash for _input in parsed_tx.inputs] + for tx_input, out_idx, req_amount, prevout_hash \ + in zip(tx_inputs, output_indexes, requested_amounts, prevout_hashes): + self.check_trusted_input( + trusted_input=tx_input, + out_index=out_idx.buf, # LE for comparison w/ out_idx in trusted_input + out_amount=req_amount, # utxo output #1 is requested in tx to sign input + out_hash=prevout_hash) # prevout hash in tx to sign + else: + hash_mode_1 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Untrusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Untrusted | TxHashMode.WithScript) + tx_inputs = parsed_tx.inputs # 2.0 Get public keys for output paths & compute their hashes print("\n--* Get Wallet Public Key - for each tx output path") - wpk_responses = [btc.getWalletPublicKey(output_path) for output_path in output_paths] + wpk_responses = [btc.get_wallet_public_key(output_path) for output_path in output_paths] print(" OK") pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] for pubkey in pubkeys_data: print(pubkey) # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. - print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs having a null script length") - input_sequences = [tx_to_sign[71:71+4]] - ptx_to_hash_part1 = [tx_to_sign[:9]] - for trusted_input, input_sequence in zip(trusted_inputs, input_sequences): - ptx_to_hash_part1.extend([ - bytes.fromhex("01"), # TrustedInput marker byte, triggers the TrustedInput's HMAC verification - bytes([len(trusted_input)]), - trusted_input, - bytes.fromhex("00"), # Input script length = 0 (no sigScript) - input_sequence - ]) - ptx_to_hash_part1 = reduce(lambda x, y: x+y, ptx_to_hash_part1) # Get a single bytes object - - ptx_to_hash_part1_chunks_len = [ - 9 # len(version||zcash flags||input_count) - ] - for trusted_input in trusted_inputs: - ptx_to_hash_part1_chunks_len.extend([ - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||len(scriptSig) == 0) - 4 # len(input_sequence) - ]) - - btc.untrustedTxInputHashStart( - p1="00", - p2="05", # Value used for Zcash - data=ptx_to_hash_part1, - chunks_len=ptx_to_hash_part1_chunks_len - ) + print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs " + "having a null script length") + btc.untrusted_hash_tx_input_start( + mode=hash_mode_1, + parsed_tx=parsed_tx, + inputs=tx_inputs, + parsed_utxos=parsed_utxos) print(" OK") # 2.2 Finalize the input-centric-, pseudo-tx hash with the remainder of that tx # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") - ptx_to_hash_part2 = change_path - ptx_to_hash_part2_chunks_len = [len(ptx_to_hash_part2)] - - btc.untrustedTxInputHashFinalize( - p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.CHANGE_PATH, # to derive BIP 32 change address + data=change_path) print(" OK") # 2.2.2 Continue w/ tx to sign outputs & scripts print("\n--* Untrusted Transaction Input Hash Finalize Full - Continue w/ hash of tx output") - ptx_to_hash_part3 = tx_to_sign[75:-4] # output_count||repeated(output_amount||scriptPubkey) - ptx_to_hash_part3_chunks_len = [len(ptx_to_hash_part3)] - - response = btc.untrustedTxInputHashFinalize( - p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) + response = btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.MORE_BLOCKS, + data=parsed_tx) assert response == bytes.fromhex("0000") print(" OK") # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. - # 2.2.3. Zcash-specific: "When using Overwinter/Sapling, UNTRUSTED HASH SIGN is - # called with an empty authorization and nExpiryHeight following the first + # 2.2.3. Zcash-specific: "When using Overwinter/Sapling, UNTRUSTED HASH SIGN is + # called with an empty authorization and nExpiryHeight following the first # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL" print("\n--* Untrusted Has Sign - with empty Auth & nExpiryHeight") - branch_id_data = [ - bytes.fromhex( - "00" # Number of derivations (None) - "00" # Empty validation code - ), - tx_to_sign[-4:], # locktime - bytes.fromhex("01"), # SigHashType - always 01 - bytes.fromhex("00000000") # Empty nExpiryHeight - ] - response = btc.untrustedHashSign( - data = reduce(lambda x, y: x+y, branch_id_data) - ) - - - # 3. Sign each input individually. Because inputs are segwit, hash each input with its scriptSig - # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. - print("\n--* Untrusted Transaction Input Hash Start, step 2 - Hash again each input individually (only 1)") - # Inputs are P2WPKH, so use 0x1976a914{20-byte-pubkey-hash}88ac from utxo as scriptSig in this step. - # - # From btc.asc: "The input scripts shall be prepared by the host for the transaction signing process as - # per bitcoin rules : the current input script being signed shall be the previous output script (or the - # redeeming script when consuming a P2SH output, or the scriptCode when consuming a BIP 143 output), and - # other input script shall be null." - input_scripts = [utxos[0][489:489 + utxos[0][489] + 1]] - # input_scripts = [tx_to_sign[45:45 + tx_to_sign[45] + 1]] - # input_scripts = [bytes.fromhex("1976a914") + pubkey.pubkey_hash + bytes.fromhex("88ac") - # for pubkey in pubkeys_data] - ptx_for_inputs = [ - [ tx_to_sign[:8], # Tx version||zcash flags - bytes.fromhex("0101"), # Input_count||TrustedInput marker byte - bytes([len(trusted_input)]), - trusted_input, - input_script, - input_sequence - ] for trusted_input, input_script, input_sequence in zip(trusted_inputs, input_scripts, input_sequences) - ] - - ptx_chunks_lengths = [ - [ - 9, # len(version||zcash flags||input_count) - segwit flag+version not sent - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||scriptSig_len == 0x19) - -1 # get len(scripSig) from last byte of previous chunk + len(input_sequence) - ] for trusted_input in trusted_inputs - ] - - # Hash & sign each input individually - for ptx_for_input, ptx_chunks_len, output_path in zip(ptx_for_inputs, ptx_chunks_lengths, output_paths): - # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( - p1="00", - p2="80", # to continue previously started tx hash, be it BTc or other BTC-like coin - data=reduce(lambda x,y: x+y, ptx_for_input), - chunks_len=ptx_chunks_len - ) - print(" Final hash OK") - - # 3.2 Sign tx at last. Param is: - # Num_derivs||Dest output path||RFU (0x00)||tx locktime||sigHashType(always 0x01)||Branch_id for overwinter (4B) - print("\n--* Untrusted Transaction Hash Sign") - tx_to_sign_data = output_path \ - + bytes.fromhex("00") \ - + tx_to_sign[-4:] \ - + bytes.fromhex("01") \ - + bytes.fromhex("00000000") - - response = btc.untrustedHashSign( - data = tx_to_sign_data - ) - self.check_signature(response) # Check sig format only - # self.check_signature(response, expected_der_sig) # Can't test sig value as it depends on signing device seed - print(" Signature OK\n") - - - @pytest.mark.zcash - @pytest.mark.manual - def test_replay_zcash_no_trusted_inputs(self) -> None: - """ - Replay of real Zcash tx from @ArnaudU's log, trusted inputs off - """ - # Send the Get Version raw apdus - apdus = test_zcash_prefix_cmds - btc = DeviceAppBtc() - self._send_raw_apdus(apdus, btc) - - out_amounts = [utxos[0][481:481+8]] # UTXO tx's 2nd output's value - prevout_hashes = [tx_to_sign[9:9+32]] - - # 2.0 Get public keys for output paths & compute their hashes - print("\n--* Get Wallet Public Key - for each tx output path") - wpk_responses = [btc.getWalletPublicKey(output_path) for output_path in output_paths] - print(" OK") - pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] - for pubkey in pubkeys_data: - print(pubkey) - - # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. - print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs having a null script length") - input_sequences = [tx_to_sign[71:71+4]] - ptx_to_hash_part1 = [tx_to_sign[:9]] - std_inputs = [tx_to_sign[9:45]] - for std_input, input_sequence in zip(std_inputs, input_sequences): - ptx_to_hash_part1.extend([ - # bytes.fromhex("00"), # standard input marker byte, relaxed mode - bytes.fromhex("02"), # segwit-like input marker byte for zcash - std_input, # utxo tx hash + utxo tx prevout idx (segwit-like) - out_amounts[0], # idx #1 prevout amount (segwit-like) - bytes.fromhex("00"), # Input script length = 0 (no scriptSig) - input_sequence - ]) - ptx_to_hash_part1 = reduce(lambda x, y: x+y, ptx_to_hash_part1) # Get a single bytes object - - ptx_to_hash_part1_chunks_len = [ - 9 # len(version||zcash flags||input_count) - ] - for std_input in std_inputs: - ptx_to_hash_part1_chunks_len.extend([ - 1 + len(std_input) + 8 + 1, # len(std_input_marker||std_input||amount||len(scriptSig) == 0) - 4 # len(input_sequence) - ]) - - btc.untrustedTxInputHashStart( - p1="00", - # p2="02", # /!\ "02" to activate BIP 143 signature (b/c the pseudo-tx - # # contains segwit inputs encapsulated in TrustedInputs). - p2="05", # Value used for Zcash (TBC if bit 143 sig is activated when bit#1 is 0) - data=ptx_to_hash_part1, - chunks_len=ptx_to_hash_part1_chunks_len - ) - print(" OK") + _ = btc.untrusted_hash_sign( + parsed_tx=parsed_tx, + output_path=None) # For untrusted_hash_sign() to behave as described in above comment - # 2.2 Finalize the input-centric-, pseudo-tx hash with the remainder of that tx - # 2.2.1 Start with change address path - print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") - ptx_to_hash_part2 = change_path - ptx_to_hash_part2_chunks_len = [len(ptx_to_hash_part2)] - - btc.untrustedTxInputHashFinalize( - p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) - print(" OK") - - # 2.2.2 Continue w/ tx to sign outputs & scripts - print("\n--* Untrusted Transaction Input Hash Finalize Full - Continue w/ hash of tx output") - ptx_to_hash_part3 = tx_to_sign[75:-4] # output_count||repeated(output_amount||scriptPubkey) - ptx_to_hash_part3_chunks_len = [len(ptx_to_hash_part3)] - - response = btc.untrustedTxInputHashFinalize( - p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) - assert response == bytes.fromhex("0000") - print(" OK") - # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. - - # 2.2.3. Zcash-specific - provide the Zcash branchId - print("\n--* Untrusted Has Sign - provide Zcash branchId as a fake derivation path") - branch_id_data = [ - bytes.fromhex( - "00" # Number of derivations (None) - "00" # RFU byte - ), - tx_to_sign[-4:], # locktime - bytes.fromhex("01"), # SigHashType - always 01 - bytes.fromhex("00000000") # As in @ArnaudU's log - ] - response = btc.untrustedHashSign( - data = reduce(lambda x, y: x+y, branch_id_data) - ) - - - # 3. Sign each input individually. Because inputs are segwit, hash each input with its scriptSig - # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. + # 3. Sign each input individually. Because tx to sign is Zcash Sapling, hash each input with its scriptSig + # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. print("\n--* Untrusted Transaction Input Hash Start, step 2 - Hash again each input individually (only 1)") # Inputs are P2WPKH, so use 0x1976a914{20-byte-pubkey-hash}88ac from utxo as scriptSig in this step. # - # From btc.asc: "The input scripts shall be prepared by the host for the transaction signing process as - # per bitcoin rules : the current input script being signed shall be the previous output script (or the - # redeeming script when consuming a P2SH output, or the scriptCode when consuming a BIP 143 output), and + # From btc.asc: "The input scripts shall be prepared by the host for the transaction signing process as + # per bitcoin rules : the current input script being signed shall be the previous output script (or the + # redeeming script when consuming a P2SH output, or the scriptCode when consuming a BIP 143 output), and # other input script shall be null." - input_scripts = [utxos[0][489:489 + utxos[0][489] + 1]] - # input_scripts = [tx_to_sign[45:45 + tx_to_sign[45] + 1]] - ptx_for_inputs = [ - [ tx_to_sign[:8], # Tx version||zcash flags - bytes.fromhex("0102"), # Input_count||segwit-like Input marker byte - std_input, - utxos[0][481:481+8], # prevout @idx 1 amount (if segwit-like) - input_script, - input_sequence - ] for std_input, input_script, input_sequence in zip(std_inputs, input_scripts, input_sequences) - ] - - ptx_chunks_lengths = [ - [ - 9, # len(version||zcash flags||input_count) - segwit flag+version not sent - # 1 + len(trusted_input) + 1, # len(std_input_marker||std_input||scriptSig_len == 0x19) - 1 + len(std_input) + 8 + 1, # len(std_input_marker||std_input||scriptSig_len == 0x19) - -1 # get len(scripSig) from last byte of previous chunk + len(input_sequence) - ] for std_input in std_inputs - ] - - # Hash & sign each input individually - for ptx_for_input, ptx_chunks_len, output_path in zip(ptx_for_inputs, ptx_chunks_lengths, output_paths): + for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( - p1="00", - p2="80", # to continue previously started tx hash, be it BTc or other BTC-like coin - data=reduce(lambda x,y: x+y, ptx_for_input), - chunks_len=ptx_chunks_len - ) + btc.untrusted_hash_tx_input_start( + # continue prev. started tx hash + mode=hash_mode_2, + parsed_tx=parsed_tx, + parsed_utxos=parsed_utxos, + input_num=idx, + inputs=[tx_input]) print(" Final hash OK") # 3.2 Sign tx at last. Param is: - # Num_derivs||Dest output path||RFU (0x00)||tx locktime||sigHashType(always 0x01)||empty nExpiryHeight (as per spec) (4B) + # Num_derivs || output path || User validation code len (0x00) || tx locktime|| sigHashType (always 0x01) print("\n--* Untrusted Transaction Hash Sign") - tx_to_sign_data = output_path \ - + bytes.fromhex("00") \ - + tx_to_sign[-4:] \ - + bytes.fromhex("01") \ - + bytes.fromhex("00000000") + response = btc.untrusted_hash_sign( + output_path=output_path, + parsed_tx=parsed_tx) - response = btc.untrustedHashSign( - data = tx_to_sign_data - ) self.check_signature(response) # Check sig format only - # self.check_signature(response, expected_der_sig) # Can't test sig value as it depends on signing device seed + # self.check_signature(response, expected_der_sig) # Not supported yet print(" Signature OK\n") - diff --git a/tests/test_btc_segwit_tx_ljs.py b/tests/test_btc_segwit_tx_ljs.py index 7bee3676..915a42c6 100644 --- a/tests/test_btc_segwit_tx_ljs.py +++ b/tests/test_btc_segwit_tx_ljs.py @@ -1,542 +1,129 @@ -# -# Note on 'chunks_len' values used in tests: -# ----------------------------------------- -# The BTC app tx parser requires the tx data to be sent in chunks. For some tx fields -# it doesn't matter where the field is cut but for others it does and the rule is unclear. -# -# Until I get a simple to use and working Tx parser class done, a workaround is -# used to split the tx in chunks of specific lengths, as done in ledgerjs' Btc.test.js -# file. Tx chunks lengths are gathered in a list, following the grammar below: -# -# chunks_lengths := list_of(chunk_desc,) i.e. [chunk_desc, chunk_desc,...] -# chunk_desc := offs_len_tuple | length | -1 -# offs_len_tuple := (offset, length) | (length1, skip_length, length2) -# -# with: -# offset: -# the offset of the 1st byte in the tx for the data chunk to be sent. Allows to skip some -# parts of the tx which should not be sent to the tx parser. -# length: -# the length of the chunk to be sent -# length1, length2: -# the lengths of 2 non-contiguous chunks of data in the tx separated by a block of -# skip_length bytes. The 2 non-contiguous blocks are concatenated together and the bloc -# of skip_length bytes is ignored. This is used when 2 non-contiguous parts of the tx -# must be sent in the same APDU but without the in-between bytes. -# -1: -# the length of the chunk to be sent is the last byte of the previous chunk + 4. This is -# used to send input/output scripts + their following 4-byte sequence_number in chunks. -# Sequence_number can't be sent separately from its output script as it puts the -# BTC app's tx parser in an invalid state (sw 0x6F01 returned, not clear why). This implicit -# +4 is to work around that limitation (but design-wise, it introduces knowledge of the tx -# format in the _sendApdu() method used by the tests :/). - +""" +Ledger BTC app unit tests, Segwit BTC tx, 2 inputs from 2 Segwit utxo txs +""" import pytest -from typing import Optional, List -from functools import reduce -from helpers.basetest import BaseTestBtc, BtcPublicKey, TxData -from helpers.deviceappbtc import DeviceAppBtc - -utxos = [ - bytes.fromhex( - # Version no (4 bytes) @offset 0 - "02000000" - # Marker + Flag (optional 2 bytes, 0001 indicates the presence of witness data) - # /!\ Remove flag for `GetTrustedInput` @offset 4 - "0001" - # In-counter (varint 1-9 bytes) @offset 6 - "01" - # 1st Previous Transaction hash (32 bytes) @offset 7 - "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db1" - # 1st Previous Txout-index (4 bytes) @offset 39 - "00000000" - # 1st Txin-script length (varint 1-9 bytes) @offset 43 - "17" - # Txin-script (a.k.a scriptSig) because P2SH @offset 44 - "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846a" - # sequence_no (4 bytes) @offset 67 - "feffffff" - # Out-counter (varint 1-9 bytes) @offset 71 - "02" - # value in satoshis (8 bytes) @offset 72 - "9b3242bf01000000" # 999681 satoshis = 0,00999681 BTC - # Txout-script length (varint 1-9 bytes) @offset 80 - "17" - # Txout-script (a.k.a scriptPubKey) @offset 81 - "a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" - # value in satoshis (8 bytes) @offset 104 - "1027000000000000" # 999681 satoshis = 0,00999681 BTC - # Txout-script length (varint 1-9 bytes) @offset 112 - "17" - # Txout-script (a.k.a scriptPubKey) @offset 113 - "a9141e852ac84f8385d76441c584e41f445aaf1624ea87" - # Witnesses (1 for each input if Marker+Flag=0001) @offset 136 - # /!\ remove witnesses for GetTrustedInput - "0247" - "304402206e54747dabff52f5c88230a3036125323e21c6c950719f671332" - "cdd0305620a302204a2f2a6474f155a316505e2224eeab6391d5e6daf22a" - "cd76728bf74bc0b48e1a0121033c88f6ef44902190f859e4a6df23ecff4d" - "86a2114bd9cf56e4d9b65c68b8121d" - # lock_time (4 bytes) @offset @offset 243 - "1f7f1900" - ), - bytes.fromhex( - # Version (4bytes) @offset 0 - "01000000" - # Segwit (2 bytes) version+flag @offset 4 - "0001" - # Input count @offset 6 - "02" - # Input #1 prevout_hash (32 bytes) @offset 7 - "7ab1cb19a44db08984031508ec97de727b32a8176cc00fce727065e86984c8df" - # Input #1 prevout_idx (4 bytes) @offset 39 - "00000000" - # Input #1 scriptSig len @offset 43 - "17" - # Input #1 scriptSig (23 bytes) @offset 44 - "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320" - # Input #1 sequence (4 bytes) @offset 67 - "ffffff00" - # Input #2 prevout_hash (32 bytes) @offset 71 - "78958127caf18fc38733b7bc061d10bca72831b48be1d6ac91e296b888003327" - # Input #2 prevout_idx (4 bytes) @offset 103 - "00000000" - # Input #2 scriptSig length @offset 107 - "17" - # Input #1 scriptSig (23 bytes) @offset 108 - "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320" - # Input #2 sequence (4 bytes) @offset 131 - "ffffff00" - # Output count @ @offset 135 - "02" - # Output # 1 value (8 bytes) @offset 136 - "1027000000000000" - # Output #1 scriptPubkey (24 bytes) @offset 144 - "17" - "a91493520844497c54e709756c819afecfffaf28761187" - # Output #2 value (8 bytes) @offset 168 - "c84b1a0000000000" - # Output #2 scriptPubkey (24 bytes) @offset 176 - "17" - "a9148f1f7cf3c847e4057be46990c4a00be4271f3cfa87" - # Witnesses (214 bytes) @offset 200 - "0247" - "3044022009116da9433c3efad4eaf5206a780115d6e4b2974152bdceba220c45" - "70e527a802202b06ca9eb93df1c9fc5b0e14dc1f6698adc8cbc15d3ec4d364b7" - "bef002c493d701210293137bc1a9b7993a1d2a462188efc45d965d135f53746b" - "6b146a3cec9905322602473044022034eceb661d9e5f777468089b262f6b25e1" - "41218f0ec9e435a98368d3f347944d02206041228b4e43a1e1fbd70ca15d3308" - "af730eedae9ec053afec97bd977be7685b01210293137bc1a9b7993a1d2a4621" - "88efc45d965d135f53746b6b146a3cec99053226" - # locktime (4 bytes) @offset 414 (or -4) - "00000000") -] - -tx_to_sign = bytes.fromhex( - # Version - "01000000" - # Segwit flag+version - "0001" - # Input count - "02" - # Prevout hash (txid) @offset 7 - "027a726f8aa4e81a45241099a9820e6cb7d8920a686701ad98000721101fa0aa" - # Prevout index @offset 39 - "01000000" - # scriptSig @offset 43 - "17" - "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320" - # Input sequence @offset 67 - "ffffff00" - # Input #2 prevout hash (32 bytes) @offset 71 - "f0b7b7ad837b4d3535bea79a2fa355262df910873b7a51afa1e4279c6b6f6e6f" - # Input #2 prevout index (4 bytes) @offset 103 - "00000000" - # Input #2 scriptSig @offset 107 - "17" - "160014eee02beeb4a8f15bbe4926130c086bd47afe8dbc" - #Input #2 sequence (4 bytes) @offset 131 - "ffffff00" - - # Output count @offset 135 - "02" - # Amount #1 @offset (8 bytes) 136 - "1027000000000000" - # scriptPubkey #1 (24 bytes) @offset 144 - "17" - "a9142406cd1d50d3be6e67c8b72f3e430a1645b0d74287" - # Amount #2 (8 bytes) @offset 168 - "0e26000000000000" - # scriptPubkey #2 (24 bytes) @ offset 176 - "17" - "a9143ae394774f1348be3a6bc2a55b67e3566d13408987" - - # Signed DER-encoded withness from testnet (@offset 200) - # /!\ Do not send to UntrustedSignHash! But the signature it contains - # can be used to verify the test output, provided the same seed and - # derivation paths are used. - "02""48" - #Input #1 sig @offset 202 - "30""45" - "02""21" - "00f4d05565991d98573629c7f98c4f575a4915600a83a0057716f1f4865054927f" - "02""20" - "10f30365e0685ee46d81586b50f5dd201ddedab39cfd7d16d3b17f94844ae6d5" - "01""21" - "0293137bc1a9b7993a1d2a462188efc45d965d135f53746b6b146a3cec99053226" - "02""47" - # Input #2 sig @offset 309 - "30""44" - "02""20" - "30c4c770db75aa1d3ed877c6f995a1e6055be00c88efefb2fb2db8c596f2999a" - "02""20" - "5529649f4366427e1d9ed3cf8dc80fe25e04ce4ac19b71578fb6da2b5788d45b" - "01""21" - "03cfbca92ae924a3bd87529956cb4f372a45daeafdb443e12a781881759e6f48ce" - - # locktime @offset -4 - "00000000" -) +from helpers.basetest import BaseTestBtc +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc, BTC_P1 +from helpers.txparser.transaction import TxHashMode, TxParse +from conftest import SignTxTestData -expected_der_sig = [ - tx_to_sign[202:202+2+tx_to_sign[203]+1], - tx_to_sign[309:309+2+tx_to_sign[309]+1] -] - -output_paths = [ - bytes.fromhex("05""80000031""80000001""80000000""00000000""000001f6"), # 49'/1'/0'/0/502 - bytes.fromhex("05""80000031""80000001""80000000""00000000""000001f7") # 49'/1'/0'/0/503 -] -change_path = bytes.fromhex("05""80000031""80000001""80000000""00000001""00000045") # 49'/1'/0'/1/69 - -test12_data = TxData( - tx_to_sign=tx_to_sign, - utxos=utxos, - output_paths=output_paths, - change_path=change_path, - expected_sig=expected_der_sig -) @pytest.mark.btc @pytest.mark.manual class TestBtcSegwitTxLjs(BaseTestBtc): - @pytest.mark.parametrize("test_data", [test12_data]) - def test_sign_tx_with_multiple_trusted_segwit_inputs(self, test_data: TxData): + @pytest.mark.parametrize("use_trusted_inputs", [True, False]) + def test_sign_tx_with_multiple_trusted_segwit_inputs(self, + use_trusted_inputs: bool, + segwit_sign_tx_test_data: SignTxTestData) -> None: """ Submit segwit input as TrustedInput for signature. - Signature obtained should be the same as no segwit inputs were used directly were used. """ # Start test - tx_to_sign = test_data.tx_to_sign - utxos = test_data.utxos - output_paths = test_data.output_paths - change_path = test_data.change_path - expected_der_sig = test_data.expected_sig + tx_to_sign = segwit_sign_tx_test_data.tx_to_sign + utxos = segwit_sign_tx_test_data.utxos + output_paths = segwit_sign_tx_test_data.output_paths + change_path = segwit_sign_tx_test_data.change_path + # expected_der_sig = test_data.expected_sig btc = DeviceAppBtc() + parsed_tx = TxParse.from_raw(raw_tx=tx_to_sign) + parsed_utxos = [TxParse.from_raw(raw_tx=utxo) for utxo in utxos] + + if use_trusted_inputs: + hash_mode_1 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Trusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Trusted | TxHashMode.WithScript) + + # 1. Get trusted inputs + print("\n--* Get Trusted Inputs") + # Data to submit is: prevout_index (BE) || utxo tx + output_indexes = [_input.prev_tx_out_index for _input in parsed_tx.inputs] + tx_inputs = [ + btc.get_trusted_input( + prev_out_index=out_idx.val, + parsed_tx=parsed_utxo + ) + for (out_idx, parsed_utxo, utxo) in zip(output_indexes, parsed_utxos, utxos)] + print(" OK") + + prevout_hashes = [_input.prev_tx_hash for _input in parsed_tx.inputs] + # UTXO tx's are ordered in the same order as the inputs in the tx to sign, so the input index is used + # to address the correct UTXO. Ideally we should rely on the UTXO tx's hash in the tx to sign to retrieve + # the correct UTXO from the list. + out_amounts = [utxo.outputs[_input.prev_tx_out_index.val].value.buf + for utxo, _input in zip(parsed_utxos, parsed_tx.inputs)] + + for tx_input, out_idx, out_amount, prevout_hash \ + in zip(tx_inputs, output_indexes, out_amounts, prevout_hashes): + self.check_trusted_input( + trusted_input=tx_input, + out_index=out_idx.buf, # LE for comparison w/ out_idx in trusted_input + out_amount=out_amount, # utxo output #1 is requested in tx to sign input + out_hash=prevout_hash # prevout hash in tx to sign + ) + else: + hash_mode_1 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Untrusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Untrusted | TxHashMode.WithScript) + tx_inputs = parsed_tx.inputs - # 1. Get trusted inputs (submit output index + prevout tx) - output_indexes = [ - tx_to_sign[39+4-1:39-1:-1], # out_index in tx_to_sign input must be passed BE as prefix to utxo tx - tx_to_sign[103+4-1:103-1:-1] - ] - input_data = [out_idx + utxo for out_idx, utxo in zip(output_indexes, utxos)] - utxos_chunks_len = [ - [ # utxo #1 - (4+4, 2, 1), # len(prevout_index (BE)||version||input_count) - (skip 2-byte segwit Marker+flags) - 37, # len(prevout_hash||prevout_index||len(scriptSig)) - -1, # len(scriptSig, from last byte of previous chunk) + len(input_sequence) - 1, # len(output_count) - 32, # len(output_value #1||len(scriptPubkey #1)||scriptPubkey #1) - 32, # len(output_value #2||len(scriptPubkey #2)||scriptPubkey #2) - (243+4, 4) # len(locktime) - skip witness data - ], - [ # utxo #2 - (4+4, 2, 1), # len(prevout_index (BE)||version||input_count) - (skip 2-byte segwit Marker+flags) - 37, # len(prevout1_hash||prevout1_index||len(scriptSig1)) - -1, # len(scriptSig1, from last byte of previous chunk) + len(input_sequence1) - 37, # len(prevout2_hash||prevout2_index||len(scriptSig2)) - -1, # len(scriptSig2, from last byte of previous chunk) + len(input_sequence2) - 1, # len(output_count) - 32, # len(output_value #1||len(scriptPubkey #1)||scriptPubkey #1) - 32, # len(output_value #2||len(scriptPubkey #2)||scriptPubkey #2) - (414+4, 4) # len(locktime) - skip witness data - ] - ] - trusted_inputs = [ - btc.getTrustedInput( - data=input_datum, - chunks_len=chunks_len - ) - for (input_datum, chunks_len) in zip(input_data, utxos_chunks_len) - ] - print(" OK") - - out_amounts = [utxos[0][104:104+8], utxos[1][136:136+8]] - prevout_hashes = [tx_to_sign[7:7+32], tx_to_sign[71:71+32]] - for trusted_input, out_idx, out_amount, prevout_hash in zip( - trusted_inputs, output_indexes, out_amounts, prevout_hashes - ): - self.check_trusted_input( - trusted_input, - out_index=out_idx[::-1], # LE for comparison w/ out_idx in trusted_input - out_amount=out_amount, # utxo output #1 is requested in tx to sign input - out_hash=prevout_hash # prevout hash in tx to sign - ) - # 2.0 Get public keys for output paths & compute their hashes print("\n--* Get Wallet Public Key - for each tx output path") - wpk_responses = [btc.getWalletPublicKey(output_path) for output_path in output_paths] + wpk_responses = [btc.get_wallet_public_key(output_path) for output_path in output_paths] print(" OK") pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] for pubkey in pubkeys_data: print(pubkey) - # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. The original segwit input - # being replaced with the previously obtained TrustedInput, it is prefixed it with the marker - # byte for TrustedInputs (0x01) that the BTC app expects to check the Trusted Input's HMAC. - print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs having a null script length") - input_sequences = [tx_to_sign[67:67+4], tx_to_sign[131:131+4]] - ptx_to_hash_part1 = [tx_to_sign[:7]] - for trusted_input, input_sequence in zip(trusted_inputs, input_sequences): - ptx_to_hash_part1.extend([ - bytes.fromhex("01"), # TrustedInput marker byte, triggers the TrustedInput's HMAC verification - bytes([len(trusted_input)]), - trusted_input, - bytes.fromhex("00"), # Input script length = 0 (no sigScript) - input_sequence - ]) - ptx_to_hash_part1 = reduce(lambda x, y: x+y, ptx_to_hash_part1) # Get a single bytes object - - ptx_to_hash_part1_chunks_len = [ - (4, 2, 1) # len(version||input_count) - skip segwit version+flag bytes - ] - for trusted_input in trusted_inputs: - ptx_to_hash_part1_chunks_len.extend([ - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||len(scriptSig) == 0) - 4 # len(input_sequence) - ]) - - btc.untrustedTxInputHashStart( - p1="00", - p2="02", # /!\ "02" to activate BIP 143 signature (b/c the pseudo-tx - # contains segwit inputs encapsulated in TrustedInputs). - data=ptx_to_hash_part1, - chunks_len=ptx_to_hash_part1_chunks_len - ) + # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. The original segwit input + # being replaced with the previously obtained TrustedInput, it is prefixed with the marker + # byte for TrustedInputs (0x01) that the BTC app expects in order to check the Trusted Input's HMAC. + print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs " + "having a null script length") + btc.untrusted_hash_tx_input_start( + mode=hash_mode_1, + parsed_tx=parsed_tx, + inputs=tx_inputs, + parsed_utxos=parsed_utxos) print(" OK") # 2.2 Finalize the input-centric-, pseudo-tx hash with the remainder of that tx # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") - ptx_to_hash_part2 = change_path - ptx_to_hash_part2_chunks_len = [len(ptx_to_hash_part2)] - - btc.untrustedTxInputHashFinalize( - p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.CHANGE_PATH, # to derive BIP 32 change address + data=change_path) print(" OK") # 2.2.2 Continue w/ tx to sign outputs & scripts print("\n--* Untrusted Transaction Input Hash Finalize Full - Continue w/ hash of tx output") - ptx_to_hash_part3 = tx_to_sign[135:200] # output_count||repeated(output_amount||scriptPubkey) - ptx_to_hash_part3_chunks_len = [len(ptx_to_hash_part3)] - - response = btc.untrustedTxInputHashFinalize( - p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) + response = btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.MORE_BLOCKS, + data=parsed_tx) assert response == bytes.fromhex("0000") print(" OK") # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. - # 3. Sign each input individually. Because inputs are segwit, hash each input with its scriptSig - # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. + # 3. Sign each input individually. Because inputs are segwit, hash each input with its scriptSig + # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. print("\n--* Untrusted Transaction Input Hash Start, step 2 - Hash again each input individually (only 1)") - # Inputs are P2WPKH, so use 0x1976a914{20-byte-pubkey-hash}88ac as scriptSig in this step. - input_scripts = [bytes.fromhex("1976a914") + pubkey.pubkey_hash + bytes.fromhex("88ac") - for pubkey in pubkeys_data] - ptx_for_inputs = [ - [ tx_to_sign[:4], # Tx version - bytes.fromhex("0101"), # Input_count||TrustedInput marker byte - bytes([len(trusted_input)]), - trusted_input, - input_script, - input_sequence - ] for trusted_input, input_script, input_sequence in zip(trusted_inputs, input_scripts, input_sequences) - ] - - ptx_chunks_lengths = [ - [ - 5, # len(version||input_count) - segwit flag+version not sent - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||scriptSig_len == 0x19) - -1 # get len(scripSig) from last byte of previous chunk + len(input_sequence) - ] for trusted_input in trusted_inputs - ] - # Hash & sign each input individually - for ptx_for_input, ptx_chunks_len, output_path in zip(ptx_for_inputs, ptx_chunks_lengths, output_paths): + for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( - p1="00", - p2="80", # to continue previously started tx hash - data=reduce(lambda x,y: x+y, ptx_for_input), - chunks_len=ptx_chunks_len - ) + btc.untrusted_hash_tx_input_start( + # continue prev. started tx hash + mode=hash_mode_2, + parsed_tx=parsed_tx, + parsed_utxos=parsed_utxos, + inputs=[tx_input], + input_num=idx) print(" Final hash OK") # 3.2 Sign tx at last. Param is: - # Num_derivs||Dest output path||User validation code length (0x00)||tx locktime||sigHashType(always 0x01) + # Num_derivs||Dest output path||User validation code length (0x00)||tx locktime||sigHashType(always 0x01) print("\n--* Untrusted Transaction Hash Sign") - tx_to_sign_data = output_path \ - + bytes.fromhex("00") \ - + tx_to_sign[-4:] \ - + bytes.fromhex("01") + response = btc.untrusted_hash_sign( + output_path=output_path, + parsed_tx=parsed_tx) - response = btc.untrustedHashSign( - data = tx_to_sign_data - ) self.check_signature(response) # Check sig format only - # self.check_signature(response, expected_der_sig) # Can't test sig value as it depends on signing device seed - print(" Signature OK\n") - - - @pytest.mark.parametrize("test_data", [test12_data]) - def test_sign_tx_with_multiple_segwit_inputs(self, test_data: TxData): - """ - Submit segwit input as is, without encapsulating them into a TrustedInput first. - Signature obtained should be the same as for if TrustedInputs were used. - """ - # Start test - tx_to_sign = test_data.tx_to_sign - utxos = test_data.utxos - output_paths = test_data.output_paths - change_path = test_data.change_path - expected_der_sig = test_data.expected_sig - - btc = DeviceAppBtc() - - # 1.0 Get public keys for output paths & compute their hashes - print("\n--* Get Wallet Public Key - for each tx output path") - wpk_responses = [btc.getWalletPublicKey(output_path) for output_path in output_paths] - print(" OK") - pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] - for pubkey in pubkeys_data: - print(pubkey) - - # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. The original segwit input - # is used as is, prefixed with the segwit input marker byte (0x02). - print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs having a null script length") - segwit_inputs = [ # Format is: prevout_hash||prevout_index||prevout_amount - tx_to_sign[7:7+32+4] + utxos[0][104:104+8], # 104 = output #1 offset in 1st utxo, ugly hardcoding when all info is in tx :-( - tx_to_sign[71:71+32+4] + utxos[1][136:136+8] # 136 = output #0 offset in 2nd utxo - ] - input_sequences = [tx_to_sign[67:67+4], tx_to_sign[131:131+4]] - ptx_to_hash_part1 = [tx_to_sign[:7]] - for segwit_input, input_sequence in zip(segwit_inputs, input_sequences): - ptx_to_hash_part1.extend([ - bytes.fromhex("02"), # segwit input marker byte - segwit_input, - bytes.fromhex("00"), # Input script length = 0 (no sigScript) - input_sequence - ]) - ptx_to_hash_part1 = reduce(lambda x, y: x+y, ptx_to_hash_part1) # Get a single bytes object - - ptx_to_hash_part1_chunks_len = [ - (4, 2, 1) # len(version||input_count) - skip segwit version+flag bytes - ] - for segwit_input in segwit_inputs: - ptx_to_hash_part1_chunks_len.extend([ - 1 + len(segwit_input) + 1, # len(segwit_input_marker||segwit_input||len(scriptSig) == 0) - 4 # len(input_sequence) - ]) - - btc.untrustedTxInputHashStart( - p1="00", - p2="02", # /!\ "02" to activate BIP 143 signature (b/c the pseudo-tx - # contains segwit inputs). - data=ptx_to_hash_part1, - chunks_len=ptx_to_hash_part1_chunks_len - ) - print(" OK") - - # 2.2 Finalize the input-centric-, pseudo-tx hash with the remainder of that tx - # 2.2.1 Start with change address path - print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") - ptx_to_hash_part2 = change_path - ptx_to_hash_part2_chunks_len = [len(ptx_to_hash_part2)] - - btc.untrustedTxInputHashFinalize( - p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) - print(" OK") - - # 2.2.2 Continue w/ tx to sign outputs & scripts - print("\n--* Untrusted Transaction Input Hash Finalize Full - Continue w/ hash of tx output") - ptx_to_hash_part3 = tx_to_sign[135:200] # output_count||repeated(output_amount||scriptPubkey) - ptx_to_hash_part3_chunks_len = [len(ptx_to_hash_part3)] - - response = btc.untrustedTxInputHashFinalize( - p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) - assert response == bytes.fromhex("0000") - print(" OK") - # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. - - # 3. Sign each input individually. Because inputs are true segwit, hash each input with its scriptSig - # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. - print("\n--* Untrusted Transaction Input Hash Start, step 2 - Hash again each input individually (2)") - # Inputs are P2WPKH, so use 0x1976a914{20-byte-pubkey-hash}88ac as scriptSig in this step. - input_scripts = [bytes.fromhex("1976a914") + pubkey.pubkey_hash + bytes.fromhex("88ac") - for pubkey in pubkeys_data] - ptx_for_inputs = [ - [ tx_to_sign[:4], # Tx version - bytes.fromhex("0102"), # input_count||segwit input marker byte - segwit_input, - input_script, - input_sequence - ] for trusted_input, input_script, input_sequence in zip(segwit_inputs, input_scripts, input_sequences) - ] - - ptx_chunks_lengths = [ - [ - 5, # len(version||input_count) - segwit flag+version not sent - 1 + len(segwit_input) + 1, # len(segwit_input_marker||segwit_input||scriptSig_len == 0x19) - -1 # get len(scripSig) from last byte of previous chunk + len(input_sequence) - ] for segwit_input in segwit_inputs - ] - - # Hash & sign each input individually - for ptx_for_input, ptx_chunks_len, output_path in zip(ptx_for_inputs, ptx_chunks_lengths, output_paths): - # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( - p1="00", - p2="80", # to continue previously started tx hash - data=reduce(lambda x,y: x+y, ptx_for_input), - chunks_len=ptx_chunks_len - ) - print(" Final hash OK") - - # 3.2 Sign tx at last. Param is: - # Num_derivs||Dest output path||User validation code length (0x00)||tx locktime||sigHashType(always 0x01) - print("\n--* Untrusted Transaction Hash Sign") - tx_to_sign_data = output_path \ - + bytes.fromhex("00") \ - + tx_to_sign[-4:] \ - + bytes.fromhex("01") - - response = btc.untrustedHashSign( - data = tx_to_sign_data - ) - self.check_signature(response) # print only - # self.check_signature(response, expected_der_sig) print(" Signature OK\n") - diff --git a/tests/test_btc_signature.py b/tests/test_btc_signature.py index bea806cd..1de3f824 100644 --- a/tests/test_btc_signature.py +++ b/tests/test_btc_signature.py @@ -1,484 +1,147 @@ -# -# Note on 'chunks_len' values used in tests: -# ----------------------------------------- -# The BTC app tx parser requires the tx data to be sent in chunks. For some tx fields -# it doesn't matter where the field is cut but for others it does and the rule is unclear. -# -# Until I get a simple to use and working Tx parser class done, a workaround is -# used to split the tx in chunks of specific lengths, as done in ledgerjs' Btc.test.js -# file. Tx chunks lengths are gathered in a list, following the grammar below: -# -# chunks_lengths := list_of(chunk_desc,) i.e. [chunk_desc, chunk_desc,...] -# chunk_desc := offs_len_tuple | length | -1 -# offs_len_tuple := (offset, length) | (length1, skip_length, length2) -# -# with: -# offset: -# the offset of the 1st byte in the tx for the data chunk to be sent. Allows to skip some -# parts of the tx which should not be sent to the tx parser. -# length: -# the length of the chunk to be sent -# length1, length2: -# the lengths of 2 non-contiguous chunks of data in the tx separated by a block of -# skip_length bytes. The 2 non-contiguous blocks are concatenated together and the bloc -# of skip_length bytes is ignored. This is used when 2 non-contiguous parts of the tx -# must be sent in the same APDU but without the in-between bytes. -# -1: -# the length of the chunk to be sent is the last byte of the previous chunk + 4. This is -# used to send input/output scripts + their following 4-byte sequence_number in chunks. -# Sequence_number can't be sent separately from its output script as it puts the -# BTC app's tx parser in an invalid state (sw 0x6F01 returned, not clear why). This implicit -# +4 is to work around that limitation (but design-wise, it introduces knowledge of the tx -# format in the _sendApdu() method used by the tests :/). - -import pytest -from typing import Optional, List -from functools import reduce -from helpers.basetest import BaseTestBtc, BtcPublicKey, TxData -from helpers.deviceappbtc import DeviceAppBtc +""" +Ledger BTC app unit tests, Legacy BTC tx, 1 input from segwit utxo tx + +Note +---- +The BTC app tx parser requires the tx data to be sent in chunks. For some tx fields +it doesn't matter where the field is cut but for others it does and it is sometimes +unclear which APDU is sensitive to fields boundary and which are not. -# BTC Testnet segwit tx used as a "prevout" tx. -# txid: 2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753 -# VO_P2WPKH -utxos = [ - bytes.fromhex( - # Version no (4 bytes) @offset 0 - "02000000" - # Segwit Marker + Flag @offset 4 - # /!\ It must be removed from the tx data passed to GetTrustedInput - "0001" - # In-counter (varint 1-9 bytes) @offset 6 - "02" - # 1st Previous Transaction hash (32 bytes) @offset 7 - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9" - # 1st Previous Txout-index (4 bytes) @offset 39 - "00000000" - # 1st Txin-script length (varint 1-9 bytes) @offset 43 - "00" - # /!\ no Txin-script (a.k.a scriptSig) because P2WPKH - # sequence_no (4 bytes) @offset 44 - "fdffffff" - # 2nd Previous Transaction hash (32 bytes) @offset 48 - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9" - # 2nd Previous Txout-index (4 bytes) @offset 80 - "01000000" - # 2nd Tx-in script length (varint 1-9 bytes) @offset 84 - "00" - # /!\ no Txin-script (a.k.a scriptSig) because P2WPKH - # sequence_no (4 bytes) @offset 85 - "fdffffff" - # Out-counter (varint 1-9 bytes) @offset 89 - "01" - # value in satoshis (8 bytes) @offset 90 - "01410f0000000000" # 999681 satoshis = 0,00999681 BTC - # Txout-script length (varint 1-9 bytes) @offset 98 - "16" # 22 - # Txout-script (a.k.a scriptPubKey, ) @offset 99 - "0014e4d3a1ec51102902f6bbede1318047880c9c7680" - # Witnesses (1 for each input if Marker+Flag=0001) @offset 121 - # /!\ They will be removed from the tx data passed to GetTrustedInput - "0247" - "30440220495838c36533616d8cbd6474842459596f4f312dce5483fe650791c8" - "2e17221c02200660520a2584144915efa8519a72819091e5ed78c52689b24235" - "182f17d96302012102ddf4af49ff0eae1d507cc50c86f903cd6aa0395f323975" - "9c440ea67556a3b91b" - "0247" - "304402200090c2507517abc7a9cb32452aabc8d1c8a0aee75ce63618ccd90154" - "2415f2db02205bb1d22cb6e8173e91dc82780481ea55867b8e753c35424da664" - "f1d2662ecb1301210254c54648226a45dd2ad79f736ebf7d5f0fc03b6f8f0e6d" - "4a61df4e531aaca431" - # lock_time (4 bytes) @offset 335 - "a7011900" - ), -] +The tests below rely on two utilitity classes to work around that issue: -# The tx we want to sign, referencing the hash of the prevout segwit tx above -# in its input. -tx_to_sign = bytes.fromhex( - # Version no (4 bytes) @offset 0 - "02000000" - # In-counter (varint 1-9 bytes) @offset 4 - "01" - # Txid (hash) of prevout segwit tx (32 bytes) @offset 5 - # /!\ It will be replaced, along with following prevout index - # by the result from GetTrustedInput - "2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753" - # Previous Txout-index (4 bytes) @offset 37 - "00000000" - # scriptSig length (varint 1-9 bytes) @offset 41 - "19" - # scriptSig (25 bytes) @offset 42 - "76a914e4d3a1ec51102902f6bbede1318047880c9c768088ac" - # sequence_no (4 bytes) @offset 67 - "fdffffff" - # Out-counter (varint 1-9 bytes) @offset 71 - "02" - # 1st value in satoshis (8 bytes) @offset 72 - "1027000000000000" # 10000 satoshis = 0.0001 BTC - # 1st scriptPubkey length (varint 1-9 bytes) @offset 80 - "16" - # 1st scriptPubkey (22 bytes) @offset 81 - "0014161d283ebbe0e6bc3d90f4c456f75221e1b3ca0f" - # 2nd value in satoshis (8 bytes) @offset 103 - "64190f0000000000" # 989540 satoshis = 0,0098954 BTC - # 2nd scriptPubkey length (varint 1-9 bytes) @offset 104 - "16" - # 2nd scriptPubkey (22 bytes) @offset 105 - "00144c5133c242683d33c61c4964611d82dcfe0d7a9a" - # lock_time (4 bytes) @offset -4 - "a7011900" -) +- The TxParse class parses a BTC tx into a dataclass the attributes of which are the + fields the BTC app needs to sign a tx. -# Expected signature (except last sigHashType byte) was extracted from raw tx at: -# https://tbtc.bitaps.com/raw/transaction/a9a7ffabd6629009488546eb1fafd5ae2c3d0008bc4570c20c273e51b2ce5abe -expected_der_sig = [ - bytes.fromhex( # for output #1 - "3044" - "0220" "2cadfbd881f592ea82e69038c7ada8f1ae50919e3be92ad1cd5fcc0bd142b2f5" - "0220" "646a699b5532fcdf38b196157e216c8808ae7bde5e786b8f3cbf2502d0f14c13" - "01"), -] +- The DeviceAppBtc class implements the specificities of the BTC app expected payloads. + It is in charge of composing the payloads of the various APDUS involved in a tx + signature generation. To that effect, it exposes an API which mimics in names those + APDUs. +""" -output_paths = [bytes.fromhex("05""80000054""80000001""80000000""00000000""00000000"),] # 84'/1'/0'/0/0 -change_path = bytes.fromhex("05""80000054""80000001""80000000""00000001""00000001") # 84'/1'/0'/1/1 +import pytest +from helpers.basetest import BaseTestBtc +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc, BTC_P1 +from helpers.txparser.transaction import TxHashMode, TxParse +from conftest import SignTxTestData -test12_data = TxData( - tx_to_sign=tx_to_sign, - utxos=utxos, - output_paths=output_paths, - change_path=change_path, - expected_sig=expected_der_sig -) @pytest.mark.btc @pytest.mark.manual class TestBtcTxSignature(BaseTestBtc): - - @pytest.mark.parametrize("test_data", [test12_data]) - def test_submit_trusted_segwit_input_btc_transaction(self, test_data: TxData) -> None: + @pytest.mark.parametrize("use_trusted_inputs", [True, False]) + def test_sign_tx_with_trusted_segwit_input(self, + use_trusted_inputs: bool, + btc_sign_tx_test_data: SignTxTestData) -> None: """ Test signing a btc transaction w/ segwit inputs submitted as TrustedInputs - From app doc "btc.asc": - "When using Segregated Witness Inputs the signing mechanism differs + From app doc "btc.asc": + "When using Segregated Witness Inputs the signing mechanism differs slightly: - - The transaction shall be processed first with all inputs having a null script length - - Then each input to sign shall be processed as part of a pseudo transaction with a + - The transaction shall be processed first with all inputs having a null script length + - Then each input to sign shall be processed as part of a pseudo transaction with a single input and no outputs." - - - Attention: Seed to initialize device with is: - "palm hammer feel bulk sting broccoli six stay ramp develop hip pony play" - "never tourist phrase wrist prepare ladder egg lottery aware dinner express" """ # Start test - tx_to_sign = test_data.tx_to_sign - utxos = test_data.utxos - output_paths = test_data.output_paths - change_path = test_data.change_path - expected_der_sig = test_data.expected_sig + tx_to_sign = btc_sign_tx_test_data.tx_to_sign + utxos = btc_sign_tx_test_data.utxos + output_paths = btc_sign_tx_test_data.output_paths + change_path = btc_sign_tx_test_data.change_path + # expected_der_sig = test_data.expected_sig btc = DeviceAppBtc() - - # 1. Get trusted inputs (submit prevout tx + output index) - print("\n--* Get Trusted Inputs") - # Data to submit is: prevout_index (BE)||utxo tx - output_indexes = [ - tx_to_sign[37+4-1:37-1:-1], - ] - input_data = [out_idx + utxo for out_idx, utxo in zip(output_indexes, utxos)] - utxos_chunks_len = [ - [ - (4+4, 2, 1), # len(prevout_index (BE)||version||input_count) - (skip 2-byte segwit Marker+flags) - 37, # len(prevout_hash #1||prevout_index #1||len(scriptSig #1) = 0x00) - 4, # len(input_sequence) - 37, # len(prevout_hash #2||prevout_index #2||len(scriptSig #2) = 0x00) - 4, # len(input_sequence) - 1, # len(output_count) - 31, # len(output_value||len(scriptPubkey)||scriptPubkey) - (335+4, 4) # len(locktime) - skip witness data - ], - ] - trusted_inputs = [ - btc.getTrustedInput( - data=input_datum, - chunks_len=chunks_len - ) - for (input_datum, chunks_len) in zip(input_data, utxos_chunks_len) - ] + parsed_tx = TxParse.from_raw(raw_tx=tx_to_sign) + parsed_utxos = [TxParse.from_raw(raw_tx=utxo) for utxo in utxos] + + if use_trusted_inputs: + hash_mode_1 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Trusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Trusted | TxHashMode.WithScript) + + # 1. Get trusted inputs (submit prevout tx + output index) + print("\n--* Get Trusted Inputs") + # Data to submit is: prevout_index (BE)||utxo tx + + output_indexes = [_input.prev_tx_out_index for _input in parsed_tx.inputs] + tx_inputs = [ + btc.get_trusted_input( + prev_out_index=out_idx.val, + parsed_tx=parsed_utxo + ) + for (out_idx, parsed_utxo, utxo) in zip(output_indexes, parsed_utxos, utxos)] + print(" OK") + + out_amounts = [_output.value.buf for parsed_utxo in parsed_utxos for _output in parsed_utxo.outputs] + prevout_hashes = [_input.prev_tx_hash for _input in parsed_tx.inputs] + for tx_input, out_idx, out_amount, prevout_hash \ + in zip(tx_inputs, output_indexes, out_amounts, prevout_hashes): + self.check_trusted_input( + trusted_input=tx_input, + out_index=out_idx.buf, # LE for comparison w/ out_idx in trusted_input + out_amount=out_amount, # utxo output #1 is requested in tx to sign input + out_hash=prevout_hash # prevout hash in tx to sign + ) + else: + hash_mode_1 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Untrusted | TxHashMode.NoScript) + hash_mode_2 = TxHashMode(TxHashMode.SegwitBtc | TxHashMode.Untrusted | TxHashMode.WithScript) + tx_inputs = parsed_tx.inputs + + # 2.0 Get public keys for output paths & compute their hashes + print("\n--* Get Wallet Public Key - for each tx output path") + wpk_responses = [btc.get_wallet_public_key(output_path) for output_path in output_paths] print(" OK") + pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] + for pubkey in pubkeys_data: + print(pubkey) - out_amounts = [utxos[0][90:90+8]] - prevout_hashes = [tx_to_sign[5:5+32]] - for trusted_input, out_idx, out_amount, prevout_hash in zip( - trusted_inputs, output_indexes, out_amounts, prevout_hashes - ): - self.check_trusted_input( - trusted_input, - out_index=out_idx[::-1], # LE for comparison w/ out_idx in trusted_input - out_amount=out_amount, # utxo output #1 is requested in tx to sign input - out_hash=prevout_hash # prevout hash in tx to sign - ) - - # Not needed for this tx that already contains a P2WPKH scriptSig in its input, see step 3. - # # 2.0 Get public keys for output paths & compute their hashes - # print("\n--* Get Wallet Public Key - for each tx output path") - # wpk_responses = [btc.getWalletPublicKey(output_path) for output_path in output_paths] - # print(" OK") - # pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] - # for pubkey in pubkeys_data: - # print(pubkey) - - # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. The original segwit input + # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. The original segwit input # being replaced with the previously obtained TrustedInput, it is prefixed it with the marker # byte for TrustedInputs (0x01) that the BTC app expects to check the Trusted Input's HMAC. - print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs having a null script length") - input_sequences = [tx_to_sign[67:67+4]] - ptx_to_hash_part1 = [tx_to_sign[:5]] - for trusted_input, input_sequence in zip(trusted_inputs, input_sequences): - ptx_to_hash_part1.extend([ - bytes.fromhex("01"), # TrustedInput marker byte, triggers the TrustedInput's HMAC verification - bytes([len(trusted_input)]), - trusted_input, - bytes.fromhex("00"), # Input script length = 0 (no sigScript) - input_sequence - ]) - ptx_to_hash_part1 = reduce(lambda x, y: x+y, ptx_to_hash_part1) # Get a single bytes object - - ptx_to_hash_part1_chunks_len = [ - 5, # len(version||input_count) - ] - for trusted_input in trusted_inputs: - ptx_to_hash_part1_chunks_len.extend([ - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||len(scriptSig) == 0) - 4 # len(input_sequence) - ]) - - btc.untrustedTxInputHashStart( - p1="00", - p2="02", # /!\ "02" to activate BIP 143 signature (b/c the pseudo-tx - # contains segwit inputs encapsulated in TrustedInputs). - data=ptx_to_hash_part1, - chunks_len=ptx_to_hash_part1_chunks_len - ) + print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs " + "having a null script length") + btc.untrusted_hash_tx_input_start( + mode=hash_mode_1, + parsed_tx=parsed_tx, + inputs=tx_inputs, + parsed_utxos=parsed_utxos) print(" OK") # 2.2 Finalize the input-centric-, pseudo-tx hash with the remainder of that tx # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") - ptx_to_hash_part2 = change_path - ptx_to_hash_part2_chunks_len = [len(ptx_to_hash_part2)] - - btc.untrustedTxInputHashFinalize( - p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.CHANGE_PATH, # to derive BIP 32 change address + data=change_path) print(" OK") # 2.2.2 Continue w/ tx to sign outputs & scripts print("\n--* Untrusted Transaction Input Hash Finalize Full - Continue w/ hash of tx output") - ptx_to_hash_part3 = tx_to_sign[71:-4] # output_count||repeated(output_amount||scriptPubkey) - ptx_to_hash_part3_chunks_len = [len(ptx_to_hash_part3)] - - response = btc.untrustedTxInputHashFinalize( - p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) + response = btc.untrusted_hash_tx_input_finalize( + p1=BTC_P1.MORE_BLOCKS, + data=parsed_tx) assert response == bytes.fromhex("0000") print(" OK") # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. - # 3. Sign each input individually. Because inputs are segwit, hash each input with its scriptSig - # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. + # 3. Sign each input individually. Because inputs are segwit, hash each input with its scriptSig + # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. print("\n--* Untrusted Transaction Input Hash Start, step 2 - Hash again each input individually (only 1)") - - # # Inputs are P2WPKH, so use 0x1976a914{20-byte-pubkey-hash}88ac as scriptSig in this step. - input_scripts = [tx_to_sign[41:41 + tx_to_sign[41] + 1]] # tx already contains the correct input script for P2WPKH - # input_scripts = [bytes.fromhex("1976a914") + pubkey.pubkey_hash + bytes.fromhex("88ac") - # for pubkey in pubkeys_data] - - # Inputs scripts in the tx to sign are already w/ the correct form - ptx_for_inputs = [ - [ tx_to_sign[:5], # Tx version||Input_count - bytes.fromhex("01"), # TrustedInput marker - bytes([len(trusted_input)]), - trusted_input, - input_script, - input_sequence - ] for trusted_input, input_script, input_sequence in zip(trusted_inputs, input_scripts, input_sequences) - ] - - ptx_chunks_lengths = [ - [ - 5, # len(version||input_count) - 1 + 1 + len(trusted_input) + 1, # len(trusted_input_marker||len(trusted_input)||trusted_input||scriptSig_len == 0x19) - -1 # get len(scripSig) from last byte of previous chunk + len(input_sequence) - ] for trusted_input in trusted_inputs - ] - - # Hash & sign each input individually - for ptx_for_input, ptx_chunks_len, output_path in zip(ptx_for_inputs, ptx_chunks_lengths, output_paths): + for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( - p1="00", - p2="80", # to continue previously started tx hash - data=reduce(lambda x,y: x+y, ptx_for_input), - chunks_len=ptx_chunks_len - ) + btc.untrusted_hash_tx_input_start( + mode=hash_mode_2, + parsed_tx=parsed_tx, + parsed_utxos=parsed_utxos, + input_num=idx, + inputs=[tx_input]) print(" Final hash OK") # 3.2 Sign tx at last. Param is: - # Num_derivs||Dest output path||User validation code length (0x00)||tx locktime||sigHashType(always 0x01) + # Num_derivs || output path || User validation code len (0x00) || tx locktime|| sigHashType (always 0x01) print("\n--* Untrusted Transaction Hash Sign") - tx_to_sign_data = output_path \ - + bytes.fromhex("00") \ - + tx_to_sign[-4:] \ - + bytes.fromhex("01") + response = btc.untrusted_hash_sign( + output_path=output_path, + parsed_tx=parsed_tx) - response = btc.untrustedHashSign( - data = tx_to_sign_data - ) self.check_signature(response) - #self.check_signature(response, expected_der_sig) - print(" Signature OK\n") - - - @pytest.mark.parametrize("test_data", [test12_data]) - def test_sign_tx_with_untrusted_segwit_input_shows_warning(self, test_data: TxData): - """ - Submit segwit inputs as is, without encapsulating them into a TrustedInput first. - - Signature obtained should be the same as for TrustedInputs were used, and device - should display a warning screen. - """ - # Start test - tx_to_sign = test_data.tx_to_sign - utxos = test_data.utxos - output_paths = test_data.output_paths - change_path = test_data.change_path - expected_der_sig = test_data.expected_sig - - btc = DeviceAppBtc() - - # Not needed for this tx that already contains a P2WPKH scriptSig in its input, see step 3. - # # 1. Get public keys for output paths & compute their hashes - # print("\n--* Get Wallet Public Key - for each tx output path") - # wpk_responses = [btc.getWalletPublicKey(output_path) for output_path in output_paths] - # print(" OK") - # pubkeys_data = [self.split_pubkey_data(data) for data in wpk_responses] - # for pubkey in pubkeys_data: - # print(pubkey) - - # 2.1 Construct a pseudo-tx without input script, to be hashed 1st. The original segwit input - # is used as is, prefixed with the segwit input marker byte (0x02). - print("\n--* Untrusted Transaction Input Hash Start - Hash tx to sign first w/ all inputs having a null script length") - segwit_inputs = [ # Format is: prevout_hash||prevout_index||prevout_amount - tx_to_sign[5:5+32+4] + utxos[0][90:90+8] # 104 = output #0 offset in utxo - ] - input_sequences = [tx_to_sign[67:67+4]] - ptx_to_hash_part1 = [tx_to_sign[:5]] - for segwit_input, input_sequence in zip(segwit_inputs, input_sequences): - ptx_to_hash_part1.extend([ - bytes.fromhex("02"), # segwit input marker byte - segwit_input, - bytes.fromhex("00"), # Input script length = 0 (no sigScript) - input_sequence - ]) - ptx_to_hash_part1 = reduce(lambda x, y: x+y, ptx_to_hash_part1) # Get a single bytes object - - ptx_to_hash_part1_chunks_len = [ - 5 # len(version||input_count) - skip segwit version+flag bytes - ] - for segwit_input in segwit_inputs: - ptx_to_hash_part1_chunks_len.extend([ - 1 + len(segwit_input) + 1, # len(segwit_input_marker||segwit_input||len(scriptSig) == 0) - 4 # len(input_sequence) - ]) - - btc.untrustedTxInputHashStart( - p1="00", - p2="02", # /!\ "02" to activate BIP 143 signature (b/c the pseudo-tx - # contains a segwit input). - data=ptx_to_hash_part1, - chunks_len=ptx_to_hash_part1_chunks_len - ) - print(" OK") - - # 2.2 Finalize the input-centric-, pseudo-tx hash with the remainder of that tx - # 2.2.1 Start with change address path - print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") - ptx_to_hash_part2 = change_path - ptx_to_hash_part2_chunks_len = [len(ptx_to_hash_part2)] - - btc.untrustedTxInputHashFinalize( - p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) - print(" OK") - - # 2.2.2 Continue w/ tx to sign outputs & scripts - print("\n--* Untrusted Transaction Input Hash Finalize Full - Continue w/ hash of tx output") - ptx_to_hash_part3 = tx_to_sign[71:-4] # output_count||repeated(output_amount||scriptPubkey) - ptx_to_hash_part3_chunks_len = [len(ptx_to_hash_part3)] - - response = btc.untrustedTxInputHashFinalize( - p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) - assert response == bytes.fromhex("0000") - print(" OK") - # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. - - # 3. Sign each input individually. Because inputs are true segwit, hash each input with its scriptSig - # and sequence individually, each in a pseudo-tx w/o output_count, outputs nor locktime. - print("\n--* Untrusted Transaction Input Hash Start, step 2 - Hash again each input individually (2)") - # Inputs are P2WPKH, so use 0x1976a914{20-byte-pubkey-hash}88ac as scriptSig in this step. - input_scripts = [tx_to_sign[41:41 + tx_to_sign[41] + 1]] # script is already in correct form inside tx - # input_scripts = [bytes.fromhex("1976a914") + pubkey.pubkey_hash + bytes.fromhex("88ac") - # for pubkey in pubkeys_data] - ptx_for_inputs = [ - [ tx_to_sign[:4], # Tx version - bytes.fromhex("0102"), # input_count||segwit input marker byte - segwit_input, - input_script, - input_sequence - ] for trusted_input, input_script, input_sequence in zip(segwit_inputs, input_scripts, input_sequences) - ] - - ptx_chunks_lengths = [ - [ - 5, # len(version||input_count) - segwit flag+version not sent - 1 + len(segwit_input) + 1, # len(segwit_input_marker||segwit_input||scriptSig_len == 0x19) - -1 # get len(scripSig) from last byte of previous chunk + len(input_sequence) - ] for segwit_input in segwit_inputs - ] - - # Hash & sign each input individually - for ptx_for_input, ptx_chunks_len, output_path in zip(ptx_for_inputs, ptx_chunks_lengths, output_paths): - # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( - p1="00", - p2="80", # to continue previously started tx hash - data=reduce(lambda x,y: x+y, ptx_for_input), - chunks_len=ptx_chunks_len - ) - print(" Final hash OK") - - # 3.2 Sign tx at last. Param is: - # Num_derivs||Dest output path||User validation code length (0x00)||tx locktime||sigHashType(always 0x01) - print("\n--* Untrusted Transaction Hash Sign") - tx_to_sign_data = output_path \ - + bytes.fromhex("00") \ - + tx_to_sign[-4:] \ - + bytes.fromhex("01") - - response = btc.untrustedHashSign( - data = tx_to_sign_data - ) - self.check_signature(response) # print only - #self.check_signature(response, expected_der_sig) + # self.check_signature(response, expected_der_sig) print(" Signature OK\n") -