diff --git a/tests/ragger/.gitignore b/tests/ragger/.gitignore new file mode 100644 index 0000000..93526df --- /dev/null +++ b/tests/ragger/.gitignore @@ -0,0 +1,2 @@ +venv/ +__pycache__/ diff --git a/tests/ragger/conftest.py b/tests/ragger/conftest.py new file mode 100644 index 0000000..64048dc --- /dev/null +++ b/tests/ragger/conftest.py @@ -0,0 +1,50 @@ +import pytest +from ragger import Firmware +from ragger.backend import SpeculosBackend, LedgerCommBackend, LedgerWalletBackend, BackendInterface +from ethereum_client import EthereumClient + +# This variable is needed for Speculos only (physical tests need the application to be already installed) +APPLICATION = "../../bin/app.elf" +# This variable will be useful in tests to implement different behavior depending on the firmware +NANOX_FIRMWARE = Firmware("nanox", "2.0.2") +NANOS_FIRMWARE = Firmware("nanos", "2.1") +NANOSP_FIRMWARE = Firmware("nanosp", "1.0") + +# adding a pytest CLI option "--backend" +def pytest_addoption(parser): + print(help(parser.addoption)) + parser.addoption("--backend", action="store", default="speculos") + +# accessing the value of the "--backend" option as a fixture +@pytest.fixture(scope="session") +def backend_name(pytestconfig) -> str: + return pytestconfig.getoption("backend") + +# Providing the firmware as a fixture +@pytest.fixture +def firmware() -> Firmware: + return NANOX_FIRMWARE + +# Depending on the "--backend" option value, a different backend is +# instantiated, and the tests will either run on Speculos or on a physical +# device depending on the backend +def create_backend(backend: str, firmware: Firmware) -> BackendInterface: + if backend.lower() == "ledgercomm": + return LedgerCommBackend(firmware, interface="hid") + elif backend.lower() == "ledgerwallet": + return LedgerWalletBackend(firmware) + elif backend.lower() == "speculos": + return SpeculosBackend(APPLICATION, firmware) + else: + raise ValueError(f"Backend '{backend}' is unknown. Valid backends are: {BACKENDS}") + +# This fixture will create and return the backend client +@pytest.fixture +def backend_client(backend_name: str, firmware: Firmware) -> BackendInterface: + with create_backend(backend_name, firmware) as b: + yield b + +# This final fixture will return the properly configured app client, to be used in tests +@pytest.fixture +def app_client(backend_client: BackendInterface) -> EthereumClient: + yield EthereumClient(backend_client) diff --git a/tests/ragger/ethereum_client.py b/tests/ragger/ethereum_client.py new file mode 100644 index 0000000..fd56ef2 --- /dev/null +++ b/tests/ragger/ethereum_client.py @@ -0,0 +1,232 @@ +from enum import IntEnum, auto +from typing import Iterator +from ragger.backend import BackendInterface +from ragger.utils import RAPDU + +class InsType(IntEnum): + EIP712_SEND_STRUCT_DEF = 0x1a, + EIP712_SEND_STRUCT_IMPL = 0x1c, + EIP712_SEND_FILTERING = 0x1e, + EIP712_SIGN = 0x0c + +class P1Type(IntEnum): + COMPLETE_SEND = 0x00, + PARTIAL_SEND = 0x01 + +class P2Type(IntEnum): + STRUCT_NAME = 0x00, + STRUCT_FIELD = 0xff, + ARRAY = 0x0f, + LEGACY_IMPLEM = 0x00 + NEW_IMPLEM = 0x01, + +class EIP712FieldType: + CUSTOM = 0, + INT = auto(), + UINT = auto(), + ADDRESS = auto(), + BOOL = auto(), + STRING = auto(), + FIXED_BYTES = auto(), + DYN_BYTES = auto() + + +class EthereumClientCmdBuilder: + _CLA: int = 0xE0 + + def _serialize(self, + ins: InsType, + p1: int, + p2: int, + cdata: bytearray = bytearray()) -> bytes: + + header = bytearray() + header.append(self._CLA) + header.append(ins) + header.append(p1) + header.append(p2) + header.append(len(cdata)) + return header + cdata + + def eip712_send_struct_def_struct_name(self, name: str) -> bytes: + data = bytearray() + for char in name: + data.append(ord(char)) + return self._serialize(InsType.EIP712_SEND_STRUCT_DEF, + P1Type.COMPLETE_SEND, + P2Type.STRUCT_NAME, + data) + + def eip712_send_struct_def_struct_field(self, + field_type: EIP712FieldType, + type_name: str, + type_size: int, + array_levels: [], + key_name: str) -> bytes: + data = bytearray() + typedesc = 0 + typedesc |= (len(array_levels) > 0) << 7 + typedesc |= (type_size > 0) << 6 + typedesc |= field_type + data.append(typedesc) + if field_type == EIP712FieldType.CUSTOM: + data.append(len(type_name)) + for char in type_name: + data.append(ord(char)) + if type_size > 0: + data.append(type_size) + if len(array_levels) > 0: + data.append(len(array_levels)) + for level in array_levels: + data.append(0 if level == None else 1) + if level != None: + data.append(level) + data.append(len(key_name)) + for char in key_name: + data.append(ord(char)) + return self._serialize(InsType.EIP712_SEND_STRUCT_DEF, + P1Type.COMPLETE_SEND, + P2Type.STRUCT_FIELD, + data) + + def eip712_send_struct_impl_root_struct(self, name: str) -> bytes: + data = bytearray() + for char in name: + data.append(ord(char)) + return self._serialize(InsType.EIP712_SEND_STRUCT_IMPL, + P1Type.COMPLETE_SEND, + P2Type.STRUCT_NAME, + data) + + def eip712_send_struct_impl_array(self, size: int) -> bytes: + data = bytearray() + data.append(size) + return self._serialize(InsType.EIP712_SEND_STRUCT_IMPL, + P1Type.COMPLETE_SEND, + P2Type.ARRAY, + data) + + def eip712_send_struct_impl_struct_field(self, data: bytearray) -> Iterator[bytes]: + while len(data > 0): + yield self._serialize(InsType.EIP712_SEND_STRUCT_IMPL, + P1Type.COMPLETE_SEND, + P2Type.STRUCT_FIELD, + data[:0xff]) + data = data[0xff:] + + def _format_bip32(self, bip32, data = bytearray()) -> bytearray: + data.append(len(bip32)) + for idx in bip32: + data.append((idx & 0xff000000) >> 24) + data.append((idx & 0x00ff0000) >> 16) + data.append((idx & 0x0000ff00) >> 8) + data.append((idx & 0x000000ff)) + return data + + def eip712_sign_new(self, bip32) -> bytes: + data = self._format_bip32(bip32) + return self._serialize(InsType.EIP712_SIGN, + P1Type.COMPLETE_SEND, + P2Type.NEW_IMPLEM, + data) + + def eip712_sign_legacy(self, + bip32, + domain_hash: bytes, + message_hash: bytes) -> bytes: + data = self._format_bip32(bip32) + data += domain_hash + data += message_hash + return self._serialize(InsType.EIP712_SIGN, + P1Type.COMPLETE_SEND, + P2Type.LEGACY_IMPLEM, + data) + + +class EthereumResponseParser: + def sign(self, data: bytes): + assert len(data) == (1 + 32 + 32) + + v = data[0:1] + data = data[1:] + + r = data[0:32] + data = data[32:] + + s = data[0:32] + data = data[32:] + + return v, r, s + + +class EthereumClient: + def __init__(self, client: BackendInterface, debug: bool = False): + self._client = client + self._debug = debug + self._cmd_builder = EthereumClientCmdBuilder() + self._resp_parser = EthereumResponseParser() + + def _send(self, payload: bytearray) -> None: + self._client.send_raw(payload) + + def _recv(self) -> RAPDU: + return self._client.receive() + + def eip712_send_struct_def_struct_name(self, name: str): + self._send(self._cmd_builder.eip712_send_struct_def_struct_name(name)) + return self._recv() + + def eip712_send_struct_def_struct_field(self, + field_type: EIP712FieldType, + type_name: str, + type_size: int, + array_levels: [], + key_name: str): + self._send(self._cmd_builder.eip712_send_struct_def_struct_field( + field_type, + type_name, + type_size, + array_levels, + key_name)) + return self._recv() + + def eip712_send_struct_impl_root_struct(self, name: str): + self._send(self._cmd_builder.eip712_send_struct_impl_root_struct(name)) + return self._recv() + + def eip712_send_struct_impl_array(self, size: int): + send._send(self._cmd_builder.eip712_send_struct_impl_array(size)) + return self._recv() + + def eip712_send_struct_impl_struct_field(self, raw_value: bytes): + ret = None + for apdu in self._cmd_builder.eip712_send_struct_impl_struct_field( + InsType.EIP712_SEND_STRUCT_IMPL, + P1Type.COMPLETE_SEND, + P2Type.STRUCT_FIELD, + data[:0xff]): + self._send(apdu) + ret = self._recv() + return ret + + def eip712_sign_new(self, bip32): + self._send(self._cmd_builder.eip712_sign_new(bip32)) + return self._recv() + + def eip712_sign_legacy(self, + bip32, + domain_hash: bytes, + message_hash: bytes): + self._send(self._cmd_builder.eip712_sign_legacy(bip32, + domain_hash, + message_hash)) + self._client.right_click() # sign typed message screen + for _ in range(2): # two hashes (domain + message) + for _ in range(2): # two screens per hash + self._client.right_click() + self._client.both_click() # approve signature + + resp = self._recv() + + assert resp.status == 0x9000 + return self._resp_parser.sign(resp.data) diff --git a/tests/ragger/requirements.txt b/tests/ragger/requirements.txt new file mode 100644 index 0000000..b85a795 --- /dev/null +++ b/tests/ragger/requirements.txt @@ -0,0 +1,3 @@ +ragger +pytest>=6.1.1,<7.0.0 +ecdsa>=0.16.1,<0.17.0 diff --git a/tests/ragger/test_eip712.py b/tests/ragger/test_eip712.py new file mode 100644 index 0000000..c3b4de1 --- /dev/null +++ b/tests/ragger/test_eip712.py @@ -0,0 +1,22 @@ +import os +import fnmatch +from ethereum_client import EthereumClient + +def test_eip712_legacy(app_client: EthereumClient): + bip32 = [ + 0x8000002c, + 0x8000003c, + 0x80000000, + 0, + 0 + ] + + v, r, s = app_client.eip712_sign_legacy( + bip32, + bytes.fromhex('6137beb405d9ff777172aa879e33edb34a1460e701802746c5ef96e741710e59'), + bytes.fromhex('eb4221181ff3f1a83ea7313993ca9218496e424604ba9492bb4052c03d5c3df8') + ) + + assert v == bytes.fromhex("1c") + assert r == bytes.fromhex("ea66f747173762715751c889fea8722acac3fc35db2c226d37a2e58815398f64") + assert s == bytes.fromhex("52d8ba9153de9255da220ffd36762c0b027701a3b5110f0a765f94b16a9dfb55")