diff --git a/.gitignore b/.gitignore index 0666ff3..581399e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,34 @@ +# Build and compilation directories (RĂ©pertoires de compilation) bin debug dep obj dev-env + +# Generated source files src/glyphs.c src/glyphs.h + +# IDE/editor specific files .idea .vscode gitignore + +# Dependency directories node_modules + +# Log files and test specifications *.log* +*.spec + +# Build output and distribution directories build dist -*.spec -*.DS_Store client/target + +# Operating system files +*.DS_Store + +# Python cache and temporary files +__pycache__ +snapshots-tmp \ No newline at end of file diff --git a/ledger_app.toml b/ledger_app.toml index 9cb05b7..e6634a3 100644 --- a/ledger_app.toml +++ b/ledger_app.toml @@ -1,4 +1,7 @@ [app] build_directory = "./" sdk = "C" -devices = ["nanos", "nanos+", "nanox", "flex", "stax"] +devices = ["nanos+", "nanox"] + +[tests] +pytest_directory = "./tests/" diff --git a/tests/application_client/__init__.py b/tests/application_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/application_client/everscale_command_sender.py b/tests/application_client/everscale_command_sender.py new file mode 100644 index 0000000..90a16c0 --- /dev/null +++ b/tests/application_client/everscale_command_sender.py @@ -0,0 +1,166 @@ +from enum import IntEnum +from typing import Generator, List, Optional +from contextlib import contextmanager + +from ragger.backend.interface import BackendInterface, RAPDU +from ragger.bip import pack_derivation_path + + +MAX_APDU_LEN: int = 255 + +CLA: int = 0xE0 + +class P1(IntEnum): + # Parameter 1 for first APDU number. + P1_START = 0x00 + # Parameter 1 for maximum APDU number. + P1_MAX = 0x03 + # Parameter 1 for screen confirmation for GET_PUBLIC_KEY. + P1_CONFIRM = 0x01 + +class P2(IntEnum): + # Parameter 2 for last APDU to receive. + P2_LAST = 0x00 + # Parameter 2 for more APDU to receive. + P2_MORE = 0x80 + +class InsType(IntEnum): + GET_APP_CONFIGURATION = 0x01 + GET_PUBLIC_KEY = 0x02 + SIGN_MESSAGE = 0x03 + GET_ADDRESS = 0X04 + SIGN_TRANSACTION = 0x05 + +class WalletType(IntEnum): + WALLET_V3 = 0 + EVER_WALLET = 1 + SAFE_MULTISIG_WALLET = 2 + SAFE_MULTISIG_WALLET_24H = 3 + SETCODE_MULTISIG_WALLET = 4 + BRIDGE_MULTISIG_WALLET = 5 + SURF_WALLET = 6 + MULTISIG_2 = 7 + MULTISIG_2_1 = 8 + +class Errors(IntEnum): + SW_INVALID_DATA = 0x6B00, + SW_CELL_UNDERFLOW = 0x6B01, + SW_RANGE_CHECK_FAIL = 0x6B02, + SW_WRONG_LABEL = 0x6B03, + SW_INVALID_FLAG = 0x6B04, + SW_END_OF_STREAM = 0x6B05, + SW_SLICE_IS_EMPTY = 0x6B06, + SW_INVALID_KEY = 0x6B07, + SW_CELL_IS_EMPTY = 0x6B08, + SW_INVALID_HASH = 0x6B09, + SW_INVALID_CELL_INDEX = 0x6B10, + SW_INVALID_REQUEST = 0x6B11, + SW_INVALID_FUNCTION_ID = 0x6B12, + SW_INVALID_SRC_ADDRESS = 0x6B13, + SW_INVALID_WALLET_ID = 0x6B14, + SW_INVALID_WALLET_TYPE = 0x6B15, + SW_INVALID_TICKER_LENGTH = 0x6B16, + SW_DENY = 0x6985, + # Status Word from everscale app + # SW_WRONG_P1P2 = 0x6A86 + # SW_WRONG_DATA_LENGTH = 0x6A87 + # SW_INS_NOT_SUPPORTED = 0x6D00 + # SW_CLA_NOT_SUPPORTED = 0x6E00 + # SW_WRONG_RESPONSE_LENGTH = 0xB000 + # SW_DISPLAY_BIP32_PATH_FAIL = 0xB001 + # SW_DISPLAY_ADDRESS_FAIL = 0xB002 + # SW_DISPLAY_AMOUNT_FAIL = 0xB003 + # SW_WRONG_TX_LENGTH = 0xB004 + # SW_TX_PARSING_FAIL = 0xB005 + # SW_TX_HASH_FAIL = 0xB006 + # SW_BAD_STATE = 0xB007 + # SW_SIGNATURE_FAIL = 0xB008 + + +def split_message(message: bytes, max_size: int) -> List[bytes]: + return [message[x:x + max_size] for x in range(0, len(message), max_size)] + + +class EverscaleCommandSender: + def __init__(self, backend: BackendInterface) -> None: + self.backend = backend + + + def get_app_and_version(self) -> RAPDU: + return self.backend.exchange(cla=0xB0, # specific CLA for BOLOS + ins=0x01, # specific INS for get_app_and_version + p1=P1.P1_START, + p2=P2.P2_LAST, + data=b"") + + + def get_app_config(self) -> RAPDU: + return self.backend.exchange(cla=CLA, + ins=InsType.GET_APP_CONFIGURATION, + p1=P1.P1_START, + p2=P2.P2_LAST, + data=b"") + + + def get_app_name(self) -> RAPDU: + return self.backend.exchange(cla=CLA, + ins=InsType.GET_APP_NAME, + p1=P1.P1_START, + p2=P2.P2_LAST, + data=b"") + + + def get_public_key(self, account_number: int) -> RAPDU: + return self.backend.exchange(cla=CLA, + ins=InsType.GET_PUBLIC_KEY, + p1=P1.P1_START, + p2=P2.P2_LAST, + data=account_number.to_bytes(4, "big")) + + + @contextmanager + def get_public_key_with_confirmation(self, account_number: int) -> Generator[None, None, None]: + with self.backend.exchange_async(cla=CLA, + ins=InsType.GET_PUBLIC_KEY, + p1=P1.P1_CONFIRM, + p2=P2.P2_LAST, + data=account_number.to_bytes(4, "big")) as response: + yield response + + def get_address(self, account_number: int, wallet_type: WalletType) -> RAPDU: + return self.backend.exchange(cla=CLA, + ins=InsType.GET_ADDRESS, + p1=P1.P1_START, + p2=P2.P2_LAST, + data=account_number.to_bytes(4, "big") + wallet_type.to_bytes(1, "big")) + + @contextmanager + def get_address_with_confirmation(self, account_number: int, wallet_type: WalletType) -> Generator[None, None, None]: + with self.backend.exchange_async(cla=CLA, + ins=InsType.GET_ADDRESS, + p1=P1.P1_CONFIRM, + p2=P2.P2_LAST, + data=account_number.to_bytes(4, "big") + wallet_type.to_bytes(1, "big")) as response: + yield response + + + @contextmanager + def sign_message(self, account_number: int, wallet_type: WalletType, message: bytes) -> Generator[None, None, None]: + with self.backend.exchange_async(cla=CLA, + ins=InsType.SIGN_MESSAGE, + p1=P1.P1_CONFIRM, + p2=P2.P2_LAST, + data=account_number.to_bytes(4, "big") + wallet_type.to_bytes(1, "big") + message) as response: + yield response + + @contextmanager + def sign_tx(self, account_number: int, wallet_type: WalletType, transaction: bytes) -> Generator[None, None, None]: + with self.backend.exchange_async(cla=CLA, + ins=InsType.SIGN_TX, + p1=P1.P1_START, + p2=P2.P2_LAST, + data=account_number.to_bytes(4, "big") + wallet_type.to_bytes(1, "big") + transaction) as response: + yield response + + def get_async_response(self) -> Optional[RAPDU]: + return self.backend.last_async_response diff --git a/tests/application_client/everscale_response_unpacker.py b/tests/application_client/everscale_response_unpacker.py new file mode 100644 index 0000000..b08e690 --- /dev/null +++ b/tests/application_client/everscale_response_unpacker.py @@ -0,0 +1,78 @@ +from typing import Tuple +from struct import unpack + +# remainder, data_len, data +def pop_sized_buf_from_buffer(buffer:bytes, size:int) -> Tuple[bytes, bytes]: + return buffer[size:], buffer[0:size] + +# remainder, data_len, data +def pop_size_prefixed_buf_from_buf(buffer:bytes) -> Tuple[bytes, int, bytes]: + data_len = buffer[0] + return buffer[1+data_len:], data_len, buffer[1:data_len+1] + +# Unpack from response: +# response = app_name (var) +def unpack_get_app_name_response(response: bytes) -> str: + return response.decode("ascii") + +# Unpack from response: +# response = MAJOR (1) +# MINOR (1) +# PATCH (1) +def unpack_get_version_response(response: bytes) -> Tuple[int, int, int]: + assert len(response) == 3 + major, minor, patch = unpack("BBB", response) + return (major, minor, patch) + +# Unpack from response: +# response = format_id (1) +# app_name_raw_len (1) +# app_name_raw (var) +# version_raw_len (1) +# version_raw (var) +# unused_len (1) +# unused (var) +def unpack_get_app_and_version_response(response: bytes) -> Tuple[str, str]: + response, _ = pop_sized_buf_from_buffer(response, 1) + response, _, app_name_raw = pop_size_prefixed_buf_from_buf(response) + response, _, version_raw = pop_size_prefixed_buf_from_buf(response) + response, _, _ = pop_size_prefixed_buf_from_buf(response) + + assert len(response) == 0 + + return app_name_raw.decode("ascii"), version_raw.decode("ascii") + +# Unpack from response: +# response = pub_key_len (1) +# pub_key (var) +def unpack_get_public_key_response(response: bytes) -> Tuple[int, bytes]: + response, pub_key_len, pub_key = pop_size_prefixed_buf_from_buf(response) + + assert pub_key_len == 32 + assert len(response) == 0 + + return pub_key_len, pub_key + +# Unpack from response: +# response = address_len (1) +# address (var) +def unpack_get_address_response(response: bytes) -> Tuple[int, bytes]: + response, address_len, address = pop_size_prefixed_buf_from_buf(response) + + assert address_len == 32 + assert len(response) == 0 + + return address_len, address + +# Unpack from response: +# response = der_sig_len (1) +# der_sig (var) +# v (1) +# TODO: check if this needs to be edited +def unpack_sign_tx_response(response: bytes) -> Tuple[int, bytes, int]: + response, der_sig_len, der_sig = pop_size_prefixed_buf_from_buf(response) + response, v = pop_sized_buf_from_buffer(response, 1) + + assert len(response) == 0 + + return der_sig_len, der_sig, int.from_bytes(v, byteorder='big') diff --git a/tests/application_client/everscale_transaction.py b/tests/application_client/everscale_transaction.py new file mode 100644 index 0000000..5094dfc --- /dev/null +++ b/tests/application_client/everscale_transaction.py @@ -0,0 +1,188 @@ +from application_client.everscale_command_sender import WalletType + +class Transaction: + ADDRESS_LENGTH = 32 + CHAIN_ID_LENGTH = 4 + + FLAG_WITH_WALLET_ID = 0x01 + FLAG_WITH_WORKCHAIN_ID = 0x02 + FLAG_WITH_ADDRESS = 0x04 + FLAG_WITH_CHAIN_ID = 0x08 + + def __init__(self, + decimals: int, + ticker: str, + message: bytes, + current_wallet_type: WalletType, + workchain_id: int | None = None, + prepend_address: bytes | None = None, + chain_id: bytes | None = None) -> None: + """ + Construct a Transaction. + + The metadata byte is deduced as follows: + - FLAG_WITH_WALLET_ID is set if a current_wallet_type is provided and + it differs from the origin_wallet_type. + - FLAG_WITH_WORKCHAIN_ID is set if workchain_id is provided. + - FLAG_WITH_ADDRESS is set if prepend_address is provided. + - FLAG_WITH_CHAIN_ID is set if chain_id is provided. + + If current_wallet_type is None or equals origin_wallet_type, then that field is omitted + and on deserialization the origin_wallet_type will be used. + """ + self.decimals = decimals + self.ticker = ticker + self.message = message + self.workchain_id = workchain_id + self.prepend_address = prepend_address + self.chain_id = chain_id + + # Deduce metadata flags based on optional inputs. + metadata = 0 + if current_wallet_type is not None: + metadata |= self.FLAG_WITH_WALLET_ID + self.current_wallet_type = current_wallet_type + else: + # Do not include the field; on the device, it will be set to origin_wallet_type. + self.current_wallet_type = None + + if workchain_id is not None: + metadata |= self.FLAG_WITH_WORKCHAIN_ID + + if prepend_address is not None: + if len(prepend_address) != self.ADDRESS_LENGTH: + raise ValueError(f"prepend_address must be {self.ADDRESS_LENGTH} bytes") + metadata |= self.FLAG_WITH_ADDRESS + + if chain_id is not None: + if len(chain_id) != self.CHAIN_ID_LENGTH: + raise ValueError(f"chain_id must be {self.CHAIN_ID_LENGTH} bytes") + metadata |= self.FLAG_WITH_CHAIN_ID + + self.metadata = metadata + + # Nice-to-haves: check ticker length constraints. + ticker_len = len(ticker) + if ticker_len == 0 or ticker_len > 10: + raise ValueError("Ticker length must be between 1 and 10 bytes.") + + def serialize(self) -> bytes: + """ + Serialize the transaction into a byte-buffer with the following structure: + + [decimals:1] [ticker_length:1] [ticker:N] [metadata:1] + [optional fields (if flagged)...] [message:remaining bytes] + """ + result = bytearray() + # 1. Decimals (1 byte) + result.append(self.decimals) + + # 2. Ticker: length (1 byte) followed by its ASCII bytes + ticker_bytes = self.ticker.encode("ascii") + ticker_len = len(ticker_bytes) + result.append(ticker_len) + result.extend(ticker_bytes) + + # 3. Metadata (1 byte; deduced from optional parameters) + result.append(self.metadata) + + # 4. Conditionally append optional fields based on metadata flags. + if self.metadata & self.FLAG_WITH_WALLET_ID: + # current_wallet_type is provided and differs from origin_wallet_type. + result.append(self.current_wallet_type) + # Workchain id. + if self.metadata & self.FLAG_WITH_WORKCHAIN_ID: + result.append(self.workchain_id) + # Prepend address. + if self.metadata & self.FLAG_WITH_ADDRESS: + result.extend(self.prepend_address) + # Chain id. + if self.metadata & self.FLAG_WITH_CHAIN_ID: + result.extend(self.chain_id) + + # 5. Append the message (payload). + result.extend(self.message) + + return bytes(result) + + @classmethod + def from_bytes(cls, data: bytes) -> "Transaction": + """ + Deserialize a byte-buffer into a Transaction object. + + The parsing order in the buffer is: + decimals (1 byte) -> + ticker_length (1 byte) -> + ticker -> + metadata (1 byte) -> + [optional fields... based on metadata] -> + message (remaining bytes) + + If the metadata does NOT include FLAG_WITH_WALLET_ID, then current_wallet_type + defaults to origin_wallet_type. + """ + offset = 0 + + # Read decimals (1 byte) + if len(data) < offset + 1: + raise ValueError("Data too short for decimals") + decimals = data[offset] + offset += 1 + + # Read ticker: first its length (1 byte) then the ticker string + if len(data) < offset + 1: + raise ValueError("Data too short for ticker length") + ticker_len = data[offset] + offset += 1 + if len(data) < offset + ticker_len: + raise ValueError("Data too short for ticker") + ticker = data[offset:offset+ticker_len].decode("ascii") + offset += ticker_len + + # Read metadata (1 byte) + if len(data) < offset + 1: + raise ValueError("Data too short for metadata") + metadata = data[offset] + offset += 1 + + # Read optional fields based on metadata flags. + current_wallet_type = None + if metadata & cls.FLAG_WITH_WALLET_ID: + if len(data) < offset + 1: + raise ValueError("Data too short for current_wallet_type") + current_wallet_type = data[offset] + offset += 1 + + workchain_id = None + if metadata & cls.FLAG_WITH_WORKCHAIN_ID: + if len(data) < offset + 1: + raise ValueError("Data too short for workchain_id") + workchain_id = data[offset] + offset += 1 + + prepend_address = None + if metadata & cls.FLAG_WITH_ADDRESS: + if len(data) < offset + cls.ADDRESS_LENGTH: + raise ValueError("Data too short for prepend_address") + prepend_address = data[offset:offset+cls.ADDRESS_LENGTH] + offset += cls.ADDRESS_LENGTH + + chain_id = None + if metadata & cls.FLAG_WITH_CHAIN_ID: + if len(data) < offset + cls.CHAIN_ID_LENGTH: + raise ValueError("Data too short for chain_id") + chain_id = data[offset:offset+cls.CHAIN_ID_LENGTH] + offset += cls.CHAIN_ID_LENGTH + + # The remaining bytes are the message. + message = data[offset:] + + return cls( + decimals=decimals, + ticker=ticker, + message=message, + current_wallet_type=current_wallet_type, + workchain_id=workchain_id, + prepend_address=prepend_address, + chain_id=chain_id + ) \ No newline at end of file diff --git a/tests/application_client/everscale_utils.py b/tests/application_client/everscale_utils.py new file mode 100644 index 0000000..fd96e62 --- /dev/null +++ b/tests/application_client/everscale_utils.py @@ -0,0 +1,61 @@ +from io import BytesIO +from typing import Optional, Literal + + +UINT64_MAX: int = 2**64-1 +UINT32_MAX: int = 2**32-1 +UINT16_MAX: int = 2**16-1 + + +def write_varint(n: int) -> bytes: + if n < 0xFC: + return n.to_bytes(1, byteorder="little") + + if n <= UINT16_MAX: + return b"\xFD" + n.to_bytes(2, byteorder="little") + + if n <= UINT32_MAX: + return b"\xFE" + n.to_bytes(4, byteorder="little") + + if n <= UINT64_MAX: + return b"\xFF" + n.to_bytes(8, byteorder="little") + + raise ValueError(f"Can't write to varint: '{n}'!") + + +def read_varint(buf: BytesIO, + prefix: Optional[bytes] = None) -> int: + b: bytes = prefix if prefix else buf.read(1) + + if not b: + raise ValueError(f"Can't read prefix: '{b.hex()}'!") + + 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 int.from_bytes(b, byteorder="little") + + +def read(buf: BytesIO, size: int) -> bytes: + b: bytes = buf.read(size) + + if len(b) < size: + raise ValueError(f"Can't read {size} bytes in buffer!") + + return b + + +def read_uint(buf: BytesIO, + bit_len: int, + byteorder: Literal['big', 'little'] = 'little') -> int: + size: int = bit_len // 8 + b: bytes = buf.read(size) + + if len(b) < size: + raise ValueError(f"Can't read u{bit_len} in buffer!") + + return int.from_bytes(b, byteorder) diff --git a/tests/application_client/py.typed b/tests/application_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..76b5e0c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +from ragger.conftest import configuration + +########################### +### CONFIGURATION START ### +########################### + +# You can configure optional parameters by overriding the value of ragger.configuration.OPTIONAL_CONFIGURATION +# Please refer to ragger/conftest/configuration.py for their descriptions and accepted values + +# Define pytest markers +def pytest_configure(config): + config.addinivalue_line( + "markers", + "active_test_scope: marks tests related to application name functionality", + ) + # Add more markers here as needed + + +######################### +### CONFIGURATION END ### +######################### + +# Pull all features from the base ragger conftest using the overridden configuration +pytest_plugins = ("ragger.conftest.base_conftest", ) diff --git a/tests/.gitignore b/tests/old_to_delete/.gitignore similarity index 100% rename from tests/.gitignore rename to tests/old_to_delete/.gitignore diff --git a/tests/Cargo.lock b/tests/old_to_delete/Cargo.lock similarity index 100% rename from tests/Cargo.lock rename to tests/old_to_delete/Cargo.lock diff --git a/tests/Cargo.toml b/tests/old_to_delete/Cargo.toml similarity index 100% rename from tests/Cargo.toml rename to tests/old_to_delete/Cargo.toml diff --git a/tests/src/main.rs b/tests/old_to_delete/src/main.rs similarity index 100% rename from tests/src/main.rs rename to tests/old_to_delete/src/main.rs diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..dd07dfb --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +pytest +ragger[speculos,ledgerwallet]>=1.11.4 +ecdsa>=0.16.1,<0.17.0 +pycryptodome diff --git a/tests/setup.cfg b/tests/setup.cfg new file mode 100644 index 0000000..2d024b3 --- /dev/null +++ b/tests/setup.cfg @@ -0,0 +1,23 @@ +[tool:pytest] +addopts = --strict-markers -m active_test_scope + +[pylint] +disable = C0114, # missing-module-docstring + C0115, # missing-class-docstring + C0116, # missing-function-docstring + C0103, # invalid-name + R0801, # duplicate-code + R0913, # too-many-arguments + R0917 # too-many-positional-arguments + +max-line-length=120 +extension-pkg-whitelist=hid + +[pycodestyle] +max-line-length = 100 + +[mypy-hid.*] +ignore_missing_imports = True + +[mypy-pytest.*] +ignore_missing_imports = True diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00000.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00000.png new file mode 100644 index 0000000..a487005 Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00000.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00001.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00001.png new file mode 100644 index 0000000..b0fb95c Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00001.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00002.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00002.png new file mode 100644 index 0000000..79ec8e7 Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00002.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00003.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00003.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00003.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00004.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00004.png new file mode 100644 index 0000000..5fd127e Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00004.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00005.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00005.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_accepted/00005.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00000.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00000.png new file mode 100644 index 0000000..a487005 Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00000.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00001.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00001.png new file mode 100644 index 0000000..b0fb95c Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00001.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00002.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00002.png new file mode 100644 index 0000000..79ec8e7 Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00002.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00003.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00003.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00003.png differ diff --git a/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00004.png b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00004.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanosp/test_get_address_wallet_v3_confirm_refused/00004.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00000.png b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00000.png new file mode 100644 index 0000000..a63f85f Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00000.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00001.png b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00001.png new file mode 100644 index 0000000..9048fa4 Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00001.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00002.png b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00002.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00002.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00003.png b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00003.png new file mode 100644 index 0000000..5fd127e Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00003.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00004.png b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00004.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_accepted/00004.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00000.png b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00000.png new file mode 100644 index 0000000..a63f85f Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00000.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00001.png b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00001.png new file mode 100644 index 0000000..9048fa4 Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00001.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00002.png b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00002.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00002.png differ diff --git a/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00003.png b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00003.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanosp/test_get_public_key_confirm_refused/00003.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00000.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00000.png new file mode 100644 index 0000000..a487005 Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00000.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00001.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00001.png new file mode 100644 index 0000000..b0fb95c Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00001.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00002.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00002.png new file mode 100644 index 0000000..79ec8e7 Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00002.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00003.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00003.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00003.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00004.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00004.png new file mode 100644 index 0000000..5fd127e Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00004.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00005.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00005.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_accepted/00005.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00000.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00000.png new file mode 100644 index 0000000..a487005 Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00000.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00001.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00001.png new file mode 100644 index 0000000..b0fb95c Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00001.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00002.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00002.png new file mode 100644 index 0000000..79ec8e7 Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00002.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00003.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00003.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00003.png differ diff --git a/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00004.png b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00004.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanox/test_get_address_wallet_v3_confirm_refused/00004.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00000.png b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00000.png new file mode 100644 index 0000000..a63f85f Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00000.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00001.png b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00001.png new file mode 100644 index 0000000..9048fa4 Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00001.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00002.png b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00002.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00002.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00003.png b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00003.png new file mode 100644 index 0000000..5fd127e Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00003.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00004.png b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00004.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_accepted/00004.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_refused/00000.png b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00000.png new file mode 100644 index 0000000..a63f85f Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00000.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_refused/00001.png b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00001.png new file mode 100644 index 0000000..9048fa4 Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00001.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_refused/00002.png b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00002.png new file mode 100644 index 0000000..78530ed Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00002.png differ diff --git a/tests/snapshots/nanox/test_get_public_key_confirm_refused/00003.png b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00003.png new file mode 100644 index 0000000..a58590b Binary files /dev/null and b/tests/snapshots/nanox/test_get_public_key_confirm_refused/00003.png differ diff --git a/tests/test_address_cmd.py b/tests/test_address_cmd.py new file mode 100644 index 0000000..8bb9362 --- /dev/null +++ b/tests/test_address_cmd.py @@ -0,0 +1,64 @@ +import pytest + +from ragger.bip import calculate_public_key_and_chaincode, CurveChoice +from ragger.error import ExceptionRAPDU +from ragger.backend.interface import BackendInterface +from ragger.navigator.navigation_scenario import NavigateWithScenario + +from application_client.everscale_command_sender import EverscaleCommandSender, Errors, WalletType +from application_client.everscale_response_unpacker import unpack_get_address_response + +HARDENED_OFFSET = 0x80000000 +PATH_PREFIX = "44'/396'/" +PATH_SUFFIX = "'/0'/0'" + # 9 types of wallets +EXPECTED_ADDRESSES = [ + '7571b498e3fed7a0fffbe21377e50548c92da4a04842e1b163547d3e8980cf64', '522a4cc774797f537c41b4853a9b83f359fe2802a5cf3bd9f31240c495e82358', 'ae990783e06a196ab03029fe4517dda0ea318c091cd49ff51cc0a40369b0aff5', '98135fb68e91833e810122abfe00ff3b38c1d555bf773741f869dea8b87fb72d', '23e76dee54e3f715e11cf374177e786878814ad2c7ac6e38bc06515efdb5fab3', '0806dbe6c4581af1165879fd196d3e02404029540e818921edfedbffb46d3c65', 'ec03eb7af13d70083b9f3c8202b0321ede255edc624292c531106f50d9f005b3', '9760a1b7393cdbb389205f72ebf4d7e362b06b419fdac9aeccd83bf39ce0d05a', '1de569744cf341d8e2e35996f23cdf5d5f9226c1400c98100f480d89e70c6bcf' +] + +# In this test we check that the GET_ADDRESS works in non-confirmation mode +@pytest.mark.active_test_scope +def test_get_address_all_types_no_confirm(backend: BackendInterface) -> None: + temp = [] + for wallet_type in range(9): + client = EverscaleCommandSender(backend) + response = client.get_address(account_number=0, wallet_type=wallet_type).data + _, address = unpack_get_address_response(response) + + assert address.hex() == EXPECTED_ADDRESSES[wallet_type], f"Error with wallet_type: {wallet_type}, expected: {EXPECTED_ADDRESSES[wallet_type]}, but got {address.hex()}" + + +# In this test we check that the GET_ADDRESS works in confirmation mode +@pytest.mark.active_test_scope +def test_get_address_wallet_v3_confirm_accepted(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: + client = EverscaleCommandSender(backend) + account_number = 0 + wallet_type = WalletType.WALLET_V3 + with client.get_address_with_confirmation(account_number=account_number, wallet_type=wallet_type): + scenario_navigator.address_review_approve() + + response = client.get_async_response().data + _, address = unpack_get_address_response(response) + + assert address.hex() == EXPECTED_ADDRESSES[wallet_type], f"Error with wallet_type: {wallet_type}, expected: {EXPECTED_ADDRESSES[wallet_type]}, but got {address.hex()}" + + +# In this test we check that the GET_ADDRESS in confirmation mode replies an error if the user refuses +@pytest.mark.active_test_scope +def test_get_address_wallet_v3_confirm_refused(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: + client = EverscaleCommandSender(backend) + account_number = 0 + wallet_type = WalletType.WALLET_V3 + + with pytest.raises(ExceptionRAPDU) as e: + with client.get_address_with_confirmation(account_number=account_number, wallet_type=wallet_type): + scenario_navigator.address_review_reject() + + # Assert that we have received a refusal + assert e.value.status == Errors.SW_DENY + assert len(e.value.data) == 0 + + + + + diff --git a/tests/test_app_config_cmd.py b/tests/test_app_config_cmd.py new file mode 100644 index 0000000..b272b86 --- /dev/null +++ b/tests/test_app_config_cmd.py @@ -0,0 +1,18 @@ +import pytest +from ragger.backend.interface import BackendInterface + +from application_client.everscale_command_sender import EverscaleCommandSender +from application_client.everscale_response_unpacker import unpack_get_version_response + +from utils import verify_version + +# In this test we check the behavior of the device when asked to provide the app version +@pytest.mark.active_test_scope +def test_get_app_config(backend: BackendInterface) -> None: + # Use the app interface instead of raw interface + client = EverscaleCommandSender(backend) + # Send the GET_VERSION instruction + rapdu = client.get_app_config() + # Use an helper to parse the response, assert the values + MAJOR, MINOR, PATCH = unpack_get_version_response(rapdu.data) + verify_version(f"{MAJOR}.{MINOR}.{PATCH}") diff --git a/tests/test_app_mainmenu.py b/tests/test_app_mainmenu.py new file mode 100644 index 0000000..d5ee01f --- /dev/null +++ b/tests/test_app_mainmenu.py @@ -0,0 +1,54 @@ +from ragger.firmware import Firmware +from ragger.navigator import Navigator, NavInsID, NavIns + + +# In this test we check the behavior of the device main menu +def test_app_mainmenu(firmware: Firmware, + navigator: Navigator, + test_name: str, + default_screenshot_path: str) -> None: + # Navigate in the main menu + instructions = [] + if firmware.is_nano: + instructions += [ + NavInsID.RIGHT_CLICK, + NavInsID.BOTH_CLICK, + NavInsID.BOTH_CLICK, + NavInsID.RIGHT_CLICK, + NavInsID.BOTH_CLICK, + NavInsID.RIGHT_CLICK, + NavInsID.RIGHT_CLICK, + NavInsID.BOTH_CLICK, + NavInsID.RIGHT_CLICK, + NavInsID.BOTH_CLICK, + NavInsID.RIGHT_CLICK, + NavInsID.BOTH_CLICK, + NavInsID.RIGHT_CLICK, + NavInsID.RIGHT_CLICK, + NavInsID.BOTH_CLICK, + NavInsID.RIGHT_CLICK, + ] + elif firmware is Firmware.STAX: + instructions += [ + NavInsID.USE_CASE_HOME_SETTINGS, + NavIns(NavInsID.TOUCH, (200, 113)), + NavIns(NavInsID.TOUCH, (200, 261)), + NavInsID.USE_CASE_CHOICE_CONFIRM, + NavIns(NavInsID.TOUCH, (200, 261)), + NavInsID.USE_CASE_SETTINGS_NEXT, + NavInsID.USE_CASE_SETTINGS_MULTI_PAGE_EXIT + ] + elif firmware is Firmware.FLEX: + instructions += [ + NavInsID.USE_CASE_HOME_SETTINGS, + NavIns(NavInsID.TOUCH, (200, 113)), + NavIns(NavInsID.TOUCH, (200, 300)), + NavInsID.USE_CASE_CHOICE_CONFIRM, + NavIns(NavInsID.TOUCH, (200, 300)), + NavInsID.USE_CASE_SETTINGS_NEXT, + NavInsID.USE_CASE_SETTINGS_MULTI_PAGE_EXIT + ] + + assert len(instructions) > 0 + navigator.navigate_and_compare(default_screenshot_path, test_name, instructions, + screen_change_before_first_instruction=False) diff --git a/tests/test_appname_cmd.py b/tests/test_appname_cmd.py new file mode 100644 index 0000000..277c58a --- /dev/null +++ b/tests/test_appname_cmd.py @@ -0,0 +1,16 @@ +from ragger.backend.interface import BackendInterface + +from application_client.everscale_command_sender import EverscaleCommandSender +from application_client.everscale_response_unpacker import unpack_get_app_name_response + +from utils import verify_name + + +# In this test we check that the GET_APP_NAME replies the application name +def test_app_name(backend: BackendInterface) -> None: + # Use the app interface instead of raw interface + client = EverscaleCommandSender(backend) + # Send the GET_APP_NAME instruction to the app + response = client.get_app_name() + # Assert that we have received the correct appname + verify_name(unpack_get_app_name_response(response.data)) diff --git a/tests/test_error_cmd.py b/tests/test_error_cmd.py new file mode 100644 index 0000000..12e3166 --- /dev/null +++ b/tests/test_error_cmd.py @@ -0,0 +1,59 @@ +import pytest + +from ragger.error import ExceptionRAPDU +from ragger.backend.interface import BackendInterface + +from application_client.everscale_command_sender import CLA, InsType, P1, P2, Errors + + +# Ensure the app returns an error when a bad CLA is used +def test_bad_cla(backend: BackendInterface) -> None: + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA + 1, ins=InsType.GET_VERSION) + assert e.value.status == Errors.SW_CLA_NOT_SUPPORTED + + +# Ensure the app returns an error when a bad INS is used +def test_bad_ins(backend: BackendInterface) -> None: + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=0xff) + assert e.value.status == Errors.SW_INS_NOT_SUPPORTED + + +# Ensure the app returns an error when a bad P1 or P2 is used +def test_wrong_p1p2(backend: BackendInterface) -> None: + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=InsType.GET_VERSION, p1=P1.P1_START + 1, p2=P2.P2_LAST) + assert e.value.status == Errors.SW_WRONG_P1P2 + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=InsType.GET_VERSION, p1=P1.P1_START, p2=P2.P2_MORE) + assert e.value.status == Errors.SW_WRONG_P1P2 + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=InsType.GET_APP_NAME, p1=P1.P1_START + 1, p2=P2.P2_LAST) + assert e.value.status == Errors.SW_WRONG_P1P2 + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, ins=InsType.GET_APP_NAME, p1=P1.P1_START, p2=P2.P2_MORE) + assert e.value.status == Errors.SW_WRONG_P1P2 + + +# Ensure the app returns an error when a bad data length is used +def test_wrong_data_length(backend: BackendInterface) -> None: + # APDUs must be at least 4 bytes: CLA, INS, P1, P2. + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange_raw(bytes.fromhex("E00300")) + assert e.value.status == Errors.SW_WRONG_DATA_LENGTH + # APDUs advertises a too long length + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange_raw(bytes.fromhex("E003000005")) + assert e.value.status == Errors.SW_WRONG_DATA_LENGTH + + +# Ensure there is no state confusion when trying wrong APDU sequences +def test_invalid_state(backend: BackendInterface) -> None: + with pytest.raises(ExceptionRAPDU) as e: + backend.exchange(cla=CLA, + ins=InsType.SIGN_TX, + p1=P1.P1_START + 1, # Try to continue a flow instead of start a new one + p2=P2.P2_MORE, + data=b"abcde") # data is not parsed in this case + assert e.value.status == Errors.SW_BAD_STATE diff --git a/tests/test_name_version.py b/tests/test_name_version.py new file mode 100644 index 0000000..ce3e067 --- /dev/null +++ b/tests/test_name_version.py @@ -0,0 +1,18 @@ +from ragger.backend.interface import BackendInterface + +from application_client.everscale_command_sender import EverscaleCommandSender +from application_client.everscale_response_unpacker import unpack_get_app_and_version_response + +from utils import verify_version, verify_name + +# Test a specific APDU asking BOLOS (and not the app) the name and version of the current app +def test_get_app_and_version(backend: BackendInterface) -> None: + # Use the app interface instead of raw interface + client = EverscaleCommandSender(backend) + # Send the special instruction to BOLOS + response = client.get_app_and_version() + # Use an helper to parse the response, assert the values + app_name, version = unpack_get_app_and_version_response(response.data) + + verify_name(app_name) + verify_version(version) diff --git a/tests/test_pubkey_cmd.py b/tests/test_pubkey_cmd.py new file mode 100644 index 0000000..e5f6fec --- /dev/null +++ b/tests/test_pubkey_cmd.py @@ -0,0 +1,61 @@ +import pytest + +from ragger.bip import calculate_public_key_and_chaincode, CurveChoice +from ragger.error import ExceptionRAPDU +from ragger.backend.interface import BackendInterface +from ragger.navigator.navigation_scenario import NavigateWithScenario + +from application_client.everscale_command_sender import EverscaleCommandSender, Errors +from application_client.everscale_response_unpacker import unpack_get_public_key_response + +HARDENED_OFFSET = 0x80000000 +PATH_PREFIX = "44'/396'/" +PATH_SUFFIX = "'/0'/0'" + +# In this test we check that the GET_PUBLIC_KEY works in non-confirmation mode +@pytest.mark.active_test_scope +def test_get_public_key_no_confirm(backend: BackendInterface) -> None: + account_number_list = [ + 0, + 1, + 911, + 255, + 2147483647 + ] + for account_number in account_number_list: + client = EverscaleCommandSender(backend) + response = client.get_public_key(account_number=account_number).data + _, public_key = unpack_get_public_key_response(response) + + ref_public_key, _ = calculate_public_key_and_chaincode(CurveChoice.Ed25519Slip, path=PATH_PREFIX + str(account_number | HARDENED_OFFSET) + PATH_SUFFIX) + assert "00" + public_key.hex() == ref_public_key + + +# In this test we check that the GET_PUBLIC_KEY works in confirmation mode +@pytest.mark.active_test_scope +def test_get_public_key_confirm_accepted(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: + client = EverscaleCommandSender(backend) + account_number = 0 + with client.get_public_key_with_confirmation(account_number=account_number): + scenario_navigator.address_review_approve() + + response = client.get_async_response().data + _, public_key = unpack_get_public_key_response(response) + + ref_public_key, _ = calculate_public_key_and_chaincode(CurveChoice.Ed25519Slip, path=PATH_PREFIX + str(account_number | HARDENED_OFFSET) + PATH_SUFFIX) + assert "00" + public_key.hex() == ref_public_key + + +# In this test we check that the GET_PUBLIC_KEY in confirmation mode replies an error if the user refuses +@pytest.mark.active_test_scope +def test_get_public_key_confirm_refused(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: + client = EverscaleCommandSender(backend) + account_number = 0 + + with pytest.raises(ExceptionRAPDU) as e: + with client.get_public_key_with_confirmation(account_number=account_number): + scenario_navigator.address_review_reject() + + # Assert that we have received a refusal + assert e.value.status == Errors.SW_DENY + assert len(e.value.data) == 0 diff --git a/tests/test_sign_cmd.py b/tests/test_sign_cmd.py new file mode 100644 index 0000000..594cd62 --- /dev/null +++ b/tests/test_sign_cmd.py @@ -0,0 +1,140 @@ +import pytest + +from ragger.backend.interface import BackendInterface +from ragger.error import ExceptionRAPDU +from ragger.firmware import Firmware +from ragger.navigator import Navigator, NavInsID +from ragger.navigator.navigation_scenario import NavigateWithScenario + +from application_client.everscale_command_sender import EverscaleCommandSender, Errors, WalletType +from application_client.everscale_response_unpacker import unpack_get_public_key_response, unpack_sign_tx_response +from utils import check_signature_validity + +# In this tests we check the behavior of the device when asked to sign a transaction + + +# In this test we send to the device a transaction to sign and validate it on screen +# The transaction is short and will be sent in one chunk +# We will ensure that the displayed information is correct by using screenshots comparison + +# TODO: Add a valid raw transaction and a valid expected signature +def test_sign_tx_short_tx(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: + # Use the app interface instead of raw interface + client = EverscaleCommandSender(backend) + account_number = 0 + wallet_type = WalletType.WALLET_V3 + # First we need to get the public key of the device in order to build the transaction + rapdu = client.get_public_key(account_number=account_number) + _, public_key, _, _ = unpack_get_public_key_response(rapdu.data) + + # Raw transaction + transaction = bytes.fromhex("0000000000000000000000000000000000000000000000000000000000000000") + + # Send the sign device instruction. + # As it requires on-screen validation, the function is asynchronous. + # It will yield the result when the navigation is done + with client.sign_tx(account_number=account_number, wallet_type=wallet_type, transaction=transaction): + # Validate the on-screen request by performing the navigation appropriate for this device + scenario_navigator.review_approve() + + # The device as yielded the result, parse it and ensure that the signature is correct + response = client.get_async_response().data + _, der_sig, _ = unpack_sign_tx_response(response) + assert der_sig.hex() == "0000000000000000000000000000000000000000000000000000000000000000" + + +# # In this test we send to the device a transaction to trig a blind-signing flow +# # The transaction is short and will be sent in one chunk +# # We will ensure that the displayed information is correct by using screenshots comparison +# def test_sign_tx_short_tx_blind_sign(firmware: Firmware, +# backend: BackendInterface, +# navigator: Navigator, +# scenario_navigator: NavigateWithScenario, +# test_name: str, +# default_screenshot_path: str) -> None: +# # Use the app interface instead of raw interface +# client = EverscaleCommandSender(backend) +# # The path used for this entire test +# path: str = "m/44'/1'/0'/0/0" + +# # First we need to get the public key of the device in order to build the transaction +# rapdu = client.get_public_key(path=path) +# _, public_key, _, _ = unpack_get_public_key_response(rapdu.data) + +# # Create the transaction that will be sent to the device for signing +# transaction = Transaction( +# nonce=1, +# to="0x0000000000000000000000000000000000000000", +# value=0, +# memo="Blind-sign" +# ).serialize() + +# # Send the sign device instruction. +# valid_instruction = [NavInsID.RIGHT_CLICK] if firmware.is_nano else [NavInsID.USE_CASE_CHOICE_REJECT] +# # As it requires on-screen validation, the function is asynchronous. +# # It will yield the result when the navigation is done +# with client.sign_tx(path=path, transaction=transaction): +# navigator.navigate_and_compare(default_screenshot_path, +# test_name+"/part1", +# valid_instruction, +# screen_change_after_last_instruction=False) + +# # Validate the on-screen request by performing the navigation appropriate for this device +# scenario_navigator.review_approve() + +# # The device as yielded the result, parse it and ensure that the signature is correct +# response = client.get_async_response().data +# _, der_sig, _ = unpack_sign_tx_response(response) +# assert check_signature_validity(public_key, der_sig, transaction) + +# # In this test se send to the device a transaction to sign and validate it on screen +# # This test is mostly the same as the previous one but with different values. +# # In particular the long memo will force the transaction to be sent in multiple chunks +# def test_sign_tx_long_tx(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: +# # Use the app interface instead of raw interface +# client = EverscaleCommandSender(backend) +# path: str = "m/44'/1'/0'/0/0" + +# rapdu = client.get_public_key(path=path) +# _, public_key, _, _ = unpack_get_public_key_response(rapdu.data) + +# transaction = Transaction( +# nonce=1, +# to="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", +# value=666, +# memo=("This is a very long memo. " +# "It will force the app client to send the serialized transaction to be sent in chunk. " +# "As the maximum chunk size is 255 bytes we will make this memo greater than 255 characters. " +# "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, " +# "dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam.") +# ).serialize() + +# with client.sign_tx(path=path, transaction=transaction): +# scenario_navigator.review_approve() + +# response = client.get_async_response().data +# _, der_sig, _ = unpack_sign_tx_response(response) +# assert check_signature_validity(public_key, der_sig, transaction) + + +# # Transaction signature refused test +# # The test will ask for a transaction signature that will be refused on screen +# def test_sign_tx_refused(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: +# # Use the app interface instead of raw interface +# client = EverscaleCommandSender(backend) +# path: str = "m/44'/1'/0'/0/0" + +# transaction = Transaction( +# nonce=1, +# to="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", +# value=666, +# memo="This transaction will be refused by the user" +# ).serialize() + +# with pytest.raises(ExceptionRAPDU) as e: +# with client.sign_tx(path=path, transaction=transaction): +# scenario_navigator.review_reject() + +# # Assert that we have received a refusal +# assert e.value.status == Errors.SW_DENY +# assert len(e.value.data) == 0 diff --git a/tests/test_sign_message_cmd.py b/tests/test_sign_message_cmd.py new file mode 100644 index 0000000..9bcd3ac --- /dev/null +++ b/tests/test_sign_message_cmd.py @@ -0,0 +1,43 @@ +import pytest + +from ragger.backend.interface import BackendInterface +from ragger.error import ExceptionRAPDU +from ragger.firmware import Firmware +from ragger.navigator import Navigator, NavInsID +from ragger.navigator.navigation_scenario import NavigateWithScenario + +from application_client.everscale_command_sender import EverscaleCommandSender, Errors, WalletType +from application_client.everscale_response_unpacker import unpack_get_public_key_response, unpack_sign_tx_response +from utils import check_signature_validity + +# In this tests we check the behavior of the device when asked to sign a transaction + + +# In this test we send to the device a transaction to sign and validate it on screen +# The transaction is short and will be sent in one chunk +# We will ensure that the displayed information is correct by using screenshots comparison + +# TODO: Add a valid raw message and a valid expected signature +def test_sign_message(backend: BackendInterface, scenario_navigator: NavigateWithScenario) -> None: + # Use the app interface instead of raw interface + client = EverscaleCommandSender(backend) + account_number = 0 + wallet_type = WalletType.WALLET_V3 + # First we need to get the public key of the device in order to build the transaction + rapdu = client.get_public_key(account_number=account_number) + _, public_key, _, _ = unpack_get_public_key_response(rapdu.data) + + # Message to sign + message = b"Hello, world!" + + # Send the sign device instruction. + # As it requires on-screen validation, the function is asynchronous. + # It will yield the result when the navigation is done + with client.sign_message(account_number=account_number, wallet_type=wallet_type, message=message): + # Validate the on-screen request by performing the navigation appropriate for this device + scenario_navigator.review_approve() + + # The device as yielded the result, parse it and ensure that the signature is correct + response = client.get_async_response().data + _, der_sig, _ = unpack_sign_tx_response(response) + assert der_sig.hex() == "0000000000000000000000000000000000000000000000000000000000000000" \ No newline at end of file diff --git a/tests/usage.md b/tests/usage.md new file mode 100644 index 0000000..dc2fbfa --- /dev/null +++ b/tests/usage.md @@ -0,0 +1,78 @@ +# How to use the Ragger test framework + +This framework allows testing the application on the Speculos emulator or on a real device using LedgerComm or LedgerWallet + +## Quickly get started with Ragger and Speculos + +### Install ragger and dependencies + +```shell +pip install --extra-index-url https://test.pypi.org/simple/ -r requirements.txt +sudo apt-get update && sudo apt-get install qemu-user-static +``` + +### Compile the application + +The application to test must be compiled for all required devices. +You can use for this the container `ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite`: + +```shell +docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite:latest +cd # replace with the name of your app, (eg boilerplate) +docker run --user "$(id -u)":"$(id -g)" --rm -ti -v "$(realpath .):/app" --privileged -v "/dev/bus/usb:/dev/bus/usb" ledger-app-builder-lite:latest +make clean && make BOLOS_SDK=$_SDK # replace with one of [NANOX, NANOSP, STAX, FLEX] +exit +``` + +### Run a simple test using the Speculos emulator + +You can use the following command to get your first experience with Ragger and Speculos + +```shell +pytest -v --tb=short --device nanox --display +``` + +Or you can refer to the section `Available pytest options` to configure the options you want to use + +### Run a simple test using a real device + +The application to test must be loaded and started on a Ledger device plugged in USB. +You can use for this the container `ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite`: + +```shell +docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite:latest +cd app-/ # replace with the name of your app, (eg boilerplate) +docker run --user "$(id -u)":"$(id -g)" --rm -ti -v "$(realpath .):/app" --privileged -v "/dev/bus/usb:/dev/bus/usb" ledger-app-builder-lite:latest +make clean && make BOLOS_SDK=$_SDK load # replace with one of [NANOX, NANOSP, STAX, FLEX] +exit +``` + +You can use the following command to get your first experience with Ragger and Ledgerwallet on a NANOX. +Make sure that the device is plugged, unlocked, and that the tested application is open. + +```shell +pytest -v --tb=short --device nanox --backend ledgerwallet +``` + +Or you can refer to the section `Available pytest options` to configure the options you want to use + +## Available pytest options + +Standard useful pytest options + +```shell + -v formats the test summary in a readable way + -s enable logs for successful tests, on Speculos it will enable app logs if compiled with DEBUG=1 + -k only run the tests that contain in their names + --tb=short in case of errors, formats the test traceback in a readable way +``` + +Custom pytest options + +```shell + --device run the test on the specified device [nanox,nanosp,stax,flex,all]. This parameter is mandatory + --backend run the tests against the backend [speculos, ledgercomm, ledgerwallet]. Speculos is the default + --display on Speculos, enables the display of the app screen using QT + --golden_run on Speculos, screen comparison functions will save the current screen instead of comparing + --log_apdu_file log all apdu exchanges to the file in parameter. The previous file content is erased +``` diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..b342855 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,76 @@ +from pathlib import Path +from typing import List +import re +from Crypto.Hash import keccak + +from ecdsa.curves import SECP256k1 +from ecdsa.keys import VerifyingKey +from ecdsa.util import sigdecode_der + + +# Check if a signature of a given message is valid +def check_signature_validity(public_key: bytes, signature: bytes, message: bytes) -> bool: + pk: VerifyingKey = VerifyingKey.from_string( + public_key, + curve=SECP256k1, + hashfunc=None + ) + # Compute message hash (keccak_256) + k = keccak.new(digest_bits=256) + k.update(message) + message_hash = k.digest() + + return pk.verify_digest(signature=signature, + digest=message_hash, + sigdecode=sigdecode_der) + + +def verify_name(name: str) -> None: + """Verify the app name, based on defines in Makefile + + Args: + name (str): Name to be checked + """ + + name_str = "" + lines = _read_makefile() + name_re = re.compile(r"^APPNAME\s?=\s?\"?(?P\w+)\"?", re.I) + for line in lines: + info = name_re.match(line) + if info: + dinfo = info.groupdict() + name_str = dinfo["val"] + assert name == name_str + + +def verify_version(version: str) -> None: + """Verify the app version, based on defines in Makefile + + Args: + Version (str): Version to be checked + """ + + vers_dict = {} + vers_str = "" + lines = _read_makefile() + version_re = re.compile(r"^APPVERSION_(?P\w)\s?=\s?(?P\d*)", re.I) + for line in lines: + info = version_re.match(line) + if info: + dinfo = info.groupdict() + vers_dict[dinfo["part"]] = dinfo["val"] + try: + vers_str = f"{vers_dict['M']}.{vers_dict['N']}.{vers_dict['P']}" + except KeyError: + pass + assert version == vers_str + + +def _read_makefile() -> List[str]: + """Read lines from the parent Makefile """ + + parent = Path(__file__).parent.parent.resolve() + makefile = f"{parent}/Makefile" + with open(makefile, "r", encoding="utf-8") as f_p: + lines = f_p.readlines() + return lines