From 349294f32930126b6669f1cf4133846f91cfa0b1 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Thu, 11 Jun 2020 17:00:07 +0200 Subject: [PATCH 01/12] Add transaction parser utility (BTC, segwit, Zcash for now) --- tests/helpers/txparser/__init__.py | 3 + tests/helpers/txparser/transaction.py | 290 ++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 tests/helpers/txparser/__init__.py create mode 100644 tests/helpers/txparser/transaction.py diff --git a/tests/helpers/txparser/__init__.py b/tests/helpers/txparser/__init__.py new file mode 100644 index 00000000..fafbbcdd --- /dev/null +++ b/tests/helpers/txparser/__init__.py @@ -0,0 +1,3 @@ +""" +Helper module to parse raw unsigned Bitcoin or Zcash transactions +""" diff --git a/tests/helpers/txparser/transaction.py b/tests/helpers/txparser/transaction.py new file mode 100644 index 00000000..fd0b833d --- /dev/null +++ b/tests/helpers/txparser/transaction.py @@ -0,0 +1,290 @@ +from io import BytesIO +from typing import Optional, List, NewType, Union, Literal, cast + +try: + from typing import TypedDict # >=3.8 +except ImportError: + from mypy_extensions import TypedDict # <=3.7 + +# Types of the transaction fields, used to check fields lengths +u8 = NewType("u8", int) # 1 byte +u16 = NewType("u16", int) # 2 bytes +u32 = NewType("u32", int) # 4 bytes +u64 = NewType("u64", int) # 8 bytes +i64 = NewType("i64", int) # 8 bytes +varint = NewType("varint", int) # 1-9 bytes +bytes32 = NewType("bytes32", type(bytes(32))) # 32 bytes +bytes64 = NewType("bytes32", type(bytes(64))) # 64 bytes +txtype = NewType("txtype", int) + + +# Types for the supported kinds of transactions. Extend as needed. +class TxType: + btc: txtype = 0 + segwit: txtype = 1 + zcash: txtype = 2 + + +# Dictionaries holding special values introduced by BTC protocol evolution or +# BTC-derivative currencies +class SpecialFields(TypedDict): + """ + Base dictionaries holding the special 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 + + +class SpecialSegwit(SpecialFields): + """ + Stores the Marker & Flag bytes of a segwit-enabled Bitcoin transaction + """ + marker: u8 + flag: u8 + + +class SpecialZcashHeader(SpecialFields): + """ + Stores the transaction fields secific to Zcash added to the header of the BTC raw tx + """ + overwintered_flag: bool + version_group_id: u32 + + +class SpecialZcashFooter(SpecialFields): + """ + Stores the transaction fields secific to Zcash appended to the end of the raw BTC tx + """ + expiry_height: u32 + # All remaining Zcash-specific fields (value_balance, shielded_spend, shielded_output, + # join_split, binding_sig) are hashed as a single bloc by the BTC app so we don't need to + # to differentiate each field + extra_data: bytes + + +# BTC transaction description dictionaries +class TxHeader(TypedDict): + """ + Raw transaction header fields + """ + version: u32 + special: SpecialFields + + +class TxFooter(SpecialFields): + special: SpecialFields + + +class TxInput(TypedDict): + prev_tx_hash: bytes + prev_tx_out_index: u32 + script_len: varint + script: bytes + sequence_no: u32 + + +class TxOutput(TypedDict): + value: u64 + script_len: varint + script: bytes + + +class Tx(TypedDict): + """ + Helper class that eases the parsing of a raw unsigned Bitcoin, Bitcoin segwit or Zcash transaction. + """ + type: txtype + header: TxHeader + input_count: varint + inputs: List[TxInput] + output_count: varint + outputs: List[TxOutput] + lock_time: u32 + 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)`` + """ + + @classmethod + def from_raw(cls, + raw_tx: Union[bytes, str], + endianness: Literal['big', 'little'] = '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 _read_varint(buf: BytesIO, + prefix: Optional[bytes] = None, + bytes_order: Literal['big', 'little'] = 'little') -> varint: + """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 cast(varint, int.from_bytes(b, 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 ValueError(f"Cant read {size} bytes in buffer!") + return b + + def _read_uint(buf: BytesIO, + bytes_len: int, + bytes_order: Literal['big', 'little'] = '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_bytes(buf, 1)) + + def _read_u16(buf: BytesIO, bytes_order: Literal['big', 'little'] = '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: Literal['big', 'little'] = '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_u64(buf: BytesIO, bytes_order: Literal['big', 'little'] = 'little') -> u64: + """Returns the integer value encoded in the next 8 bytes of 'buf'. + """ + return cast(u64, _read_uint(buf, 8, bytes_order)) + + def _parse_inputs(buf: BytesIO, in_count: int) -> 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: bytes = _read_bytes(buf, 32) + prev_tx_out_index: u32 = _read_u32(buf) + in_script_len: varint = _read_varint(buf) + in_script: bytes = _read_bytes(buf, in_script_len) + sequence_no: u32 = _read_u32(buf) + _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_no=sequence_no)) + return _inputs + + def _parse_outputs(buf: BytesIO, out_count: int) -> List[TxOutput]: + """Returns a list of TxOutputs containing the raw tx's output fields.""" + _outputs: List[TxOutput] = [] + for _ in range(out_count): + value: u64 = _read_u64(buf) + out_script_len: varint = _read_varint(buf) + out_script: bytes = _read_bytes(buf, out_script_len) + _outputs.append( + TxOutput( + value=value, + script_len=out_script_len, + script=out_script)) + return _outputs + + 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(0) + + byte0: Optional[u8] = _read_u8(buf) + byte1: Optional[u8] = _read_u8(buf) + if byte0 == b"\x00" and byte1 == b'\x01': + # Either segwit tx or legacy coinbase tx =>if coinbase, byte1 is the output count (1 byte) + buf.seek(8) + buf.seek(_read_u8(buf) + 4) # If a coinbase tx, stream pointer is at the end of the stream now + if buf.read(1): + typ = TxType.segwit + elif byte0 == b'\0x85' and byte1 == b'\x20': # 1st two bytes of zcash special bytes + bytes2_3: Optional[u16] = _read_u16(buf, 'big') + if bytes2_3 == b'\x2f89': + typ = TxType.zcash + + buf.seek(stream_pos) + return typ + + # + # Transaction parsing code starts here + # + io_buf: BytesIO = BytesIO(bytes.fromhex(raw_tx)) if type(raw_tx) == str else BytesIO(raw_tx) + version: u32 = _read_u32(io_buf, endianness) + tx_type: txtype = _tx_type(io_buf) + + marker: Optional[u8] = None + flag: Optional[u8] = None + version_group_id: Optional[u32] = None + overwintered_flag: bool = False + expiry_height: Optional[u32] = None + extra_data: Optional[bytes] = None + + if tx_type == TxType.segwit: + marker = _read_u8(io_buf) + flag = _read_u8(io_buf) + elif tx_type == TxType.zcash: + version_group_id = _read_u32(io_buf, endianness) + overwintered_flag = True if version_group_id & 0x80000000 else False + + input_count: varint = _read_varint(io_buf) + inputs: List[TxInput] = _parse_inputs(io_buf, input_count) + output_count: varint = _read_varint(io_buf) + outputs: List[TxOutput] = _parse_outputs(io_buf, output_count) + lock_time: u32 = _read_u32(io_buf) + + if tx_type == TxType.zcash: + expiry_height: Optional[u32] = _read_u32(io_buf) + extra_data: Optional[bytes] = _read_bytes(io_buf, -1) # read up to EOF + + return Tx( + type=tx_type, + header=TxHeader( + version=version, + special=SpecialSegwit( + marker=marker, + flag=flag) if tx_type == TxType.segwit + else SpecialZcashHeader( + overwintered_flag=overwintered_flag, + version_group_id=version_group_id) if tx_type == TxType.zcash + else None + ), + input_count=input_count, + inputs=inputs, + output_count=output_count, + outputs=outputs, + lock_time=lock_time, + footer=TxFooter( + special=SpecialZcashFooter( + expiry_height=expiry_height, + extra_data=extra_data) if tx_type == TxType.zcash + else None + ) + ) From 516590393f976fc39c83385336d753310d4257d7 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Thu, 11 Jun 2020 18:21:25 +0200 Subject: [PATCH 02/12] Rationalize helpers module file structure --- tests/helpers/__init__.py | 6 +- tests/helpers/deviceappproxy/__init__.py | 3 + .../{ => deviceappproxy}/apduabstract.py | 100 ++++++------------ .../{ => deviceappproxy}/deviceappbtc.py | 18 ++-- .../{ => deviceappproxy}/deviceappproxy.py | 4 +- 5 files changed, 51 insertions(+), 80 deletions(-) create mode 100644 tests/helpers/deviceappproxy/__init__.py rename tests/helpers/{ => deviceappproxy}/apduabstract.py (63%) rename tests/helpers/{ => deviceappproxy}/deviceappbtc.py (85%) rename tests/helpers/{ => deviceappproxy}/deviceappproxy.py (97%) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index de101117..4c7ea47c 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1 +1,5 @@ -import sys +""" +Helper modules: + - deviceappproxy: send & receive APDUs to a Ledger device + - txparser: simplifies parsing of a raw unsigned BTC transaction +""" \ No newline at end of file diff --git a/tests/helpers/deviceappproxy/__init__.py b/tests/helpers/deviceappproxy/__init__.py new file mode 100644 index 00000000..8604e94d --- /dev/null +++ b/tests/helpers/deviceappproxy/__init__.py @@ -0,0 +1,3 @@ +""" +Module that abstract communicating with a Ledger device through ISO-7816 +""" \ No newline at end of file diff --git a/tests/helpers/apduabstract.py b/tests/helpers/deviceappproxy/apduabstract.py similarity index 63% rename from tests/helpers/apduabstract.py rename to tests/helpers/deviceappproxy/apduabstract.py index ae72f8ba..1bf115a7 100644 --- a/tests/helpers/apduabstract.py +++ b/tests/helpers/deviceappproxy/apduabstract.py @@ -1,10 +1,9 @@ -from typing import List, Dict, Union, Optional +from typing import List, Dict, Union, Optional, cast, NewType from dataclasses import dataclass, field from functools import reduce - # Type aliases -BytesOrStr = Union[bytes, str] +BytesOrStr = NewType("BytesOrStr", Union[bytes, str]) @dataclass @@ -14,6 +13,7 @@ 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") @@ -32,16 +32,17 @@ class ApduSet: to be sent to a device/an app" """ _apdus: ApduDict = {} - - def __init__(self, apdus: Optional[ApduDict] = None, max_lc: int = 255 ) -> None: + + 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: + + @staticmethod + def _bytes(data: BytesOrStr) -> bytes: if type(data) is bytes: return data if type(data) is int: - return bytes([data]) + return bytes([cast(int, data)]) if type(data) is str: return bytes.fromhex(data) raise TypeError(f"{data} cannot be converted to bytes") @@ -50,7 +51,7 @@ 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, + return reduce(lambda x, y: x + y, [self._bytes(getattr(apdu, k)) for k in apdu.__dict__ if k in apdu_keys]) @property @@ -58,67 +59,68 @@ 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: + def apdus(self, new_apdus: 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: + if type(new_apdus) is not dict: raise ValueError("Attribute newApdus must be a dictionary containing CApdu instances as values") - ApduSet._apdus = newApdus + ApduSet._apdus = new_apdus - def apdu(self, name: str, - p1: Optional[BytesOrStr] = None, + 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"\ + 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 + 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 + '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: + def set_params(self, key: str, + p1: Optional[BytesOrStr] = None, + p2: Optional[BytesOrStr] = None, + data: Optional[BytesOrStr] = None, + le: Optional[BytesOrStr] = 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, + 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): + 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 + # 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") @@ -129,38 +131,4 @@ def set_params(self, key: str, 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 -# + self.apdus[key].le = bytes([le if le is not None else 0]) diff --git a/tests/helpers/deviceappbtc.py b/tests/helpers/deviceappproxy/deviceappbtc.py similarity index 85% rename from tests/helpers/deviceappbtc.py rename to tests/helpers/deviceappproxy/deviceappbtc.py index c74a7f25..f5ff59ad 100644 --- a/tests/helpers/deviceappbtc.py +++ b/tests/helpers/deviceappproxy/deviceappbtc.py @@ -1,6 +1,6 @@ from typing import Optional, List from .apduabstract import ApduSet, ApduDict, CApdu, BytesOrStr -from .deviceappproxy import DeviceAppProxy, dongle_connected, CommException +from .deviceappproxy import DeviceAppProxy class BTC_P1: @@ -23,18 +23,17 @@ class BTC_P2: P2SH_P2WPKH_ADDR = bytes.fromhex("01") BECH32_ADDR = bytes.fromhex("02") # UntrustedHashTxInputStart - STD_INPUTS_ = bytes.fromhex("00") + 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) + 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" @@ -50,13 +49,10 @@ class DeviceAppBtc(DeviceAppProxy): 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) - + self.btc = ApduSet(DeviceAppBtc.apdus, max_lc=DeviceAppBtc.default_chunk_size) + super().__init__(mnemonic=mnemonic, chunk_size=DeviceAppBtc.default_chunk_size) - def getTrustedInput(self, + def getTrustedInput(self, data: BytesOrStr, chunks_len: Optional[List[int]] = None) -> bytes: return self.sendApdu("getTrustedInput", "00", "00", data, chunks_lengths=chunks_len) diff --git a/tests/helpers/deviceappproxy.py b/tests/helpers/deviceappproxy/deviceappproxy.py similarity index 97% rename from tests/helpers/deviceappproxy.py rename to tests/helpers/deviceappproxy/deviceappproxy.py index 086a2b54..b5cc42c8 100644 --- a/tests/helpers/deviceappproxy.py +++ b/tests/helpers/deviceappproxy/deviceappproxy.py @@ -1,9 +1,9 @@ from typing import Optional, List +from ledgerblue.comm import getDongle from .apduabstract import BytesOrStr -from ledgerblue.comm import getDongle, CommException -#decorator that try to connect to a physical dongle before executing a method +# 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: From f8e6ca3326d75bf34d70c038ea9d5bf59d4aa64d Mon Sep 17 00:00:00 2001 From: hcleonis Date: Thu, 11 Jun 2020 18:30:10 +0200 Subject: [PATCH 03/12] Helpers: Add recognition of overwinter, pre-sapling Zcash tx to TxParse, Fix parser issues related to GetTrustedInput, reworked sendApdus() method, Update txparser, Helpers reworked to make segwit tests pass, More fixes in helpers for segwit inputs, Deactivate expected_signature as a test data (still WIP), Fix issue with input script not sent in some cases, Rework hash computation to fix wrong segwit tx hash + stuff, Adaptations for test data move to conftest.py + initial mods for test automation (unfinished) --- tests/helpers/__init__.py | 8 +- tests/helpers/basetest.py | 88 ++-- tests/helpers/deviceappproxy/__init__.py | 7 +- tests/helpers/deviceappproxy/apduabstract.py | 81 ++-- tests/helpers/deviceappproxy/deviceappbtc.py | 330 ++++++++++++-- .../helpers/deviceappproxy/deviceappproxy.py | 207 +++++---- tests/helpers/txparser/__init__.py | 4 +- tests/helpers/txparser/transaction.py | 404 +++++++++++------- tests/helpers/txparser/txtypes.py | 204 +++++++++ tests/test_btc_rawtx_zcash2.py | 2 - tests/test_btc_signature.py | 174 +++----- 11 files changed, 1053 insertions(+), 456 deletions(-) create mode 100644 tests/helpers/txparser/txtypes.py diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 4c7ea47c..a1f1ffb8 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,5 +1,9 @@ """ -Helper modules: +Helper package: - deviceappproxy: send & receive APDUs to a Ledger device - txparser: simplifies parsing of a raw unsigned BTC transaction -""" \ No newline at end of file +""" +from ledgerblue import comm, commException +from .deviceappproxy import apduabstract, deviceappproxy, deviceappbtc +from .txparser import transaction, txtypes +from . import basetest diff --git a/tests/helpers/basetest.py b/tests/helpers/basetest.py index d8d5aae4..c36b7026 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 typing import Optional, List, Any import hashlib import base58 +from .deviceappproxy.deviceappproxy import DeviceAppProxy +from ledgerblue.commException import CommException + class NID: MAINNET = bytes.fromhex("00") TESTNET = bytes.fromhex("6f") + class CONSENSUS_BRANCH_ID: OVERWINTER = bytes.fromhex("5ba81b19") SAPLING = bytes.fromhex("76b809bb") @@ -14,30 +18,15 @@ 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.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:] @@ -47,19 +36,19 @@ def __init__(self, apdu_response: bytes, network_id: NID = NID.TESTNET) -> None: 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. """ - def check_trusted_input(self, - trusted_input: bytes, + @staticmethod + def check_trusted_input(trusted_input: bytes, out_index: bytes, out_amount: bytes, out_hash: Optional[bytes] = None) -> None: @@ -76,9 +65,9 @@ def check_trusted_input(self, 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 @@ -100,15 +89,17 @@ def check_signature(self, # 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: + @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. @@ -121,15 +112,17 @@ def expected_len(exp_str: str) -> int: recv = received.hex() for i in range(len(expected)): if expected[i] != '-': - assert recv[i] == expected[i] + assert recv[i] == expected[i] - 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,26 @@ 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 == 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/deviceappproxy/__init__.py b/tests/helpers/deviceappproxy/__init__.py index 8604e94d..e33a5401 100644 --- a/tests/helpers/deviceappproxy/__init__.py +++ b/tests/helpers/deviceappproxy/__init__.py @@ -1,3 +1,6 @@ """ -Module that abstract communicating with a Ledger device through ISO-7816 -""" \ No newline at end of file +Helper package that abstract communicating with a Ledger device through ISO-7816 +""" +from .apduabstract import BytesOrStr, ApduDict, CApdu, ApduSet +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 index 1bf115a7..d51f18df 100644 --- a/tests/helpers/deviceappproxy/apduabstract.py +++ b/tests/helpers/deviceappproxy/apduabstract.py @@ -1,6 +1,5 @@ -from typing import List, Dict, Union, Optional, cast, NewType +from typing import List, Dict, Union, Optional, cast, NewType, Tuple from dataclasses import dataclass, field -from functools import reduce # Type aliases BytesOrStr = NewType("BytesOrStr", Union[bytes, str]) @@ -8,23 +7,21 @@ @dataclass class CApdu: - @dataclass class Type: IN: int = 0 OUT: int = 1 INOUT: int = 2 - + data: List[bytes] 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] +ApduDict = NewType("ApduDict", Dict[str, CApdu]) class ApduSet: @@ -47,13 +44,6 @@ def _bytes(data: BytesOrStr) -> bytes: 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 @@ -64,16 +54,23 @@ def apdus(self, new_apdus: ApduDict, overwrite: bool = False) -> None: unless overwrite is True.""" if not self.apdus or overwrite is True: if type(new_apdus) is not dict: - raise ValueError("Attribute newApdus must be a dictionary containing CApdu instances as values") + raise ValueError("Attribute newApdus must be a dictionary containing CApdu " + "instances as values") ApduSet._apdus = new_apdus 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. + data: Optional[List[BytesOrStr]] = None, + le: Optional[BytesOrStr] = None) -> Tuple[bytes, List[Optional[bytes]]]: + """Returns the raw bytes for the C-APDU header requested by name. """ + + def _bytesbuf(apdu: CApdu, apdu_keys: List[str]) -> bytes: + """Concatenates all @apdu attributes whose names are provided in @apdu_keys, + into a single byte buffer.""" + return b''.join(self._bytes(getattr(apdu, k)) if k in apdu.__dict__ else self._bytes(k) for k in apdu_keys) + if not self.apdus: raise ValueError("ApduSet object is empty! Provide an ApduDict either at instanciation" " or with the 'apdus' attribute.") @@ -81,54 +78,56 @@ def apdu(self, name: str, 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 '' - ] + # Determine APDU type + apdu_is_in_only_or_inout: bool = self._apdus[name].typ == CApdu.Type.IN \ + or self._apdus[name].typ == CApdu.Type.INOUT + apdu_is_out_only: bool = self._apdus[name].typ == CApdu.Type.OUT + # Return the C-APDU header with correct Lc + return ( + _bytesbuf( + self.apdus[name], + ['cla', 'ins', 'p1', 'p2', 'lc' if apdu_is_in_only_or_inout else 'le' if apdu_is_out_only else '00'] + ), + self.apdus[name].data ) 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") + raise ValueError(f"Syntax '{self.__class__.__name__}[{key}] = value' " + f"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, + data: Optional[List[BytesOrStr]] = None, le: Optional[BytesOrStr] = 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)]) + params_valid: bool = all(True if type(param) in (str, bytes, list) 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") + raise ValueError("Parameters must either be single byte (e.g. p1 or p2), multiple bytes" + " (e.g. 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): + if (p1 and len(self._bytes(p1)) > 1) \ + or (p2 and len(self._bytes(p2)) > 1) \ + or (le 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) + # Concatenate payload chunks to compute Lc + data_len: int = len(b''.join(data)) + self.apdus[key].data = [self._bytes(d) for d in data if d is not None] 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]) + self.apdus[key].lc = data_len.to_bytes(1, 'big') if data_len < self.max_lc else b'\x00' elif self.apdus[key].typ == CApdu.Type.OUT: - self.apdus[key].le = bytes([le if le is not None else 0]) + self.apdus[key].le = self._bytes(le) if le is not None else b'\x00' diff --git a/tests/helpers/deviceappproxy/deviceappbtc.py b/tests/helpers/deviceappproxy/deviceappbtc.py index f5ff59ad..3384523a 100644 --- a/tests/helpers/deviceappproxy/deviceappbtc.py +++ b/tests/helpers/deviceappproxy/deviceappbtc.py @@ -1,6 +1,9 @@ -from typing import Optional, List +from typing import Optional, List, cast, Union from .apduabstract import ApduSet, ApduDict, CApdu, BytesOrStr 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, lbstr, TxInput class BTC_P1: @@ -26,55 +29,308 @@ class BTC_P2: 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) + 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"\ + 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), + "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 } - def __init__(self, + def __init__(self, mnemonic: str = default_mnemonic) -> None: self.btc = ApduSet(DeviceAppBtc.apdus, max_lc=DeviceAppBtc.default_chunk_size) + self._tx_endianness: str = 'little' 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) + @staticmethod + def _get_input_index(tx: Tx, _input: bytes, endianness: lbstr = '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[bytes] = [ + 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="00", p2="00", data=payload_chunks)) + + def get_wallet_public_key(self, + data: BytesOrStr) -> bytes: + return self.send_apdu(*self.btc.apdu("GetWalletPublicKey", p1="00", p2="00", data=[data])) + + 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: lbstr = 'little') -> bytes: + """Hash the inputs of the tx data""" + def _get_p2() -> BytesOrStr: + if mode.is_hash_with_script: + return "80" + elif mode.is_segwit_input_hash: + return "02" + elif mode.is_bcash_input_hash: + return "03" + elif mode.is_zcash_input_hash: + return "04" + elif mode.is_sapling_input_hash: + return "05" + 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 + if mode.is_segwit_zcash_or_sapling_input_hash: + if mode.is_hash_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 + ]) + else: # BTC or BCash tx + if script is not None: + 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: + payload_chunks.extend( + [ # [input||script_len (00), 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="00", p2=p2, data=payload_chunks)) + + def untrusted_hash_tx_input_finalize(self, + p1: BytesOrStr, + data: Union[BytesOrStr, Tx]) -> bytes: + """ + Submit either tx outputs or change path to hashing, depending on value of p1 argument + """ + param1: bytes = bytes.fromhex(p1) if type(p1) is 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 index b5cc42c8..9f3ef1e4 100644 --- a/tests/helpers/deviceappproxy/deviceappproxy.py +++ b/tests/helpers/deviceappproxy/deviceappproxy.py @@ -1,13 +1,16 @@ -from typing import Optional, List +import subprocess +import time +from typing import Optional, Union, List, cast from ledgerblue.comm import getDongle -from .apduabstract import BytesOrStr +from ledgerblue.commTCP import DongleServer +from .apduabstract import BytesOrStr, 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 = getDongle(False) + self.dongle: DongleServer = cast(DongleServer, getDongle(False)) ret = func(self, *args, **kwargs) self.close() return ret @@ -16,103 +19,139 @@ def wrapper(self, *args, **kwargs): class DeviceAppProxy: - def __init__(self, - mnemonic: str = "", - debug: bool = True, - delay_connect: bool = True, - chunk_size: int = 200 + 11) -> None: + 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 sendApdu(self, - name: str, - p1: BytesOrStr, - p2: BytesOrStr, - data: Optional[BytesOrStr] = None, - le: Optional[BytesOrStr] = None, - chunks_lengths: Optional[List[int]] = None) -> bytes: + def send_apdu(self, + apdu: Union[CApdu, bytes], + data: Optional[List[BytesOrStr]] = None, + p1_msb_means_next: bool = True) -> BytesOrStr: + """Send APDUs to a Ledger device.""" + def _bytes(str_bytes: BytesOrStr) -> bytes: + ret = str_bytes if type(str_bytes) is bytes \ + else bytes([cast(int, str_bytes)]) if type(str_bytes) is int \ + else bytes.fromhex(str_bytes) if type(str_bytes) is 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) 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 - 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}") + hdr: bytearray = bytearray(apdu[0:4]) + response: BytesOrStr = 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) else "OK" + print(f"[device >] {_resp}") + _set_p1(hdr, _chunk, payload_chunks, p1_msb_means_next) else: - # Auto splitting of large APDUs into chunks of equal length until payload is exhausted - response = self._send_chunked_apdu(apdu=hdr, data=payload) + # 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 sendRawApdu(self, - apdu: bytes) -> bytes: + def send_raw_apdu(self, + apdu: bytes) -> BytesOrStr: print(f"[device <] {apdu.hex()}") - response = self.dongle.exchange(apdu) - _resp = response.hex() if len(response) else "OK" + response: BytesOrStr = self.dongle.exchange(apdu) + _resp: Union[bytes, str] = 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() + 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 index fafbbcdd..1245f92d 100644 --- a/tests/helpers/txparser/__init__.py +++ b/tests/helpers/txparser/__init__.py @@ -1,3 +1,5 @@ """ -Helper module to parse raw unsigned Bitcoin or Zcash transactions +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 index fd0b833d..c44c9ed0 100644 --- a/tests/helpers/txparser/transaction.py +++ b/tests/helpers/txparser/transaction.py @@ -1,106 +1,86 @@ -from io import BytesIO -from typing import Optional, List, NewType, Union, Literal, cast - -try: - from typing import TypedDict # >=3.8 -except ImportError: - from mypy_extensions import TypedDict # <=3.7 - -# Types of the transaction fields, used to check fields lengths -u8 = NewType("u8", int) # 1 byte -u16 = NewType("u16", int) # 2 bytes -u32 = NewType("u32", int) # 4 bytes -u64 = NewType("u64", int) # 8 bytes -i64 = NewType("i64", int) # 8 bytes -varint = NewType("varint", int) # 1-9 bytes -bytes32 = NewType("bytes32", type(bytes(32))) # 32 bytes -bytes64 = NewType("bytes32", type(bytes(64))) # 64 bytes -txtype = NewType("txtype", int) - - -# Types for the supported kinds of transactions. Extend as needed. -class TxType: - btc: txtype = 0 - segwit: txtype = 1 - zcash: txtype = 2 - - -# Dictionaries holding special values introduced by BTC protocol evolution or -# BTC-derivative currencies -class SpecialFields(TypedDict): - """ - Base dictionaries holding the special 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 +from .txtypes import * +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 -class SpecialSegwit(SpecialFields): - """ - Stores the Marker & Flag bytes of a segwit-enabled Bitcoin transaction - """ +@dataclass +class SegwitExtHeader(TxExtension): + """Stores the Marker & Flag bytes of a segwit-enabled Bitcoin transaction""" marker: u8 flag: u8 -class SpecialZcashHeader(SpecialFields): - """ - Stores the transaction fields secific to Zcash added to the header of the BTC raw tx - """ - overwintered_flag: bool - version_group_id: u32 +@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 + witness_len: varint + witness: List[WitnessData] -class SpecialZcashFooter(SpecialFields): - """ - Stores the transaction fields secific to Zcash appended to the end of the raw BTC tx - """ - expiry_height: u32 - # All remaining Zcash-specific fields (value_balance, shielded_spend, shielded_output, - # join_split, binding_sig) are hashed as a single bloc by the BTC app so we don't need to - # to differentiate each field - extra_data: bytes - - -# BTC transaction description dictionaries -class TxHeader(TypedDict): - """ - Raw transaction header fields - """ - version: u32 - special: SpecialFields +@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 -class TxFooter(SpecialFields): - special: SpecialFields +@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 -class TxInput(TypedDict): - prev_tx_hash: bytes - prev_tx_out_index: u32 - script_len: varint +# BTC transaction description dictionaries +@dataclass +class TxInput: + prev_tx_hash: bytes32 + prev_tx_out_index: TxInt4 + script_len: TxVarInt script: bytes - sequence_no: u32 + sequence_nb: TxInt4 -class TxOutput(TypedDict): - value: u64 - script_len: varint +@dataclass +class TxOutput: + value: TxInt8 + script_len: TxVarInt script: bytes -class Tx(TypedDict): +@dataclass +class Tx: """ Helper class that eases the parsing of a raw unsigned Bitcoin, Bitcoin segwit or Zcash transaction. """ type: txtype - header: TxHeader - input_count: varint + hash: Optional[str] + version: TxInt4 + header: Optional[TxHeader] + input_count: TxVarInt inputs: List[TxInput] - output_count: varint + output_count: TxVarInt outputs: List[TxOutput] - lock_time: u32 + lock_time: TxInt4 footer: Optional[TxFooter] @@ -117,7 +97,7 @@ class TxParse: @classmethod def from_raw(cls, raw_tx: Union[bytes, str], - endianness: Literal['big', 'little'] = 'little') -> Tx: + endianness: lbstr = 'little') -> Tx: """ Returns a TX object with members initialized from the parsing of the rawTx parameter @@ -132,29 +112,72 @@ def from_raw(cls, """ # 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 is not None and type(obj) is not bytes: + # Each items in a list of objects must be parsed entirely + if type(obj) is 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 + if key not in ignored_fields and value is not None and \ + type(value) not in (SegwitExtHeader, SegwitExtFooter): + tmp = path[:] + tmp.append(key) + _recursive_hash_obj(getattr(obj, key), hasher, ignored_fields, tmp, show_path) + else: + # 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)) + + h1, h2 = (sha256(), sha256()) + if type(tx) is bytes: + # 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: Literal['big', 'little'] = 'little') -> varint: + bytes_order: lbstr = 'little') -> TxVarInt: """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 cast(varint, int.from_bytes(b, bytes_order)) + 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 ValueError(f"Cant read {size} bytes in buffer!") + raise IOError(f"Cant read {size} bytes in buffer!") return b def _read_uint(buf: BytesIO, bytes_len: int, - bytes_order: Literal['big', 'little'] = 'little') -> int: + bytes_order: lbstr = '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: @@ -163,46 +186,64 @@ def _read_uint(buf: BytesIO, def _read_u8(buf: BytesIO) -> u8: """Returns the next byte in 'buf'.""" - return cast(u8, _read_bytes(buf, 1)) + return cast(u8, _read_uint(buf, 1)) - def _read_u16(buf: BytesIO, bytes_order: Literal['big', 'little'] = 'little') -> u16: + def _read_u16(buf: BytesIO, bytes_order: lbstr = '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: Literal['big', 'little'] = 'little') -> u32: + def _read_u32(buf: BytesIO, bytes_order: lbstr = '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_u64(buf: BytesIO, bytes_order: Literal['big', 'little'] = 'little') -> u64: - """Returns the integer value encoded in the next 8 bytes of 'buf'. - """ - return cast(u64, _read_uint(buf, 8, bytes_order)) + def _read_tx_int(buf: BytesIO, count: int, bytes_order: lbstr) -> (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) -> List[TxInput]: + def _parse_inputs(buf: BytesIO, + in_count: int, + bytes_order: lbstr = '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: bytes = _read_bytes(buf, 32) - prev_tx_out_index: u32 = _read_u32(buf) - in_script_len: varint = _read_varint(buf) - in_script: bytes = _read_bytes(buf, in_script_len) - sequence_no: u32 = _read_u32(buf) + 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_no=sequence_no)) + sequence_nb=sequence_nb)) return _inputs - def _parse_outputs(buf: BytesIO, out_count: int) -> List[TxOutput]: + def _parse_outputs(buf: BytesIO, + out_count: int, + bytes_order: lbstr = 'little') -> List[TxOutput]: """Returns a list of TxOutputs containing the raw tx's output fields.""" _outputs: List[TxOutput] = [] for _ in range(out_count): - value: u64 = _read_u64(buf) - out_script_len: varint = _read_varint(buf) - out_script: bytes = _read_bytes(buf, out_script_len) + 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, @@ -210,25 +251,80 @@ def _parse_outputs(buf: BytesIO, out_count: int) -> List[TxOutput]: script=out_script)) return _outputs + def _parse_zcash_footer(buf: BytesIO, bytes_order: lbstr = '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: + ival, bval = _read_tx_int(buf, 4, bytes_order) + expiry_height = TxInt4(val=cast(u32, ival), buf=cast(bytes4, bval)) + if version.val >= 4: + ival, bval = _read_tx_int(buf, 8, bytes_order) + value_balance = TxInt8(val=cast(u64, ival), buf=cast(bytes8, bval)) + 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 + typ: txtype = TxType.Btc stream_pos: int = buf.tell() - buf.seek(0) + 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 == b"\x00" and byte1 == b'\x01': - # Either segwit tx or legacy coinbase tx =>if coinbase, byte1 is the output count (1 byte) - buf.seek(8) - buf.seek(_read_u8(buf) + 4) # If a coinbase tx, stream pointer is at the end of the stream now - if buf.read(1): - typ = TxType.segwit - elif byte0 == b'\0x85' and byte1 == b'\x20': # 1st two bytes of zcash special bytes + + 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 == b'\x2f89': - typ = TxType.zcash + 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 @@ -236,44 +332,59 @@ def _tx_type(buf: BytesIO) -> txtype: # # Transaction parsing code starts here # - io_buf: BytesIO = BytesIO(bytes.fromhex(raw_tx)) if type(raw_tx) == str else BytesIO(raw_tx) - version: u32 = _read_u32(io_buf, endianness) + raw_tx_bytes: bytes = bytes.fromhex(raw_tx) if type(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[u32] = None + version_group_id: Optional[TxInt4] = None overwintered_flag: bool = False - expiry_height: Optional[u32] = None - extra_data: Optional[bytes] = None - if tx_type == TxType.segwit: + if tx_type == TxType.Segwit: marker = _read_u8(io_buf) flag = _read_u8(io_buf) - elif tx_type == TxType.zcash: - version_group_id = _read_u32(io_buf, endianness) - overwintered_flag = True if version_group_id & 0x80000000 else False - - input_count: varint = _read_varint(io_buf) - inputs: List[TxInput] = _parse_inputs(io_buf, input_count) - output_count: varint = _read_varint(io_buf) - outputs: List[TxOutput] = _parse_outputs(io_buf, output_count) - lock_time: u32 = _read_u32(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 = True if ivers & 0x80000000 else False + + 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) + ) - if tx_type == TxType.zcash: - expiry_height: Optional[u32] = _read_u32(io_buf) - extra_data: Optional[bytes] = _read_bytes(io_buf, -1) # read up to EOF + zcash_footer: Optional[ZcashExtFooter] = None + if tx_type in (TxType.Zcash, TxType.ZcashSapling): + zcash_footer: ZcashExtFooter = _parse_zcash_footer(io_buf, endianness) - return Tx( + parsed_tx = Tx( type=tx_type, + hash=None, # Will be set just before returning + version=version, header=TxHeader( - version=version, - special=SpecialSegwit( + ext=SegwitExtHeader( marker=marker, - flag=flag) if tx_type == TxType.segwit - else SpecialZcashHeader( + flag=flag) if tx_type == TxType.Segwit + else ZcashExtHeader( overwintered_flag=overwintered_flag, - version_group_id=version_group_id) if tx_type == TxType.zcash + version_group_id=version_group_id) if tx_type in (TxType.Zcash, TxType.ZcashSapling) else None ), input_count=input_count, @@ -282,9 +393,8 @@ def _tx_type(buf: BytesIO) -> txtype: outputs=outputs, lock_time=lock_time, footer=TxFooter( - special=SpecialZcashFooter( - expiry_height=expiry_height, - extra_data=extra_data) if tx_type == TxType.zcash - else None + 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..95202e4a --- /dev/null +++ b/tests/helpers/txparser/txtypes.py @@ -0,0 +1,204 @@ +from io import BytesIO +from sys import version_info +from typing import NewType, Optional, cast +from dataclasses import dataclass +assert version_info.major >= 3, "Python 3 required!" +if version_info.minor >= 8: + from typing import Literal +elif version_info.minor <= 6: # TypedDict & Literal not yet standard in 3.6 + 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", type(bytes(1))) # 1 byte +bytes2 = NewType("bytes2", type(bytes(2))) # 2 bytes +bytes4 = NewType("bytes4", type(bytes(4))) # 4 bytes +bytes8 = NewType("bytes8", type(bytes(8))) # 8 bytes +bytes16 = NewType("bytes16", type(bytes(16))) # 16 bytes +bytes32 = NewType("bytes32", type(bytes(32))) # 32 bytes +bytes64 = NewType("bytes64", type(bytes(64))) # 64 bytes +txtype = NewType("txtype", int) +lbstr = NewType("lbstr", 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(self._hash_mode) + + @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) + elif int_value <= 0xffff: + bval = int_value.to_bytes(2, endianness) + return b'\xfd' + bval if endianness == 'big' else bval + b'\xfd' + elif 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: lbstr = '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/test_btc_rawtx_zcash2.py b/tests/test_btc_rawtx_zcash2.py index f26e6725..cff96bed 100644 --- a/tests/test_btc_rawtx_zcash2.py +++ b/tests/test_btc_rawtx_zcash2.py @@ -171,7 +171,6 @@ def _send_raw_apdus(self, apdus: List[LedgerjsApdu], device: DeviceAppBtc): continue raise error - @pytest.mark.zcash @pytest.mark.manual def test_replay_zcash_with_trusted_inputs(self) -> None: @@ -367,7 +366,6 @@ def test_replay_zcash_with_trusted_inputs(self) -> None: # 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: diff --git a/tests/test_btc_signature.py b/tests/test_btc_signature.py index bea806cd..039c6a1d 100644 --- a/tests/test_btc_signature.py +++ b/tests/test_btc_signature.py @@ -35,7 +35,8 @@ from typing import Optional, List from functools import reduce from helpers.basetest import BaseTestBtc, BtcPublicKey, TxData -from helpers.deviceappbtc import DeviceAppBtc +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from helpers.txparser.transaction import Tx, TxType, TxParse # BTC Testnet segwit tx used as a "prevout" tx. # txid: 2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753 @@ -178,43 +179,29 @@ def test_submit_trusted_segwit_input_btc_transaction(self, test_data: TxData) -> expected_der_sig = test_data.expected_sig btc = DeviceAppBtc() - + tx = TxParse.from_raw(raw_tx=tx_to_sign) + parsed_utxos = [TxParse.from_raw(raw_tx=utxo) for utxo in utxos] # 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 - ], - ] + + output_indexes = [_input.prev_tx_out_index for _input in tx.inputs] trusted_inputs = [ btc.getTrustedInput( - data=input_datum, - chunks_len=chunks_len + prev_out_index=out_idx.val, + parsed_tx=parsed_utxo, + raw_tx=utxo ) - for (input_datum, chunks_len) in zip(input_data, utxos_chunks_len) - ] + for (out_idx, parsed_utxo, utxo) in zip(output_indexes, parsed_utxos, utxos)] print(" OK") - 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 - ): + 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 tx.inputs] + 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_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 ) @@ -232,59 +219,27 @@ def test_submit_trusted_segwit_input_btc_transaction(self, test_data: TxData) -> # 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( + btc.untrustedTxInputHashStart2( p1="00", - p2="02", # /!\ "02" to activate BIP 143 signature (b/c the pseudo-tx + 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 - ) + parsed_tx=tx, + trusted_inputs=trusted_inputs) 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( + btc.untrustedTxInputHashFinalize2( p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + tx_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 - ) + tx_data=tx) assert response == bytes.fromhex("0000") print(" OK") # We're done w/ the hashing of the pseudo-tx with all inputs w/o scriptSig. @@ -293,52 +248,63 @@ def test_submit_trusted_segwit_input_btc_transaction(self, test_data: TxData) -> # 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 - ] - + # # # 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 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.untrustedTxInputHashStart2( + # 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 + # ) + for trusted_input, output_path in zip(trusted_inputs, output_paths): # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart( + btc.untrustedTxInputHashStart2( 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 - ) + parsed_tx=tx, + trusted_inputs=trusted_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) 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 - ) + # tx_to_sign_data = output_path \ + # + bytes.fromhex("00") \ + # + tx_to_sign[-4:] \ + # + bytes.fromhex("01") + + # response = btc.untrustedHashSign( + # data = tx_to_sign_data + # ) + response = btc.untrustedHashSign2( + output_path= output_path, + parsed_tx=tx + ) self.check_signature(response) #self.check_signature(response, expected_der_sig) print(" Signature OK\n") From f133b4d7726dc933f872431b99a1c3a1386da06f Mon Sep 17 00:00:00 2001 From: hcleonis Date: Mon, 15 Jun 2020 21:06:43 +0200 Subject: [PATCH 04/12] Update GetTrustedInput test to use new BTC tx parser --- tests/test_btc_get_trusted_input.py | 76 +++++++++++------------------ 1 file changed, 28 insertions(+), 48 deletions(-) diff --git a/tests/test_btc_get_trusted_input.py b/tests/test_btc_get_trusted_input.py index 95d7f986..ad85ed5c 100644 --- a/tests/test_btc_get_trusted_input.py +++ b/tests/test_btc_get_trusted_input.py @@ -35,17 +35,14 @@ from dataclasses import dataclass, field from typing import Optional, List from helpers.basetest import BaseTestBtc -from helpers.deviceappbtc import DeviceAppBtc, BTC_P1, BTC_P2 +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from helpers.txparser.transaction import Tx, TxParse @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 @@ -103,18 +100,6 @@ class TrustedInputTestData: # 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")] ) @@ -155,24 +140,19 @@ class TrustedInputTestData: "0014e4d3a1ec51102902f6bbede1318047880c9c7680" # Witnesses (1 for each input if Flag=0001) # /!\ remove witnesses for `GetTrustedInput` - "0247" - "30440220495838c36533616d8cbd6474842459596f4f312dce5483fe650791c8" - "2e17221c02200660520a2584144915efa8519a72819091e5ed78c52689b24235" - "182f17d96302012102ddf4af49ff0eae1d507cc50c86f903cd6aa0395f323975" - "9c440ea67556a3b91b" - "0247" - "304402200090c2507517abc7a9cb32452aabc8d1c8a0aee75ce63618ccd90154" - "2415f2db02205bb1d22cb6e8173e91dc82780481ea55867b8e753c35424da664" - "f1d2662ecb1301210254c54648226a45dd2ad79f736ebf7d5f0fc03b6f8f0e6d" - "4a61df4e531aaca431" + # "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")] ) @@ -220,17 +200,17 @@ class TrustedInputTestData: # 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: @@ -238,24 +218,24 @@ 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.getTrustedInput2( + prev_out_index=idx, + parsed_tx=tx, + raw_tx=testdata.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 ) - From de25e4d141ef136aca1538a829e97f6d4a551711 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Tue, 16 Jun 2020 11:41:50 +0200 Subject: [PATCH 05/12] Test: remove old methods calls --- tests/test_btc_get_trusted_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_btc_get_trusted_input.py b/tests/test_btc_get_trusted_input.py index ad85ed5c..fe0fe4d9 100644 --- a/tests/test_btc_get_trusted_input.py +++ b/tests/test_btc_get_trusted_input.py @@ -225,7 +225,7 @@ def test_get_trusted_input(self, testdata: TrustedInputTestData) -> None: else [testdata.prevout_idx] trusted_inputs = [ - btc.getTrustedInput2( + btc.getTrustedInput( prev_out_index=idx, parsed_tx=tx, raw_tx=testdata.tx) From f5e8e376e219ab72d693dd5cef24a9eb05ad6d06 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Fri, 19 Jun 2020 20:18:32 +0200 Subject: [PATCH 06/12] Tests: method names refactoring in tests, BTC test updated to remove offsets, Removed some more indexes + fixed issues in segwit inputs script handling, Fixed Zcash Overwinter tests + adapted other tests to TxHashMode being instanciable, Finish Segwit / Zcash tests adaptations to new tx parser, Moved test data from test scripts to new conftest.py & use fixtures to access them --- .gitignore | 2 +- tests/conftest.py | 671 ++++++++++++++++++++++++++++ tests/test_btc_get_trusted_input.py | 214 +-------- tests/test_btc_rawtx_ljs.py | 194 +------- tests/test_btc_rawtx_zcash.py | 545 ++++------------------ tests/test_btc_rawtx_zcash2.py | 554 ++++------------------- tests/test_btc_segwit_tx_ljs.py | 574 ++++-------------------- tests/test_btc_signature.py | 486 ++++---------------- 8 files changed, 1025 insertions(+), 2215 deletions(-) create mode 100644 tests/conftest.py 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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..c85de09f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,671 @@ +import pytest +from dataclasses import dataclass, field +from typing import List, Optional + + +@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" + "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab""00000000""6b" + "483045022100ca145f0694ffaedd333d3724ce3f4e44aabc0ed5128113660d11" + "f917b3c5205302207bec7c66328bace92bd525f385a9aa1261b83e0f92310ea1" + "850488b40bd25a5d0121032006c64cdd0485e068c1e22ba0fa267ca02ca0c2b3" + "4cdc6dd08cba23796b6ee7""fdffffff" + "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab""01000000""6a" + "47304402202a5d54a1635a7a0ae22cef76d8144ca2a1c3c035c87e7cd0280ab4" + "3d3451090602200c7e07e384b3620ccd2f97b5c08f5893357c653edc2b8570f0" + "99d9ff34a0285c012102d82f3fa29d38297db8e1879010c27f27533439c868b1" + "cc6af27dd3d33b243dec""fdffffff" + "01" + "d7ee7c0100000000""19""76a9140ea263ff8b0da6e8d187de76f6a362beadab781188ac" + "e3691900" + ), + prevout_idx=0, + prevout_amount=[bytes.fromhex("d7ee7c0100000000")] + ) + + segwit_tx = TrustedInputTestData( + tx=bytes.fromhex( + "02000000""0001" + "02" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""00000000""00""fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""01000000""00""fdffffff" + "01" + "01410f0000000000""16""0014e4d3a1ec51102902f6bbede1318047880c9c7680" + "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( + "02000000""0001" + "01" + "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db1""00000000""17" + "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846a""feffffff" + "02" + "9b3242bf01000000""17""a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" + "1027000000000000""17""a9141e852ac84f8385d76441c584e41f445aaf1624ea87" + "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="410486b865b52b753d0a84d09bc20063fab5d8453ec33c215d4019a5801c9c6438b917770b2782e29a9ecc6edb67cd1f0fbf05ec4c1236884b6d686d6be3b1588abb2231334b453654666641724c683466564d36756f517a7673597135767765744a63564dbce80dd580792cd18af542790e56aa813178dc28644bb5f03dbd44c85f2d2e7a" + ) + ] + + ljs_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 + ) + ] + + 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", + "e042800032483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336dfa248aea9ccf022023b13e57595635452130", + "e0428000321c91ed0fe7072d295aa232215e74e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f42d458da", + "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", + "e04480053b013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d9491000000000045e1e144cb88d4d800", + "e044800504ffffff00", + ] + ), + LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL + commands=[ + "e04aff0015058000002c80000085800000000000000100000003", + # "e04a0000320240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" + "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""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 + ) + ] + + 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 + "04000080""85202f89" + "01" + "53685b8809efc50dd7d5cb0906b307a1b8aa5157baa5fc1bd6fe2d0344dd193a""00000000""6b" + "483045022100ca0be9f37a4975432a52bb65b25e483f6f93d577955290bb7fb0" + "060a93bfc92002203e0627dff004d3c72a957dc9f8e4e0e696e69d125e4d8e27" + "5d119001924d3b48012103b243171fae5516d1dc15f9178cfcc5fdc67b0a8830" + "55c117b01ba8af29b953f6" + "ffffffff" + "01" + "4072070000000000""19""76a91449964a736f3713d64283fd0018626ba50091c7e988ac" + "00000000" + "00000000""0000000000000000""00""00""00" + ) + + +@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 + "04000080""85202f89" + "01" + "edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857""01000000""6b" + "483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336d" + "fa248aea9ccf022023b13e575956354521301c91ed0fe7072d295aa232215e74" + "e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f" + "42d458da1100831dc4ff72" + "ffffff00" + "02" + "a086010000000000""19""76a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac" + "4d94910000000000""19""76a914b714c60805804d86eb72a38c65ba8370582d09e888ac" + "00000000" + "00000000""0000000000000000""00""00""00" + ) + ] + + test_tx_to_sign = bytes.fromhex( + # Zcash Sapling + "04000080""85202f89" + "01" + "d35f0793da27a5eacfe984c73b1907af4b50f3aa3794ba1bb555b9233addf33f""01000000""00" + "ffffff00" + "02" + "40420f0000000000""19""76a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + "2b51820000000000""19""76a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + "5eb3f840" + "00000000""0000000000000000""00""00""00" + ) + + test_change_path = bytes.fromhex("058000002c80000085800000000000000100000003") # 44'/133'/0'/1/3 + test_output_paths = [ + bytes.fromhex("058000002c80000085800000000000000100000001"), # 44'/133'/0'/1/1 + bytes.fromhex("058000002c80000085800000000000000000000004") # 44'/133'/0'/0/4 + ] + + 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( + "03000080""7082c403" + "03" + "f6959fbdd8cc614211e4db1ca287a766441dcda8d786f70d956ad19de03373a4""01000000""69" + "46304302203dc5102d80e08cb8dee8e83894026a234d84ddd92da1605405a677" + "ead9fcb21a021f40bedfa4b5611fc00a6d43aedb6ea0769175c2eb4ce4f68963" + "c3a6103228080121028aceaa654c031435beb9bcf80d656a7519a6732f3da3c8" + "14559396131ea3532e""ffffff00" + "5ae818ee42a08d5c335d850cacb4b5996e5d2bc1cd5f0c5b46733652771c23b9""01000000""6b" + "483045022100df24e46115778a766068f1b744a7ffd2b0ae4e09b34259eecb2f" + "5871f5e3ff7802207c83c3c13c8113f904da3ea4d4ceedb0db4e8518fb43e9fb" + "8aeda64d1a69c76b012103e604d3cbc5c8aa4f9c53f84157be926d443054ba93" + "b60fbddf0aea053173f595""ffffff00" + "6065c6c49cd132fc148f947b5aa5fd2a4e0ae4b5a884ccb3247b5ccbfa3ecc58""01000000""6a" + "473044022064d92d88b8223f9e502214b2abf8eb72b91ad7ed69ae9597cb510a" + "3c94c7a2b00220327b4b852c2a81ad918bb341e7cd1c7e15903fc3e298663d75" + "675c4ab180be890121037dbc2659579d22c284a3ea2e3b5d0881f678583e2b4a" + "8b19dbd50f384d4b2535""ffffff00" + "02" + "002d310100000000""19""76a914772b6723ec72c99f6a37009407006fe1c790733988ac" + "13b6240000000000""19""76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac" + "00000000" + "00000000""00" + ) + ] + + test_tx_to_sign = bytes.fromhex( + # Zcash Sapling + "04000080""85202f89" + "01" + "605d4c86ca4511e962dbd968ab6805deeff0f076f6a8c6069dadefb0378c7244""01000000""19" + "76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac""ffffff00" + "02" + "c05c150000000000""19""76a914130715c4e654cff3fced8a9d6876310083d44f2e88ac" + "e9540f0000000000""19""76a91478dff3b7ed9dac8e9177c587375937f9d057769588ac" + "00000000" + "00000000""0000000000000000""00""00""00" + ) + + 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" + "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db1""00000000""17" + "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846a""feffffff" + "02" + "9b3242bf01000000""17""a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" + "1027000000000000""17""a9141e852ac84f8385d76441c584e41f445aaf1624ea87" + "0247" + "304402206e54747dabff52f5c88230a3036125323e21c6c950719f671332cdd0" + "305620a302204a2f2a6474f155a316505e2224eeab6391d5e6daf22acd76728b" + "f74bc0b48e1a0121033c88f6ef44902190f859e4a6df23ecff4d86a2114bd9cf" + "56e4d9b65c68b8121d" + "1f7f1900" + ), + bytes.fromhex( + "01000000" + "0001" + "02" + "7ab1cb19a44db08984031508ec97de727b32a8176cc00fce727065e86984c8df""00000000""17" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320""ffffff00" + "78958127caf18fc38733b7bc061d10bca72831b48be1d6ac91e296b888003327""00000000""17" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320""ffffff00" + "02" + "1027000000000000""17""a91493520844497c54e709756c819afecfffaf28761187" + "c84b1a0000000000""17""a9148f1f7cf3c847e4057be46990c4a00be4271f3cfa87" + "0247" + "3044022009116da9433c3efad4eaf5206a780115d6e4b2974152bdceba220c45" + "70e527a802202b06ca9eb93df1c9fc5b0e14dc1f6698adc8cbc15d3ec4d364b7" + "bef002c493d701210293137bc1a9b7993a1d2a462188efc45d965d135f53746b" + "6b146a3cec9905322602473044022034eceb661d9e5f777468089b262f6b25e1" + "41218f0ec9e435a98368d3f347944d02206041228b4e43a1e1fbd70ca15d3308" + "af730eedae9ec053afec97bd977be7685b01210293137bc1a9b7993a1d2a4621" + "88efc45d965d135f53746b6b146a3cec99053226" + "00000000" + ) + ] + + test_tx_to_sign = bytes.fromhex( + "01000000" + "0001" + # Inputs + "02" + "027a726f8aa4e81a45241099a9820e6cb7d8920a686701ad98000721101fa0aa""01000000""17" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320""ffffff00" + "f0b7b7ad837b4d3535bea79a2fa355262df910873b7a51afa1e4279c6b6f6e6f""00000000""17" + "160014eee02beeb4a8f15bbe4926130c086bd47afe8dbc""ffffff00" + # Outputs + "02" + "1027000000000000""17""a9142406cd1d50d3be6e67c8b72f3e430a1645b0d74287" + "0e26000000000000""17""a9143ae394774f1348be3a6bc2a55b67e3566d13408987" + # 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("05""80000031""80000001""80000000""00000000""000001f6"), # 49'/1'/0'/0/502 + bytes.fromhex("05""80000031""80000001""80000000""00000000""000001f7") # 49'/1'/0'/0/503 + ] + test_change_path = bytes.fromhex("05""80000031""80000001""80000000""00000001""00000045") # 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" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""00000000""00""fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""01000000""00""fdffffff" + "01" + "01410f0000000000""16""0014e4d3a1ec51102902f6bbede1318047880c9c7680" + "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" + "2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753""00000000""19""76a914e4d3a1ec" + "51102902f6bbede1318047880c9c768088ac""fdffffff" + "02" + "1027000000000000""16""0014161d283ebbe0e6bc3d90f4c456f75221e1b3ca0f" + "64190f0000000000""16""00144c5133c242683d33c61c4964611d82dcfe0d7a9a" + "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" + # "0220""2cadfbd881f592ea82e69038c7ada8f1ae50919e3be92ad1cd5fcc0bd142b2f5" + # "0220""646a699b5532fcdf38b196157e216c8808ae7bde5e786b8f3cbf2502d0f14c13" + # "01"), + # ] + + test_output_paths = [bytes.fromhex("05""80000054""80000001""80000000""00000000""00000000"), ] # 84'/1'/0'/0/0 + test_change_path = bytes.fromhex("05""80000054""80000001""80000000""00000001""00000001") # 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/test_btc_get_trusted_input.py b/tests/test_btc_get_trusted_input.py index fe0fe4d9..ad0ba4be 100644 --- a/tests/test_btc_get_trusted_input.py +++ b/tests/test_btc_get_trusted_input.py @@ -1,208 +1,16 @@ -# -# Note on 'chunks_len' values used in tests: -# ----------------------------------------- +# 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.deviceappproxy.deviceappbtc import DeviceAppBtc from helpers.txparser.transaction import Tx, TxParse - - -@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 definition - -# 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" - ), - 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" - ), - 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" - ), - num_outputs=2, - prevout_amount=[bytes.fromhex(amount) for amount in ("9b3242bf01000000", "1027000000000000")] -) +from conftest import btc_gti_test_data, TrustedInputTestData @pytest.mark.btc @@ -210,9 +18,10 @@ 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. @@ -225,10 +34,9 @@ def test_get_trusted_input(self, testdata: TrustedInputTestData) -> None: else [testdata.prevout_idx] trusted_inputs = [ - btc.getTrustedInput( + btc.get_trusted_input( prev_out_index=idx, - parsed_tx=tx, - raw_tx=testdata.tx) + parsed_tx=tx) for idx in prevout_idx] # Check each TrustedInput content diff --git a/tests/test_btc_rawtx_ljs.py b/tests/test_btc_rawtx_ljs.py index 483d498a..92bd6978 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 - ) -] +from helpers.basetest import BaseTestBtc +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from conftest import ledgerjs_test_data, LedgerjsApdu @pytest.mark.manual @@ -185,11 +10,11 @@ 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 @@ -197,11 +22,12 @@ def test_replay_ledgerjs_tests(self, test_data: List[LedgerjsApdu]) -> None: """ apdus = test_data btc = DeviceAppBtc() + response: Optional[bytes] = None # All apdus shall return 9000 + potentially some data 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..cba40e3a 100644 --- a/tests/test_btc_rawtx_zcash.py +++ b/tests/test_btc_rawtx_zcash.py @@ -1,317 +1,20 @@ 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 typing import List +from helpers.basetest import BaseTestZcash +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse, ZcashExtFooter +from conftest import zcash_ledgerjs_test_data, zcash_sign_tx_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. @@ -319,150 +22,102 @@ def test_replay_zcash_test(self, test_data: List[LedgerjsApdu]) -> None: 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("prefix_cmds", zcash_prefix_cmds()) + def test_sign_zcash_tx_with_trusted_zec_sap_inputs(self, + zcash_sign_tx_test_data: SignTxTestData, + 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 + 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 + btc = DeviceAppBtc() - self._send_raw_apdus(apdus, btc) + 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) # 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) - ] - ] + output_indexes = [_input.prev_tx_out_index for _input in parsed_tx.inputs] trusted_inputs = [ - btc.getTrustedInput( - data=input_datum, - chunks_len=chunks_len - ) - for (input_datum, chunks_len) in zip(input_data, utxos_chunks_len) - ] + 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 = [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 - ): + 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(trusted_inputs, output_indexes, requested_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 + 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 ) # 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=TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.NoScript), + parsed_tx=parsed_tx, + inputs=trusted_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( + btc.untrusted_hash_tx_input_finalize( p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + 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( + response = btc.untrusted_hash_tx_input_finalize( p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) + 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. @@ -471,75 +126,31 @@ def test_replay_zcash_test2(self) -> None: # 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 + # 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, (trusted_input, output_path) in enumerate(zip(trusted_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=TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.WithScript), + parsed_tx=parsed_tx, + parsed_utxos=parsed_utxos, + input_num=idx, + inputs=[trusted_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 cff96bed..4d338f7a 100644 --- a/tests/test_btc_rawtx_zcash2.py +++ b/tests/test_btc_rawtx_zcash2.py @@ -1,291 +1,96 @@ 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 typing import List +from helpers.basetest import BaseTestZcash +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse, ZcashExtFooter +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) + 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 - # 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") - - 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( + btc.untrusted_hash_tx_input_finalize( p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + 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( + response = btc.untrusted_hash_tx_input_finalize( p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) + 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. @@ -294,179 +99,11 @@ def test_replay_zcash_with_trusted_inputs(self) -> None: # 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]] + _ = btc.untrusted_hash_sign( + parsed_tx=parsed_tx, + output_path=None) # For untrusted_hash_sign() to behave as described in above comment - # 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") - - # 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 + # 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. @@ -475,51 +112,24 @@ def test_replay_zcash_no_trusted_inputs(self) -> None: # 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..dfd1df73 100644 --- a/tests/test_btc_segwit_tx_ljs.py +++ b/tests/test_btc_segwit_tx_ljs.py @@ -1,355 +1,103 @@ -# -# 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" -) - -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 -) +from helpers.basetest import BaseTestBtc, BtcPublicKey +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse +from conftest import segwit_sign_tx_test_data, SignTxTestData @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 - ) + # 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( + btc.untrusted_hash_tx_input_finalize( p1="ff", # to derive BIP 32 change address - data=ptx_to_hash_part2, - chunks_len=ptx_to_hash_part2_chunks_len - ) + 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( + response = btc.untrusted_hash_tx_input_finalize( p1="00", - data=ptx_to_hash_part3, - chunks_len=ptx_to_hash_part3_chunks_len - ) + 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. @@ -357,186 +105,24 @@ def test_sign_tx_with_multiple_trusted_segwit_inputs(self, test_data: TxData): # 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 039c6a1d..aec6ae42 100644 --- a/tests/test_btc_signature.py +++ b/tests/test_btc_signature.py @@ -1,162 +1,38 @@ -# -# 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.deviceappproxy.deviceappbtc import DeviceAppBtc -from helpers.txparser.transaction import Tx, TxType, TxParse +""" +Ledger BTC app unit tests, Legacy BTC tx, 1 input from segwit utxo tx -# 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" - ), -] +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. -# 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 tests below rely on two utilitity classes to work around that issue: -# 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 TxParse class parses a BTC tx into a dataclass the attributes of which are the + fields the BTC app needs to sign a tx. -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 +- 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. +""" + +import pytest +from helpers.basetest import BaseTestBtc, BtcPublicKey +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc +from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse +from conftest import btc_sign_tx_test_data, 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 @@ -166,80 +42,83 @@ def test_submit_trusted_segwit_input_btc_transaction(self, test_data: TxData) -> - 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() - tx = TxParse.from_raw(raw_tx=tx_to_sign) + parsed_tx = TxParse.from_raw(raw_tx=tx_to_sign) parsed_utxos = [TxParse.from_raw(raw_tx=utxo) for utxo in utxos] - # 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 tx.inputs] - trusted_inputs = [ - btc.getTrustedInput( - prev_out_index=out_idx.val, - parsed_tx=parsed_utxo, - raw_tx=utxo - ) - for (out_idx, parsed_utxo, utxo) in zip(output_indexes, parsed_utxos, 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") - - 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 tx.inputs] - 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.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 - ) - - # 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) + 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") - btc.untrustedTxInputHashStart2( - p1="00", - p2="02", # /!\ "02" to activate BIP 143 signature (b/c the pseudo-tx - # contains segwit inputs encapsulated in TrustedInputs). - parsed_tx=tx, - trusted_inputs=trusted_inputs) + 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") - btc.untrustedTxInputHashFinalize2( + btc.untrusted_hash_tx_input_finalize( p1="ff", # to derive BIP 32 change address - tx_data=change_path) + 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") - response = btc.untrustedTxInputHashFinalize( + response = btc.untrusted_hash_tx_input_finalize( p1="00", - tx_data=tx) + 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. @@ -247,204 +126,23 @@ def test_submit_trusted_segwit_input_btc_transaction(self, test_data: TxData) -> # 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): - # # 3.1 Send pseudo-tx w/ sigScript - # btc.untrustedTxInputHashStart2( - # 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 - # ) - for trusted_input, output_path in zip(trusted_inputs, output_paths): + for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript - btc.untrustedTxInputHashStart2( - p1="00", - p2="80", # to continue previously started tx hash - parsed_tx=tx, - trusted_inputs=trusted_input) + 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 - # ) - response = btc.untrustedHashSign2( - output_path= output_path, - parsed_tx=tx - ) self.check_signature(response) - #self.check_signature(response, expected_der_sig) + # 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) - print(" Signature OK\n") - From bbd0471f85556aae15b7c15396459e87e37c0199 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Fri, 3 Jul 2020 22:41:04 +0200 Subject: [PATCH 07/12] Tests: add README --- tests/README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/README.md diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..25f90da7 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,72 @@ +## 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 /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 /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.py --ontop -m nanos -k 1.6 -s <24-word seed> /bin/app.elf + +# Launch tests +cd /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.py --ontop -m nanos -k 1.6 -s <24-word seed> -l Bitcoin:/lib/btc.elf /bin/app.elf + +# Launch tests +cd /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 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 +WIP From 9522ab72e6f4fab4717f9f52e852b60208b5a49e Mon Sep 17 00:00:00 2001 From: hcleonis Date: Mon, 6 Jul 2020 16:02:31 +0200 Subject: [PATCH 08/12] Update tests/README.md with additional info of test framework & suggested next steps --- tests/README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/tests/README.md b/tests/README.md index 25f90da7..745328d9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -66,7 +66,79 @@ LEDGER_PROXY_ADDRESS=127.0.0.1 LEDGER_PROXY_PATH=9999 pytest -x -v [-s] -m zcash ### 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 -WIP +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 `ApduSet` 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 `ApduSet` gathers these `CApdu`s in one place. + | + |-- deviceappbtc.py: class derived from `DeviceAppProxy` that defines the `ApduSet` 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 +Below is a compilation of the various things to do 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). + +- `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: + - [ ] Replace `BytesOrStr` type with `AnyStr` built-in type + - [ ] Rename `lbstr` type to something more verbose like `ByteOrder` + - [ ] 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. From ce6538a8ab1fac530237b45c716fad07ab82f174 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Tue, 7 Jul 2020 11:32:09 +0200 Subject: [PATCH 09/12] Applied @onyb & @grydz review remarks --- setup.cfg | 26 +++ tests/README.md | 23 +- tests/conftest.py | 204 ++++++++++-------- tests/helpers/basetest.py | 37 ++-- tests/helpers/deviceappproxy/__init__.py | 2 +- tests/helpers/deviceappproxy/apduabstract.py | 52 +++-- tests/helpers/deviceappproxy/deviceappbtc.py | 52 ++--- .../helpers/deviceappproxy/deviceappproxy.py | 28 +-- tests/helpers/txparser/transaction.py | 50 +++-- tests/helpers/txparser/txtypes.py | 33 +-- tests/pytest.ini | 8 - tests/test_btc_get_trusted_input.py | 2 +- tests/test_btc_rawtx_ljs.py | 6 +- tests/test_btc_rawtx_zcash.py | 25 ++- tests/test_btc_rawtx_zcash2.py | 22 +- tests/test_btc_segwit_tx_ljs.py | 19 +- tests/test_btc_signature.py | 27 ++- 17 files changed, 330 insertions(+), 286 deletions(-) create mode 100644 setup.cfg delete mode 100644 tests/pytest.ini 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 index 745328d9..0e4b193b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -24,14 +24,14 @@ Because tests are available for both the BTC app and the Zcash app (using the BT ###### BTC tests The BTC app must be loaded on the device and started. ```shell script -cd /tests +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 /tests +cd $APP_BITCOIN_REPO_PATH/tests pytest -x -v [-s] -m zcash ``` @@ -40,27 +40,27 @@ 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 +cd $SPECULOS_REPO_PATH ./speculos.py --ontop -m nanos -k 1.6 -s <24-word seed> /bin/app.elf # Launch tests -cd /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 +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 /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 and the device. +- 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. @@ -139,6 +139,11 @@ Below is a compilation of the various things to do to structure and rationalize - 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: - - [ ] Replace `BytesOrStr` type with `AnyStr` built-in type + - [ ] 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 - [ ] Rename `lbstr` type to something more verbose like `ByteOrder` - - [ ] 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. diff --git a/tests/conftest.py b/tests/conftest.py index c85de09f..e952c2a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ -import pytest from dataclasses import dataclass, field from typing import List, Optional +import pytest @dataclass @@ -47,18 +47,18 @@ def btc_gti_test_data() -> List[TrustedInputTestData]: tx=bytes.fromhex( "02000000" "02" - "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab""00000000""6b" + "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab000000006b" "483045022100ca145f0694ffaedd333d3724ce3f4e44aabc0ed5128113660d11" "f917b3c5205302207bec7c66328bace92bd525f385a9aa1261b83e0f92310ea1" "850488b40bd25a5d0121032006c64cdd0485e068c1e22ba0fa267ca02ca0c2b3" - "4cdc6dd08cba23796b6ee7""fdffffff" - "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab""01000000""6a" + "4cdc6dd08cba23796b6ee7fdffffff" + "40d1ae8a596b34f48b303e853c56f8f6f54c483babc16978eb182e2154d5f2ab010000006a" "47304402202a5d54a1635a7a0ae22cef76d8144ca2a1c3c035c87e7cd0280ab4" "3d3451090602200c7e07e384b3620ccd2f97b5c08f5893357c653edc2b8570f0" "99d9ff34a0285c012102d82f3fa29d38297db8e1879010c27f27533439c868b1" - "cc6af27dd3d33b243dec""fdffffff" + "cc6af27dd3d33b243decfdffffff" "01" - "d7ee7c0100000000""19""76a9140ea263ff8b0da6e8d187de76f6a362beadab781188ac" + "d7ee7c01000000001976a9140ea263ff8b0da6e8d187de76f6a362beadab781188ac" "e3691900" ), prevout_idx=0, @@ -67,12 +67,12 @@ def btc_gti_test_data() -> List[TrustedInputTestData]: segwit_tx = TrustedInputTestData( tx=bytes.fromhex( - "02000000""0001" + "020000000001" "02" - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""00000000""00""fdffffff" - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""01000000""00""fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90000000000fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90100000000fdffffff" "01" - "01410f0000000000""16""0014e4d3a1ec51102902f6bbede1318047880c9c7680" + "01410f0000000000160014e4d3a1ec51102902f6bbede1318047880c9c7680" "024730440220495838c36533616d8cbd6474842459596f4f312dce5483fe6507" "91c82e17221c02200660520a2584144915efa8519a72819091e5ed78c52689b2" "4235182f17d96302012102ddf4af49ff0eae1d507cc50c86f903cd6aa0395f32" @@ -89,13 +89,13 @@ def btc_gti_test_data() -> List[TrustedInputTestData]: segwit_tx_2_outputs = TrustedInputTestData( tx=bytes.fromhex( - "02000000""0001" + "020000000001" "01" - "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db1""00000000""17" - "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846a""feffffff" + "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db10000000017" + "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846afeffffff" "02" - "9b3242bf01000000""17""a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" - "1027000000000000""17""a9141e852ac84f8385d76441c584e41f445aaf1624ea87" + "9b3242bf0100000017a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" + "102700000000000017a9141e852ac84f8385d76441c584e41f445aaf1624ea87" "0247304402206e54747dabff52f5c88230a3036125323e21c6c950719f671332" "cdd0305620a302204a2f2a6474f155a316505e2224eeab6391d5e6daf22acd76" "728bf74bc0b48e1a0121033c88f6ef44902190f859e4a6df23ecff4d86a2114b" @@ -120,7 +120,9 @@ def ledgerjs_test_data() -> List[List[LedgerjsApdu]]: # 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" + # expected_resp="410486b865b52b753d0a84d09bc20063fab5d8453ec33c215d4019a5801c9c6438b917770b2782e29a9ecc6edb" + # "67cd1f0fbf05ec4c1236884b6d686d6be3b1588abb2231334b453654666641724c683466564d36756f517a76735971357677657" + # "44a63564dbce80dd580792cd18af542790e56aa813178dc28644bb5f03dbd44c85f2d2e7a" ) ] @@ -129,22 +131,27 @@ def ledgerjs_test_data() -> List[List[LedgerjsApdu]]: commands=[ "e042000009000000010100000001", "e0428000254ea60aeac5252c14291d428915bd7ccd1bfc4af009f4d4dc57ae597ed0420b71010000008a", - "e04280003247304402201f36a12c240dbf9e566bc04321050b1984cd6eaf6caee8f02bb0bfec08e3354b022012ee2aeadcbbfd1e92959f", - "e04280003257c15c1c6debb757b798451b104665aa3010569b49014104090b15bde569386734abf2a2b99f9ca6a50656627e77de663ca7", + "e042800032" + "47304402201f36a12c240dbf9e566bc04321050b1984cd6eaf6caee8f02bb0bfec08e3354b022012ee2aeadcbbfd1e92959f", + "e042800032" + "57c15c1c6debb757b798451b104665aa3010569b49014104090b15bde569386734abf2a2b99f9ca6a50656627e77de663ca7", "e04280002a325702769986cf26cc9dd7fdea0af432c8e2becc867c932e1b9dd742f2a108997c2252e2bdebffffffff", "e04280000102", "e04280002281b72e00000000001976a91472a5d75c8d2d0565b656a5232703b167d50d5a2b88ac", "e042800022a0860100000000001976a9144533f5fb9b4817f713c48f0bfe96b9f50c476c9b88ac", "e04280000400000000" ], - expected_resp="3200" + "--"*2 + "c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f1001000000a086010000000000" + "--"*8 + expected_resp="3200" + "--" * 2 + + "c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f1001000000a086010000000000" + "--" * 8 ), LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START - - commands= [ + commands=[ "e0440000050100000001", "e04480002600c773da236484dae8f0fdba3d7e0ba1d05070d1a34fc44943e638441262a04f100100000069", - "e04480003252210289b4a3ad52a919abd2bdd6920d8a6879b1e788c38aa76f0440a6f32a9f1996d02103a3393b1439d1693b063482c04b", - "e044800032d40142db97bdf139eedd1b51ffb7070a37eac321030b9a409a1e476b0d5d17b804fcdb81cf30f9b99c6f3ae1178206e08bc5", + "e044800032" + "52210289b4a3ad52a919abd2bdd6920d8a6879b1e788c38aa76f0440a6f32a9f1996d02103a3393b1439d1693b063482c04b", + "e044800032" + "d40142db97bdf139eedd1b51ffb7070a37eac321030b9a409a1e476b0d5d17b804fcdb81cf30f9b99c6f3ae1178206e08bc5", "e04480000900639853aeffffffff" ] ), @@ -248,15 +255,18 @@ def zcash_ledgerjs_test_data() -> List[List[LedgerjsApdu]]: commands=[ "e042000009000000010400008001", "e042800025edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857010000006b", - "e042800032483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336dfa248aea9ccf022023b13e57595635452130", - "e0428000321c91ed0fe7072d295aa232215e74e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f42d458da", + "e042800032" + "483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336dfa248aea9ccf022023b13e57595635452130", + "e042800032" + "1c91ed0fe7072d295aa232215e74e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f42d458da", "e04280000b1100831dc4ff72ffffff00", "e04280000102", "e042800022a0860100000000001976a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac", "e0428000224d949100000000001976a914b714c60805804d86eb72a38c65ba8370582d09e888ac", "e04280000400000000", ], - expected_resp="3200" + "--"*2 + "20b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "--"*8 + expected_resp="3200" + "--" * 2 + + "20b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "--" * 8 ), ] @@ -267,14 +277,17 @@ def zcash_ledgerjs_test_data() -> List[List[LedgerjsApdu]]: LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START commands=[ "e0440005090400008085202f8901", - "e04480053b013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d9491000000000045e1e144cb88d4d800", + "e04480053b" + "013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "45e1e144cb88d4d800", "e044800504ffffff00", ] ), LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL commands=[ "e04aff0015058000002c80000085800000000000000100000003", - # "e04a0000320240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" + # "e04a000032" + # "0240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" "e04a0000230140420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" ], # tx aborted on 2nd command expected_sw="6985" @@ -306,14 +319,17 @@ def zcash_ledgerjs_test_data() -> List[List[LedgerjsApdu]]: LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT START commands=[ "e0440005090400008085202f8901", - "e04480053b""013832004d""0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51""01000000""4d94910000000000""45e1e144cb88d4d8""00", + "e04480053b" + "013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "45e1e144cb88d4d800", "e044800504ffffff00", ] ), LedgerjsApdu( # UNTRUSTED HASH TRANSACTION INPUT FINALIZE FULL commands=[ "e04aff0015058000002c80000085800000000000000100000003", - # "e04a0000320240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" + # "e04a000032" + # "0240420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac39498200000000001976a91425ea06" "e04a0000230140420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" "e04a8000045eb3f840" ], @@ -322,10 +338,12 @@ def zcash_ledgerjs_test_data() -> List[List[LedgerjsApdu]]: LedgerjsApdu( commands=[ - "e044008509""0400008085202f8901", - "e04480853b""013832004d04""20b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51""01000000""4d94910000000000""45e1e144cb88d4d8""19", - "e04480851d""76a9140a146582553b2f5537e13cef6659e82ed8f69b8f88ac""ffffff00", - "e048000015""058000002c80000085800000000000000100000001" + "e0440085090400008085202f8901", + "e04480853b" + "013832004d0420b7c68231303b2425a91b12f05bd6935072e9901137ae30222ef6d60849fc51010000004d94910000000000" + "45e1e144cb88d4d819", + "e04480851d76a9140a146582553b2f5537e13cef6659e82ed8f69b8f88acffffff00", + "e048000015058000002c80000085800000000000000100000001" ], check_sig_format=True ) @@ -340,18 +358,18 @@ def zcash_utxo_single() -> bytes: return bytes.fromhex( # https://sochain.com/api/v2/tx/ZEC/ec9033381c1cc53ada837ef9981c03ead1c7c41700ff3a954389cfaddc949256 # Zcash Sapling - "04000080""85202f89" + "0400008085202f89" "01" - "53685b8809efc50dd7d5cb0906b307a1b8aa5157baa5fc1bd6fe2d0344dd193a""00000000""6b" + "53685b8809efc50dd7d5cb0906b307a1b8aa5157baa5fc1bd6fe2d0344dd193a000000006b" "483045022100ca0be9f37a4975432a52bb65b25e483f6f93d577955290bb7fb0" "060a93bfc92002203e0627dff004d3c72a957dc9f8e4e0e696e69d125e4d8e27" "5d119001924d3b48012103b243171fae5516d1dc15f9178cfcc5fdc67b0a8830" "55c117b01ba8af29b953f6" "ffffffff" "01" - "4072070000000000""19""76a91449964a736f3713d64283fd0018626ba50091c7e988ac" + "40720700000000001976a91449964a736f3713d64283fd0018626ba50091c7e988ac" "00000000" - "00000000""0000000000000000""00""00""00" + "000000000000000000000000000000" ) @@ -362,33 +380,33 @@ def zcash_sign_tx_test_data() -> SignTxTestData: # Get Trusted Input APDUs as they are not supposed to be sent w/ these APDUs. bytes.fromhex( # Zcash Sapling - "04000080""85202f89" + "0400008085202f89" "01" - "edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857""01000000""6b" + "edc69b8179fd7c6a11a8a1ba5d17017df5e09296c3a1acdada0d94e199f68857010000006b" "483045022100e8043cd498714122a78b6ecbf8ced1f74d1c65093c5e2649336d" "fa248aea9ccf022023b13e575956354521301c91ed0fe7072d295aa232215e74" "e50d01a73b005dac01210201e1c9d8186c093d116ec619b7dad2b7ff0e7dd16f" "42d458da1100831dc4ff72" "ffffff00" "02" - "a086010000000000""19""76a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac" - "4d94910000000000""19""76a914b714c60805804d86eb72a38c65ba8370582d09e888ac" + "a0860100000000001976a914fa9737ab9964860ca0c3e9ad6c7eb3bc9c8f6fb588ac" + "4d949100000000001976a914b714c60805804d86eb72a38c65ba8370582d09e888ac" "00000000" - "00000000""0000000000000000""00""00""00" + "000000000000000000000000000000" ) ] test_tx_to_sign = bytes.fromhex( # Zcash Sapling - "04000080""85202f89" + "0400008085202f89" "01" - "d35f0793da27a5eacfe984c73b1907af4b50f3aa3794ba1bb555b9233addf33f""01000000""00" + "d35f0793da27a5eacfe984c73b1907af4b50f3aa3794ba1bb555b9233addf33f0100000000" "ffffff00" "02" - "40420f0000000000""19""76a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" - "2b51820000000000""19""76a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + "40420f00000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" + "2b518200000000001976a91490360f7a0b0e50d5dd0c924fc1d6e7adb8519c9388ac" "5eb3f840" - "00000000""0000000000000000""00""00""00" + "000000000000000000000000000000" ) test_change_path = bytes.fromhex("058000002c80000085800000000000000100000003") # 44'/133'/0'/1/3 @@ -460,42 +478,42 @@ def zcash2_sign_tx_test_data() -> SignTxTestData: test_utxos = [ # Zcash Overwinter bytes.fromhex( - "03000080""7082c403" + "030000807082c403" "03" - "f6959fbdd8cc614211e4db1ca287a766441dcda8d786f70d956ad19de03373a4""01000000""69" + "f6959fbdd8cc614211e4db1ca287a766441dcda8d786f70d956ad19de03373a40100000069" "46304302203dc5102d80e08cb8dee8e83894026a234d84ddd92da1605405a677" "ead9fcb21a021f40bedfa4b5611fc00a6d43aedb6ea0769175c2eb4ce4f68963" "c3a6103228080121028aceaa654c031435beb9bcf80d656a7519a6732f3da3c8" - "14559396131ea3532e""ffffff00" - "5ae818ee42a08d5c335d850cacb4b5996e5d2bc1cd5f0c5b46733652771c23b9""01000000""6b" + "14559396131ea3532effffff00" + "5ae818ee42a08d5c335d850cacb4b5996e5d2bc1cd5f0c5b46733652771c23b9010000006b" "483045022100df24e46115778a766068f1b744a7ffd2b0ae4e09b34259eecb2f" "5871f5e3ff7802207c83c3c13c8113f904da3ea4d4ceedb0db4e8518fb43e9fb" "8aeda64d1a69c76b012103e604d3cbc5c8aa4f9c53f84157be926d443054ba93" - "b60fbddf0aea053173f595""ffffff00" - "6065c6c49cd132fc148f947b5aa5fd2a4e0ae4b5a884ccb3247b5ccbfa3ecc58""01000000""6a" + "b60fbddf0aea053173f595ffffff00" + "6065c6c49cd132fc148f947b5aa5fd2a4e0ae4b5a884ccb3247b5ccbfa3ecc58010000006a" "473044022064d92d88b8223f9e502214b2abf8eb72b91ad7ed69ae9597cb510a" "3c94c7a2b00220327b4b852c2a81ad918bb341e7cd1c7e15903fc3e298663d75" "675c4ab180be890121037dbc2659579d22c284a3ea2e3b5d0881f678583e2b4a" - "8b19dbd50f384d4b2535""ffffff00" + "8b19dbd50f384d4b2535ffffff00" "02" - "002d310100000000""19""76a914772b6723ec72c99f6a37009407006fe1c790733988ac" - "13b6240000000000""19""76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac" + "002d3101000000001976a914772b6723ec72c99f6a37009407006fe1c790733988ac" + "13b62400000000001976a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac" "00000000" - "00000000""00" + "0000000000" ) ] test_tx_to_sign = bytes.fromhex( # Zcash Sapling - "04000080""85202f89" + "0400008085202f89" "01" - "605d4c86ca4511e962dbd968ab6805deeff0f076f6a8c6069dadefb0378c7244""01000000""19" - "76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388ac""ffffff00" + "605d4c86ca4511e962dbd968ab6805deeff0f076f6a8c6069dadefb0378c72440100000019" + "76a914d46156a9e784f5f28fdbbaa4ed8301170be6cc0388acffffff00" "02" - "c05c150000000000""19""76a914130715c4e654cff3fced8a9d6876310083d44f2e88ac" - "e9540f0000000000""19""76a91478dff3b7ed9dac8e9177c587375937f9d057769588ac" + "c05c1500000000001976a914130715c4e654cff3fced8a9d6876310083d44f2e88ac" + "e9540f00000000001976a91478dff3b7ed9dac8e9177c587375937f9d057769588ac" "00000000" - "00000000""0000000000000000""00""00""00" + "000000000000000000000000000000" ) test_change_path = bytes.fromhex("058000002c80000085800000000000000100000007") # 44'/133'/0'/1/7 @@ -519,11 +537,11 @@ def segwit_sign_tx_test_data() -> SignTxTestData: "02000000" "0001" "01" - "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db1""00000000""17" - "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846a""feffffff" + "1541bf80c7b109c50032345d7b6ad6935d5868520477966448dc78ab8f493db10000000017" + "160014d44d01d48f9a0d5dfa73dab21c30f7757aed846afeffffff" "02" - "9b3242bf01000000""17""a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" - "1027000000000000""17""a9141e852ac84f8385d76441c584e41f445aaf1624ea87" + "9b3242bf0100000017a914ff31b9075c4ac9aee85668026c263bc93d016e5a87" + "102700000000000017a9141e852ac84f8385d76441c584e41f445aaf1624ea87" "0247" "304402206e54747dabff52f5c88230a3036125323e21c6c950719f671332cdd0" "305620a302204a2f2a6474f155a316505e2224eeab6391d5e6daf22acd76728b" @@ -535,13 +553,13 @@ def segwit_sign_tx_test_data() -> SignTxTestData: "01000000" "0001" "02" - "7ab1cb19a44db08984031508ec97de727b32a8176cc00fce727065e86984c8df""00000000""17" - "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320""ffffff00" - "78958127caf18fc38733b7bc061d10bca72831b48be1d6ac91e296b888003327""00000000""17" - "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320""ffffff00" + "7ab1cb19a44db08984031508ec97de727b32a8176cc00fce727065e86984c8df0000000017" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320ffffff00" + "78958127caf18fc38733b7bc061d10bca72831b48be1d6ac91e296b8880033270000000017" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320ffffff00" "02" - "1027000000000000""17""a91493520844497c54e709756c819afecfffaf28761187" - "c84b1a0000000000""17""a9148f1f7cf3c847e4057be46990c4a00be4271f3cfa87" + "102700000000000017a91493520844497c54e709756c819afecfffaf28761187" + "c84b1a000000000017a9148f1f7cf3c847e4057be46990c4a00be4271f3cfa87" "0247" "3044022009116da9433c3efad4eaf5206a780115d6e4b2974152bdceba220c45" "70e527a802202b06ca9eb93df1c9fc5b0e14dc1f6698adc8cbc15d3ec4d364b7" @@ -559,14 +577,14 @@ def segwit_sign_tx_test_data() -> SignTxTestData: "0001" # Inputs "02" - "027a726f8aa4e81a45241099a9820e6cb7d8920a686701ad98000721101fa0aa""01000000""17" - "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320""ffffff00" - "f0b7b7ad837b4d3535bea79a2fa355262df910873b7a51afa1e4279c6b6f6e6f""00000000""17" - "160014eee02beeb4a8f15bbe4926130c086bd47afe8dbc""ffffff00" + "027a726f8aa4e81a45241099a9820e6cb7d8920a686701ad98000721101fa0aa0100000017" + "160014d815dddcf8cf1b820419dcb1206a2a78cfa60320ffffff00" + "f0b7b7ad837b4d3535bea79a2fa355262df910873b7a51afa1e4279c6b6f6e6f0000000017" + "160014eee02beeb4a8f15bbe4926130c086bd47afe8dbcffffff00" # Outputs "02" - "1027000000000000""17""a9142406cd1d50d3be6e67c8b72f3e430a1645b0d74287" - "0e26000000000000""17""a9143ae394774f1348be3a6bc2a55b67e3566d13408987" + "102700000000000017a9142406cd1d50d3be6e67c8b72f3e430a1645b0d74287" + "0e2600000000000017a9143ae394774f1348be3a6bc2a55b67e3566d13408987" # witnesses "02483045022100f4d05565991d98573629c7f98c4f575a4915600a83a0057716" "f1f4865054927f022010f30365e0685ee46d81586b50f5dd201ddedab39cfd7d" @@ -588,10 +606,10 @@ def segwit_sign_tx_test_data() -> SignTxTestData: # ] test_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 + bytes.fromhex("0580000031800000018000000000000000000001f6"), # 49'/1'/0'/0/502 + bytes.fromhex("0580000031800000018000000000000000000001f7") # 49'/1'/0'/0/503 ] - test_change_path = bytes.fromhex("05""80000031""80000001""80000000""00000001""00000045") # 49'/1'/0'/1/69 + test_change_path = bytes.fromhex("058000003180000001800000000000000100000045") # 49'/1'/0'/1/69 return SignTxTestData( tx_to_sign=test_tx_to_sign, @@ -617,10 +635,10 @@ def btc_sign_tx_test_data() -> SignTxTestData: "02000000" "0001" "02" - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""00000000""00""fdffffff" - "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a9""01000000""00""fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90000000000fdffffff" + "daf4d7b97a62dd9933bd6977b5da9a3edb7c2d853678c9932108f1eb4d27b7a90100000000fdffffff" "01" - "01410f0000000000""16""0014e4d3a1ec51102902f6bbede1318047880c9c7680" + "01410f0000000000160014e4d3a1ec51102902f6bbede1318047880c9c7680" "0247" "30440220495838c36533616d8cbd6474842459596f4f312dce5483fe650791c8" "2e17221c02200660520a2584144915efa8519a72819091e5ed78c52689b24235" @@ -640,11 +658,11 @@ def btc_sign_tx_test_data() -> SignTxTestData: test_tx_to_sign = bytes.fromhex( "02000000" "01" - "2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753""00000000""19""76a914e4d3a1ec" - "51102902f6bbede1318047880c9c768088ac""fdffffff" + "2CE0F1697564D5DAA5AFDB778E32782CC95443D9A6E39F39519991094DEF8753000000001976a914e4d3a1ec" + "51102902f6bbede1318047880c9c768088acfdffffff" "02" - "1027000000000000""16""0014161d283ebbe0e6bc3d90f4c456f75221e1b3ca0f" - "64190f0000000000""16""00144c5133c242683d33c61c4964611d82dcfe0d7a9a" + "1027000000000000160014161d283ebbe0e6bc3d90f4c456f75221e1b3ca0f" + "64190f00000000001600144c5133c242683d33c61c4964611d82dcfe0d7a9a" "a7011900" ) @@ -654,13 +672,13 @@ def btc_sign_tx_test_data() -> SignTxTestData: # test_expected_der_sig = [ # bytes.fromhex( # for output #1 # "3044" - # "0220""2cadfbd881f592ea82e69038c7ada8f1ae50919e3be92ad1cd5fcc0bd142b2f5" - # "0220""646a699b5532fcdf38b196157e216c8808ae7bde5e786b8f3cbf2502d0f14c13" + # "02202cadfbd881f592ea82e69038c7ada8f1ae50919e3be92ad1cd5fcc0bd142b2f5" + # "0220646a699b5532fcdf38b196157e216c8808ae7bde5e786b8f3cbf2502d0f14c13" # "01"), # ] - test_output_paths = [bytes.fromhex("05""80000054""80000001""80000000""00000000""00000000"), ] # 84'/1'/0'/0/0 - test_change_path = bytes.fromhex("05""80000054""80000001""80000000""00000001""00000001") # 84'/1'/0'/1/1 + 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, diff --git a/tests/helpers/basetest.py b/tests/helpers/basetest.py index c36b7026..5f8ad58f 100644 --- a/tests/helpers/basetest.py +++ b/tests/helpers/basetest.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Optional, List, Any import hashlib import base58 -from .deviceappproxy.deviceappproxy import DeviceAppProxy from ledgerblue.commException import CommException +from .deviceappproxy.deviceappproxy import DeviceAppProxy class NID: @@ -23,13 +23,13 @@ class BtcPublicKey: def __init__(self, apdu_response: bytes, network_id: NID = NID.TESTNET) -> None: self.nid: NID = network_id self.pubkey_len: int = apdu_response[0] - self.pubkey: bytes = apdu_response[1:1+self.pubkey_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.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 @@ -45,11 +45,11 @@ def __repr__(self) -> str: class BaseTestBtc: """ - Base class for tests of BTC app, contains data validators. + Base class for tests of BTC app, contains data validators. """ @staticmethod def check_trusted_input(trusted_input: bytes, - out_index: bytes, + out_index: bytes, out_amount: bytes, out_hash: Optional[bytes] = None) -> None: print(f" Magic marker = {trusted_input[:2].hex()}") @@ -60,8 +60,8 @@ def check_trusted_input(trusted_input: bytes, 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 @@ -82,7 +82,7 @@ def check_signature(resp: bytes, 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()}") @@ -100,19 +100,19 @@ def check_signature(resp: bytes, @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. '--'), + # 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 @staticmethod def split_pubkey_data(data: bytes) -> BtcPublicKey: @@ -147,10 +147,9 @@ def send_ljs_apdus(self, apdus: List[Any], device: DeviceAppProxy): 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 == True: + 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/deviceappproxy/__init__.py b/tests/helpers/deviceappproxy/__init__.py index e33a5401..9c2a5847 100644 --- a/tests/helpers/deviceappproxy/__init__.py +++ b/tests/helpers/deviceappproxy/__init__.py @@ -1,6 +1,6 @@ """ Helper package that abstract communicating with a Ledger device through ISO-7816 """ -from .apduabstract import BytesOrStr, ApduDict, CApdu, ApduSet +from .apduabstract import ApduDict, CApdu, ApduSet 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 index d51f18df..97e3cf16 100644 --- a/tests/helpers/deviceappproxy/apduabstract.py +++ b/tests/helpers/deviceappproxy/apduabstract.py @@ -1,9 +1,6 @@ -from typing import List, Dict, Union, Optional, cast, NewType, Tuple +from typing import List, Dict, Optional, cast, NewType, Tuple, AnyStr from dataclasses import dataclass, field -# Type aliases -BytesOrStr = NewType("BytesOrStr", Union[bytes, str]) - @dataclass class CApdu: @@ -12,12 +9,12 @@ class Type: OUT: int = 1 INOUT: int = 2 data: List[bytes] - 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") + cla: AnyStr = field(default="00") + ins: AnyStr = field(default="00") + p1: AnyStr = field(default="00") + p2: AnyStr = field(default="00") + lc: AnyStr = field(default="00") + le: AnyStr = field(default="00") typ: Type = field(default=Type.INOUT) @@ -35,12 +32,12 @@ def __init__(self, apdus: Optional[ApduDict] = None, max_lc: int = 255) -> None: self.max_lc = max_lc @staticmethod - def _bytes(data: BytesOrStr) -> bytes: - if type(data) is bytes: + def _bytes(data: AnyStr) -> bytes: + if isinstance(data, (bytes, bytearray)): return data - if type(data) is int: + if isinstance(data, int): return bytes([cast(int, data)]) - if type(data) is str: + if isinstance(data, str): return bytes.fromhex(data) raise TypeError(f"{data} cannot be converted to bytes") @@ -53,16 +50,16 @@ def apdus(self, new_apdus: 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(new_apdus) is not dict: + if not isinstance(new_apdus, dict): raise ValueError("Attribute newApdus must be a dictionary containing CApdu " "instances as values") ApduSet._apdus = new_apdus def apdu(self, name: str, - p1: Optional[BytesOrStr] = None, - p2: Optional[BytesOrStr] = None, - data: Optional[List[BytesOrStr]] = None, - le: Optional[BytesOrStr] = None) -> Tuple[bytes, List[Optional[bytes]]]: + 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. """ @@ -94,29 +91,28 @@ def _bytesbuf(apdu: CApdu, apdu_keys: List[str]) -> bytes: 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: + if not isinstance(value, CApdu): raise ValueError(f"Syntax '{self.__class__.__name__}[{key}] = value' " f"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[List[BytesOrStr]] = None, - le: Optional[BytesOrStr] = None): + p1: Optional[AnyStr] = None, + p2: Optional[AnyStr] = None, + data: Optional[List[AnyStr]] = None, + le: Optional[AnyStr] = 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: bool = all(True if type(param) in (str, bytes, list) else False for param in (p1, p2, data)) + params_valid: bool = all(bool(isinstance(param, (str, bytes, list))) for param in (p1, p2, data)) if not params_valid: raise ValueError("Parameters must either be single byte (e.g. p1 or p2), multiple bytes" " (e.g. data) or an hex string adhering to these constraints") # Set APDU parameters & payload - if (p1 and len(self._bytes(p1)) > 1) \ - or (p2 and len(self._bytes(p2)) > 1) \ - or (le and len(self._bytes(le)) > 1): + 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 default values for p1, p2 and le if they were not provided diff --git a/tests/helpers/deviceappproxy/deviceappbtc.py b/tests/helpers/deviceappproxy/deviceappbtc.py index 3384523a..1c7dd0b9 100644 --- a/tests/helpers/deviceappproxy/deviceappbtc.py +++ b/tests/helpers/deviceappproxy/deviceappbtc.py @@ -1,9 +1,9 @@ -from typing import Optional, List, cast, Union -from .apduabstract import ApduSet, ApduDict, CApdu, BytesOrStr +from typing import Optional, List, cast, Union, AnyStr +from .apduabstract import ApduSet, ApduDict, 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, lbstr, TxInput +from ..txparser.transaction import Tx, TxType, TxVarInt, TxHashMode, ZcashExtHeader, ZcashExtFooter, byteorder, TxInput class BTC_P1: @@ -46,7 +46,8 @@ class DeviceAppBtc(DeviceAppProxy): "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 + # 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, @@ -56,7 +57,7 @@ def __init__(self, super().__init__(mnemonic=mnemonic, chunk_size=DeviceAppBtc.default_chunk_size) @staticmethod - def _get_input_index(tx: Tx, _input: bytes, endianness: lbstr = 'little'): + 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 @@ -138,7 +139,7 @@ def get_trusted_input(self, 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[bytes] = [ + 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) @@ -165,12 +166,14 @@ def get_trusted_input(self, else: payload_chunks.append(parsed_tx.lock_time.buf) - return self.send_apdu(*self.btc.apdu("GetTrustedInput", p1="00", p2="00", data=payload_chunks)) + 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: BytesOrStr) -> bytes: - return self.send_apdu(*self.btc.apdu("GetWalletPublicKey", p1="00", p2="00", data=[data])) + 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], @@ -178,19 +181,19 @@ def untrusted_hash_tx_input_start(self, input_num: Optional[int] = None, mode: TxHashMode = TxHashMode(TxHashMode.LegacyBtc | TxHashMode.Trusted | TxHashMode.WithScript), - endianness: lbstr = 'little') -> bytes: + endianness: byteorder = 'little') -> bytes: """Hash the inputs of the tx data""" - def _get_p2() -> BytesOrStr: + def _get_p2() -> AnyStr: if mode.is_hash_with_script: - return "80" - elif mode.is_segwit_input_hash: - return "02" - elif mode.is_bcash_input_hash: - return "03" - elif mode.is_zcash_input_hash: - return "04" - elif mode.is_sapling_input_hash: - return "05" + 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: @@ -286,15 +289,16 @@ def pubkey_hash_from_script(pubkey_script: bytes) -> bytes: ]) p2 = _get_p2() - return self.send_apdu(*self.btc.apdu("UntrustedHashTxInputStart", p1="00", p2=p2, data=payload_chunks)) + 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: BytesOrStr, - data: Union[BytesOrStr, Tx]) -> bytes: + 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 type(p1) is str else p1 + 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 diff --git a/tests/helpers/deviceappproxy/deviceappproxy.py b/tests/helpers/deviceappproxy/deviceappproxy.py index 9f3ef1e4..5145fec1 100644 --- a/tests/helpers/deviceappproxy/deviceappproxy.py +++ b/tests/helpers/deviceappproxy/deviceappproxy.py @@ -1,9 +1,9 @@ import subprocess import time -from typing import Optional, Union, List, cast +from typing import Optional, Union, List, AnyStr, cast from ledgerblue.comm import getDongle from ledgerblue.commTCP import DongleServer -from .apduabstract import BytesOrStr, CApdu +from .apduabstract import CApdu # decorator that try to connect to a physical dongle before executing a method @@ -34,13 +34,13 @@ def __init__(self, @dongle_connected def send_apdu(self, apdu: Union[CApdu, bytes], - data: Optional[List[BytesOrStr]] = None, - p1_msb_means_next: bool = True) -> BytesOrStr: + data: Optional[List[AnyStr]] = None, + p1_msb_means_next: bool = True) -> AnyStr: """Send APDUs to a Ledger device.""" - def _bytes(str_bytes: BytesOrStr) -> bytes: - ret = str_bytes if type(str_bytes) is bytes \ - else bytes([cast(int, str_bytes)]) if type(str_bytes) is int \ - else bytes.fromhex(str_bytes) if type(str_bytes) is str else None + 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") @@ -63,14 +63,14 @@ def _send_chunked_apdu(apdu_header: bytearray, 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) else "OK" + 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: BytesOrStr = None + response: Optional[AnyStr] = None if data and len(data) > 1: # Payload already split in chunks of the appropriate lengths @@ -82,7 +82,7 @@ def _send_chunked_apdu(apdu_header: bytearray, # 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) else "OK" + _resp = response.hex() if len(response) > 0 else "OK" print(f"[device >] {_resp}") _set_p1(hdr, _chunk, payload_chunks, p1_msb_means_next) else: @@ -95,10 +95,10 @@ def _send_chunked_apdu(apdu_header: bytearray, @dongle_connected def send_raw_apdu(self, - apdu: bytes) -> BytesOrStr: + apdu: bytes) -> AnyStr: print(f"[device <] {apdu.hex()}") - response: BytesOrStr = self.dongle.exchange(apdu) - _resp: Union[bytes, str] = response.hex() if len(response) else "OK" + response: AnyStr = self.dongle.exchange(apdu) + _resp: Union[bytes, str] = response.hex() if len(response) > 0 else "OK" print(f"[device >] {_resp}") return response diff --git a/tests/helpers/txparser/transaction.py b/tests/helpers/txparser/transaction.py index c44c9ed0..df6031cd 100644 --- a/tests/helpers/txparser/transaction.py +++ b/tests/helpers/txparser/transaction.py @@ -1,9 +1,11 @@ -from .txtypes import * 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 @@ -22,8 +24,8 @@ class Sig: s: bytes sig: Sig other: bytes - witness_count: varint - witness_len: varint + 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] @@ -94,10 +96,11 @@ class TxParse: ``parsed_tx = TxParse.from_raw(raw_btc_tx)`` """ + # pylint: disable=too-many-statements @classmethod def from_raw(cls, raw_tx: Union[bytes, str], - endianness: lbstr = 'little') -> Tx: + endianness: byteorder = 'little') -> Tx: """ Returns a TX object with members initialized from the parsing of the rawTx parameter @@ -122,18 +125,19 @@ def _recursive_hash_obj(obj: Any, """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 is not None and type(obj) is not bytes: + if obj is not None and not isinstance(obj, (bytes, bytearray)): # Each items in a list of objects must be parsed entirely - if type(obj) is list: + if isinstance(obj, list): for i, item in enumerate(obj): - path.append(str(i+1)) # Display the item rank in the list + 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 + # 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): tmp = path[:] @@ -146,7 +150,7 @@ def _recursive_hash_obj(obj: Any, hasher.update(cast(bytes, obj)) h1, h2 = (sha256(), sha256()) - if type(tx) is bytes: + 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) @@ -163,7 +167,7 @@ def _recursive_hash_obj(obj: Any, def _read_varint(buf: BytesIO, prefix: Optional[bytes] = None, - bytes_order: lbstr = 'little') -> TxVarInt: + 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) @@ -177,7 +181,7 @@ def _read_bytes(buf: BytesIO, size: int) -> bytes: def _read_uint(buf: BytesIO, bytes_len: int, - bytes_order: lbstr = 'little') -> 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: @@ -188,21 +192,21 @@ 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: lbstr = 'little') -> u16: + 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: lbstr = 'little') -> u32: + 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: lbstr) -> (int, bytes): + 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: lbstr = 'little') -> List[TxInput]: + 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): @@ -233,7 +237,7 @@ def _parse_inputs(buf: BytesIO, def _parse_outputs(buf: BytesIO, out_count: int, - bytes_order: lbstr = 'little') -> List[TxOutput]: + 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): @@ -251,7 +255,7 @@ def _parse_outputs(buf: BytesIO, script=out_script)) return _outputs - def _parse_zcash_footer(buf: BytesIO, bytes_order: lbstr = 'little') -> Optional[ZcashExtFooter]: + 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 @@ -265,11 +269,11 @@ def _parse_zcash_footer(buf: BytesIO, bytes_order: lbstr = 'little') -> Optional binding_sig: Optional[bytes32] = None if version.val >= 3: - ival, bval = _read_tx_int(buf, 4, bytes_order) - expiry_height = TxInt4(val=cast(u32, ival), buf=cast(bytes4, bval)) + iv, bv = _read_tx_int(buf, 4, bytes_order) + expiry_height = TxInt4(val=cast(u32, iv), buf=cast(bytes4, bv)) if version.val >= 4: - ival, bval = _read_tx_int(buf, 8, bytes_order) - value_balance = TxInt8(val=cast(u64, ival), buf=cast(bytes8, bval)) + 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 @@ -332,7 +336,7 @@ def _tx_type(buf: BytesIO) -> txtype: # # Transaction parsing code starts here # - raw_tx_bytes: bytes = bytes.fromhex(raw_tx) if type(raw_tx) == str else raw_tx + 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( @@ -355,7 +359,7 @@ def _tx_type(buf: BytesIO) -> txtype: val=cast(u32, ival), buf=cast(bytes4, bval) ) - overwintered_flag = True if ivers & 0x80000000 else False + overwintered_flag = bool(ivers & 0x80000000) input_count: TxVarInt = _read_varint(io_buf) inputs: List[TxInput] = _parse_inputs(io_buf, input_count.val) diff --git a/tests/helpers/txparser/txtypes.py b/tests/helpers/txparser/txtypes.py index 95202e4a..498034e0 100644 --- a/tests/helpers/txparser/txtypes.py +++ b/tests/helpers/txparser/txtypes.py @@ -1,11 +1,12 @@ from io import BytesIO from sys import version_info -from typing import NewType, Optional, cast from dataclasses import dataclass assert version_info.major >= 3, "Python 3 required!" if version_info.minor >= 8: - from typing import Literal + # 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 @@ -14,16 +15,16 @@ 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", type(bytes(1))) # 1 byte -bytes2 = NewType("bytes2", type(bytes(2))) # 2 bytes -bytes4 = NewType("bytes4", type(bytes(4))) # 4 bytes -bytes8 = NewType("bytes8", type(bytes(8))) # 8 bytes -bytes16 = NewType("bytes16", type(bytes(16))) # 16 bytes -bytes32 = NewType("bytes32", type(bytes(32))) # 32 bytes -bytes64 = NewType("bytes64", type(bytes(64))) # 64 bytes +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) -lbstr = NewType("lbstr", Literal['big', 'little']) +byteorder = NewType("byteorder", Literal['big', 'little']) # Types for the supported kinds of transactions. Extend as needed. @@ -77,7 +78,7 @@ def is_hash_with_script(self) -> bool: @property def is_hash_no_script(self): - return not self.is_hash_with_script(self._hash_mode) + return not self.is_hash_with_script @property def is_btc_input_hash(self) -> bool: @@ -106,7 +107,7 @@ def is_zcash_or_sapling_input_hash(self) -> bool: @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 @@ -158,10 +159,10 @@ 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) - elif int_value <= 0xffff: + if int_value <= 0xffff: bval = int_value.to_bytes(2, endianness) return b'\xfd' + bval if endianness == 'big' else bval + b'\xfd' - elif int_value <= 0xffffffff: + 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") @@ -169,7 +170,7 @@ def to_bytes(cls, value: Optional[int], endianness: str = 'big'): @staticmethod def from_raw(buf: BytesIO, prefix: Optional[bytes] = None, - endianness: lbstr = 'big'): + 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 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 ad0ba4be..e66a1596 100644 --- a/tests/test_btc_get_trusted_input.py +++ b/tests/test_btc_get_trusted_input.py @@ -1,6 +1,6 @@ # Note on APDU payload chunks splitting: # -------------------------------------- -# The BTC app tx parser requires the tx data to be sent in chunks. For some tx fields +# 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. # # The tx data splitting into the appropriate payload chunks is now delegated to the diff --git a/tests/test_btc_rawtx_ljs.py b/tests/test_btc_rawtx_ljs.py index 92bd6978..71ae61a0 100644 --- a/tests/test_btc_rawtx_ljs.py +++ b/tests/test_btc_rawtx_ljs.py @@ -1,5 +1,5 @@ -import pytest from typing import List, Optional +import pytest from helpers.basetest import BaseTestBtc from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc from conftest import ledgerjs_test_data, LedgerjsApdu @@ -17,14 +17,14 @@ class TestLedgerjsBtcTx(BaseTestBtc): @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.send_raw_apdu(bytes.fromhex(command)) if apdu.expected_resp is not None: diff --git a/tests/test_btc_rawtx_zcash.py b/tests/test_btc_rawtx_zcash.py index cba40e3a..06d9ff81 100644 --- a/tests/test_btc_rawtx_zcash.py +++ b/tests/test_btc_rawtx_zcash.py @@ -1,10 +1,9 @@ -import pytest from typing import List +import pytest from helpers.basetest import BaseTestZcash -from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc -from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse, ZcashExtFooter -from conftest import zcash_ledgerjs_test_data, zcash_sign_tx_test_data, zcash_prefix_cmds, \ - SignTxTestData, LedgerjsApdu +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 @pytest.mark.zcash @@ -16,10 +15,10 @@ class TestLedgerjsZcashTx(BaseTestZcash): @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. """ btc = DeviceAppBtc() @@ -109,21 +108,21 @@ def test_sign_zcash_tx_with_trusted_zec_sap_inputs(self, # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") btc.untrusted_hash_tx_input_finalize( - p1="ff", # to derive BIP 32 change address + 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") response = btc.untrusted_hash_tx_input_finalize( - p1="00", + 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") _ = btc.untrusted_hash_sign( @@ -131,7 +130,7 @@ def test_sign_zcash_tx_with_trusted_zec_sap_inputs(self, output_path=None) # For untrusted_hash_sign() to behave as described in above comment # 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. + # 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)") for idx, (trusted_input, output_path) in enumerate(zip(trusted_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript diff --git a/tests/test_btc_rawtx_zcash2.py b/tests/test_btc_rawtx_zcash2.py index 4d338f7a..b3e9dd06 100644 --- a/tests/test_btc_rawtx_zcash2.py +++ b/tests/test_btc_rawtx_zcash2.py @@ -1,8 +1,8 @@ -import pytest from typing import List +import pytest from helpers.basetest import BaseTestZcash -from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc -from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse, ZcashExtFooter +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc, BTC_P1 +from helpers.txparser.transaction import Tx, TxHashMode, TxParse from conftest import zcash2_prefix_cmds, SignTxTestData, LedgerjsApdu @@ -82,21 +82,21 @@ def test_sign_zcash_tx_with_trusted_zec_ovw_inputs(self, # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") btc.untrusted_hash_tx_input_finalize( - p1="ff", # to derive BIP 32 change address + 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") response = btc.untrusted_hash_tx_input_finalize( - p1="00", + 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") _ = btc.untrusted_hash_sign( @@ -104,13 +104,13 @@ def test_sign_zcash_tx_with_trusted_zec_ovw_inputs(self, output_path=None) # For untrusted_hash_sign() to behave as described in above comment # 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. + # 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." for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript diff --git a/tests/test_btc_segwit_tx_ljs.py b/tests/test_btc_segwit_tx_ljs.py index dfd1df73..915a42c6 100644 --- a/tests/test_btc_segwit_tx_ljs.py +++ b/tests/test_btc_segwit_tx_ljs.py @@ -2,10 +2,11 @@ Ledger BTC app unit tests, Segwit BTC tx, 2 inputs from 2 Segwit utxo txs """ import pytest -from helpers.basetest import BaseTestBtc, BtcPublicKey -from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc -from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse -from conftest import segwit_sign_tx_test_data, SignTxTestData +from helpers.basetest import BaseTestBtc +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc, BTC_P1 +from helpers.txparser.transaction import TxHashMode, TxParse +from conftest import SignTxTestData + @pytest.mark.btc @pytest.mark.manual @@ -73,7 +74,7 @@ def test_sign_tx_with_multiple_trusted_segwit_inputs(self, 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 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 " @@ -89,21 +90,21 @@ def test_sign_tx_with_multiple_trusted_segwit_inputs(self, # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") btc.untrusted_hash_tx_input_finalize( - p1="ff", # to derive BIP 32 change address + 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") response = btc.untrusted_hash_tx_input_finalize( - p1="00", + 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)") # Hash & sign each input individually for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): diff --git a/tests/test_btc_signature.py b/tests/test_btc_signature.py index aec6ae42..1de3f824 100644 --- a/tests/test_btc_signature.py +++ b/tests/test_btc_signature.py @@ -19,16 +19,15 @@ """ import pytest -from helpers.basetest import BaseTestBtc, BtcPublicKey -from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc -from helpers.txparser.transaction import Tx, TxType, TxHashMode, TxParse -from conftest import btc_sign_tx_test_data, SignTxTestData +from helpers.basetest import BaseTestBtc +from helpers.deviceappproxy.deviceappbtc import DeviceAppBtc, BTC_P1 +from helpers.txparser.transaction import TxHashMode, TxParse +from conftest import SignTxTestData @pytest.mark.btc @pytest.mark.manual class TestBtcTxSignature(BaseTestBtc): - @pytest.mark.parametrize("use_trusted_inputs", [True, False]) def test_sign_tx_with_trusted_segwit_input(self, use_trusted_inputs: bool, @@ -36,11 +35,11 @@ def test_sign_tx_with_trusted_segwit_input(self, """ 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." """ # Start test @@ -94,7 +93,7 @@ def test_sign_tx_with_trusted_segwit_input(self, 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 " @@ -110,21 +109,21 @@ def test_sign_tx_with_trusted_segwit_input(self, # 2.2.1 Start with change address path print("\n--* Untrusted Transaction Input Hash Finalize Full - Handle change address") btc.untrusted_hash_tx_input_finalize( - p1="ff", # to derive BIP 32 change address + 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") response = btc.untrusted_hash_tx_input_finalize( - p1="00", + 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)") for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript From 55cde36ff954383ed2200305d0dc9c3a8f379e44 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Tue, 7 Jul 2020 11:48:27 +0200 Subject: [PATCH 10/12] Add forgotten review fix --- tests/README.md | 2 +- tests/helpers/txparser/txtypes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/README.md b/tests/README.md index 0e4b193b..97587cd0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -146,4 +146,4 @@ Below is a compilation of the various things to do to structure and rationalize - [ ] 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 - - [ ] Rename `lbstr` type to something more verbose like `ByteOrder` + - [X] Rename `lbstr` type to something more verbose like `ByteOrder` diff --git a/tests/helpers/txparser/txtypes.py b/tests/helpers/txparser/txtypes.py index 498034e0..c1683a90 100644 --- a/tests/helpers/txparser/txtypes.py +++ b/tests/helpers/txparser/txtypes.py @@ -24,7 +24,7 @@ bytes32 = NewType("bytes32", bytes) # 32 bytes bytes64 = NewType("bytes64", bytes) # 64 bytes txtype = NewType("txtype", int) -byteorder = NewType("byteorder", Literal['big', 'little']) +byteorder = Literal['big', 'little'] # Types for the supported kinds of transactions. Extend as needed. From d882cc94f3f843443f389d4324dd7180d92bc2ff Mon Sep 17 00:00:00 2001 From: hcleonis Date: Thu, 9 Jul 2020 11:53:06 +0200 Subject: [PATCH 11/12] Modifications following review from @Yass --- tests/README.md | 12 +- tests/conftest.py | 5 +- tests/helpers/deviceappproxy/__init__.py | 2 +- tests/helpers/deviceappproxy/apduabstract.py | 230 +++++++++++-------- tests/helpers/deviceappproxy/deviceappbtc.py | 47 ++-- tests/helpers/txparser/transaction.py | 4 +- tests/test_btc_rawtx_zcash.py | 62 ++--- 7 files changed, 196 insertions(+), 166 deletions(-) diff --git a/tests/README.md b/tests/README.md index 97587cd0..ff4fa5cc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -98,11 +98,11 @@ The tests and framework are organized as described below: | communication between the app & the tests. | |-- apduabstract.py: Define the `CApdu` dataclass (abstract representation of an APDU) - | and the `ApduSet` class which is a collection of `CApdu`s supported by an app + | 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 `ApduSet` gathers these `CApdu`s in one place. + | supported by an app and `ApduDict` gathers these `CApdu`s in one place. | - |-- deviceappbtc.py: class derived from `DeviceAppProxy` that defines the `ApduSet` of + |-- 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. @@ -112,8 +112,9 @@ The tests and framework are organized as described below: === -### Next steps -Below is a compilation of the various things to do 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). +### 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. @@ -139,6 +140,7 @@ Below is a compilation of the various things to do to structure and rationalize - 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: diff --git a/tests/conftest.py b/tests/conftest.py index e952c2a6..2b6bb0ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -410,10 +410,7 @@ def zcash_sign_tx_test_data() -> SignTxTestData: ) test_change_path = bytes.fromhex("058000002c80000085800000000000000100000003") # 44'/133'/0'/1/3 - test_output_paths = [ - bytes.fromhex("058000002c80000085800000000000000100000001"), # 44'/133'/0'/1/1 - bytes.fromhex("058000002c80000085800000000000000000000004") # 44'/133'/0'/0/4 - ] + test_output_paths = [bytes.fromhex("058000002c80000085800000000000000100000001")] # 44'/133'/0'/1/1 return SignTxTestData( tx_to_sign=test_tx_to_sign, diff --git a/tests/helpers/deviceappproxy/__init__.py b/tests/helpers/deviceappproxy/__init__.py index 9c2a5847..b6aebf29 100644 --- a/tests/helpers/deviceappproxy/__init__.py +++ b/tests/helpers/deviceappproxy/__init__.py @@ -1,6 +1,6 @@ """ Helper package that abstract communicating with a Ledger device through ISO-7816 """ -from .apduabstract import ApduDict, CApdu, ApduSet +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 index 97e3cf16..92baf5db 100644 --- a/tests/helpers/deviceappproxy/apduabstract.py +++ b/tests/helpers/deviceappproxy/apduabstract.py @@ -1,35 +1,60 @@ -from typing import List, Dict, Optional, cast, NewType, Tuple, AnyStr -from dataclasses import dataclass, field +from typing import List, Dict, Optional, Union, cast, NewType, Tuple, AnyStr +# from dataclasses import dataclass, field - -@dataclass -class CApdu: - class Type: - IN: int = 0 - OUT: int = 1 - INOUT: int = 2 - data: List[bytes] - cla: AnyStr = field(default="00") - ins: AnyStr = field(default="00") - p1: AnyStr = field(default="00") - p2: AnyStr = field(default="00") - lc: AnyStr = field(default="00") - le: AnyStr = field(default="00") - typ: Type = field(default=Type.INOUT) +ApduType = NewType("ApduType", int) -ApduDict = NewType("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" +# @dataclass +class CApdu: """ - _apdus: ApduDict = {} - - def __init__(self, apdus: Optional[ApduDict] = None, max_lc: int = 255) -> None: - self.apdus = apdus - self.max_lc = max_lc + 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: @@ -41,89 +66,94 @@ def _bytes(data: AnyStr) -> bytes: return bytes.fromhex(data) raise TypeError(f"{data} cannot be converted to bytes") - @property - def apdus(self) -> Optional[ApduDict]: - return ApduSet._apdus if len(ApduSet._apdus.keys()) > 0 else None - - @apdus.setter - def apdus(self, new_apdus: 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 not isinstance(new_apdus, dict): - raise ValueError("Attribute newApdus must be a dictionary containing CApdu " - "instances as values") - ApduSet._apdus = new_apdus + 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) - 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. - """ + @classmethod + def set_max_lc(cls, max_lc): + cls.max_lc = max_lc - def _bytesbuf(apdu: CApdu, apdu_keys: List[str]) -> bytes: - """Concatenates all @apdu attributes whose names are provided in @apdu_keys, - into a single byte buffer.""" - return b''.join(self._bytes(getattr(apdu, k)) if k in apdu.__dict__ else self._bytes(k) for k in apdu_keys) - - 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) - # Determine APDU type - apdu_is_in_only_or_inout: bool = self._apdus[name].typ == CApdu.Type.IN \ - or self._apdus[name].typ == CApdu.Type.INOUT - apdu_is_out_only: bool = self._apdus[name].typ == CApdu.Type.OUT - # Return the C-APDU header with correct Lc - return ( - _bytesbuf( - self.apdus[name], - ['cla', 'ins', 'p1', 'p2', 'lc' if apdu_is_in_only_or_inout else 'le' if apdu_is_out_only else '00'] - ), - self.apdus[name].data - ) - - def __setitem__(self, key: str, value: CApdu) -> None: - """Change an existing APDU or add a new one to the APDU dict - """ - if not isinstance(value, CApdu): - raise ValueError(f"Syntax '{self.__class__.__name__}[{key}] = value' " - f"only accept CApdu instances as value") - self.apdus[key] = value - - def set_params(self, key: str, + def set_params(self, p1: Optional[AnyStr] = None, p2: Optional[AnyStr] = None, data: Optional[List[AnyStr]] = None, le: Optional[AnyStr] = None): - """Set the parameters and payload of a specific APDU + """Updates the p1, p2, data and le attributes a CApdu instance """ # 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: bool = all(bool(isinstance(param, (str, bytes, list))) for param in (p1, p2, data)) - if not params_valid: - raise ValueError("Parameters must either be single byte (e.g. p1 or p2), multiple bytes" - " (e.g. data) or an hex string adhering to these constraints") - # Set APDU parameters & payload 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 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") - if data is not None: + # 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.apdus[key].data = [self._bytes(d) for d in data if d is not None] - if self.apdus[key].typ in (CApdu.Type.IN, CApdu.Type.INOUT): - self.apdus[key].lc = data_len.to_bytes(1, 'big') if data_len < self.max_lc else b'\x00' - elif self.apdus[key].typ == CApdu.Type.OUT: - self.apdus[key].le = self._bytes(le) if le is not None else b'\x00' + 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 index 1c7dd0b9..9a98b4ab 100644 --- a/tests/helpers/deviceappproxy/deviceappbtc.py +++ b/tests/helpers/deviceappproxy/deviceappbtc.py @@ -1,5 +1,5 @@ from typing import Optional, List, cast, Union, AnyStr -from .apduabstract import ApduSet, ApduDict, CApdu +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. @@ -40,7 +40,7 @@ class DeviceAppBtc(DeviceAppProxy): "sorry charge ozone often gauge photo sponsor faith business taste front" \ "differ bounce chaos" - apdus: ApduDict = { + 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), @@ -52,7 +52,7 @@ class DeviceAppBtc(DeviceAppProxy): def __init__(self, mnemonic: str = default_mnemonic) -> None: - self.btc = ApduSet(DeviceAppBtc.apdus, max_lc=DeviceAppBtc.default_chunk_size) + 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) @@ -262,31 +262,22 @@ def pubkey_hash_from_script(pubkey_script: bytes) -> bytes: # 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 - if mode.is_segwit_zcash_or_sapling_input_hash: - if mode.is_hash_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 - ]) - else: # BTC or BCash tx - if script is not None: - 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: - payload_chunks.extend( - [ # [input||script_len (00), sequence] - f_input + b'\x00', parsed_tx.inputs[input_idx].sequence_nb.buf - ]) + # 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( diff --git a/tests/helpers/txparser/transaction.py b/tests/helpers/txparser/transaction.py index df6031cd..b077fa85 100644 --- a/tests/helpers/txparser/transaction.py +++ b/tests/helpers/txparser/transaction.py @@ -125,7 +125,7 @@ def _recursive_hash_obj(obj: Any, """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 is not None and not isinstance(obj, (bytes, bytearray)): + 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): @@ -139,7 +139,7 @@ def _recursive_hash_obj(obj: Any, # 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): + 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) diff --git a/tests/test_btc_rawtx_zcash.py b/tests/test_btc_rawtx_zcash.py index 06d9ff81..292f338e 100644 --- a/tests/test_btc_rawtx_zcash.py +++ b/tests/test_btc_rawtx_zcash.py @@ -45,9 +45,11 @@ def test_get_trusted_input_from_zec_sap_tx(self, zcash_utxo_single) -> None: print(" OK") @pytest.mark.manual + @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: """ Replay of real Zcash tx with inputs from a Zcash tx, trusted inputs on @@ -64,27 +66,35 @@ def test_sign_zcash_tx_with_trusted_zec_sap_inputs(self, # 0. Send the Get Version raw apdus self.send_ljs_apdus(apdus=prefix_cmds, device=btc) - # 1. Get Trusted Input - print("\n--* Get Trusted Input - from utxos") - output_indexes = [_input.prev_tx_out_index for _input in parsed_tx.inputs] - trusted_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(trusted_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 - ) + 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") @@ -98,9 +108,9 @@ def test_sign_zcash_tx_with_trusted_zec_sap_inputs(self, 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=TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.NoScript), + mode=hash_mode_1, parsed_tx=parsed_tx, - inputs=trusted_inputs, + inputs=tx_inputs, parsed_utxos=parsed_utxos) print(" OK") @@ -132,15 +142,15 @@ def test_sign_zcash_tx_with_trusted_zec_sap_inputs(self, # 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)") - for idx, (trusted_input, output_path) in enumerate(zip(trusted_inputs, output_paths)): + for idx, (tx_input, output_path) in enumerate(zip(tx_inputs, output_paths)): # 3.1 Send pseudo-tx w/ sigScript btc.untrusted_hash_tx_input_start( # continue prev. started tx hash - mode=TxHashMode(TxHashMode.ZcashSapling | TxHashMode.Trusted | TxHashMode.WithScript), + mode=hash_mode_2, parsed_tx=parsed_tx, parsed_utxos=parsed_utxos, input_num=idx, - inputs=[trusted_input]) + inputs=[tx_input]) print(" Final hash OK") # 3.2 Sign tx at last. Param is: From 23ab55a913dfff620d3bfc0f521a741e20621242 Mon Sep 17 00:00:00 2001 From: hcleonis Date: Thu, 9 Jul 2020 17:04:15 +0200 Subject: [PATCH 12/12] Review fix: guard againt hashing a None transaction --- tests/helpers/txparser/transaction.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/helpers/txparser/transaction.py b/tests/helpers/txparser/transaction.py index b077fa85..4ae92b9f 100644 --- a/tests/helpers/txparser/transaction.py +++ b/tests/helpers/txparser/transaction.py @@ -143,11 +143,12 @@ def _recursive_hash_obj(obj: Any, tmp = path[:] tmp.append(key) _recursive_hash_obj(getattr(obj, key), hasher, ignored_fields, tmp, show_path) - else: + 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)):