├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── utils │ │ ├── __init__.py │ │ ├── test_structs.py │ │ ├── test_path.py │ │ ├── test_packing.py │ │ └── test_misc.py │ ├── backend │ │ ├── __init__.py │ │ ├── test_stub.py │ │ ├── test_interface.py │ │ ├── test_ledgercomm.py │ │ ├── test_ledgerwallet.py │ │ ├── test_physical_backend.py │ │ └── test_speculos.py │ ├── conftests │ │ ├── __init__.py │ │ └── test_base_conftest.py │ ├── navigator │ │ ├── __init__.py │ │ ├── test_nano_navigator.py │ │ ├── test_touch_navigator.py │ │ └── test_navigation_scenario.py │ ├── helpers.py │ ├── test_error_ApplicationError.py │ ├── firmware │ │ ├── test_structs_Firmware.py │ │ └── touch │ │ │ ├── test_element.py │ │ │ ├── test_screen_MetaScreen.py │ │ │ └── test_screen_FullScreen.py │ └── bip │ │ └── test_path.py ├── functional │ ├── __init__.py │ ├── backend │ │ ├── __init__.py │ │ └── test_speculos.py │ ├── navigator │ │ └── __init__.py │ ├── conftest.py │ └── test_boilerplate.py ├── pytest.ini ├── snapshots │ ├── nanos │ │ ├── generic │ │ │ ├── 00000.png │ │ │ ├── 00001.png │ │ │ ├── 00002.png │ │ │ ├── 00003.png │ │ │ └── 00004.png │ │ ├── test_navigate_and_compare │ │ │ ├── 00000.png │ │ │ ├── 00001.png │ │ │ ├── 00002.png │ │ │ ├── 00003.png │ │ │ ├── 00004.png │ │ │ ├── 00005.png │ │ │ ├── 00006.png │ │ │ └── 00007.png │ │ ├── test_navigate_and_compare_no_golden │ │ │ └── 00000.png │ │ ├── test_navigate_until_text_and_compare │ │ │ ├── 00000.png │ │ │ ├── 00001.png │ │ │ ├── 00002.png │ │ │ └── 00003.png │ │ └── test_navigate_and_compare_wrong_golden │ │ │ ├── 00000.png │ │ │ └── 00001.png │ ├── flex │ │ └── waiting_screen │ │ │ ├── 00000.png │ │ │ ├── 00001.png │ │ │ ├── 00002.png │ │ │ ├── 00003.png │ │ │ └── 00004.png │ ├── stax │ │ └── waiting_screen │ │ │ ├── 00000.png │ │ │ ├── 00001.png │ │ │ ├── 00002.png │ │ │ ├── 00003.png │ │ │ └── 00004.png │ └── apex_p │ │ └── waiting_screen │ │ ├── 00000.png │ │ ├── 00001.png │ │ ├── 00002.png │ │ ├── 00003.png │ │ └── 00004.png └── stubs.py ├── src └── ragger │ ├── py.typed │ ├── conftest │ ├── __init__.py │ └── configuration.py │ ├── gui │ ├── assets │ │ ├── nanos_body.png │ │ ├── nanox_body.png │ │ ├── stax_body.png │ │ ├── nanosp_body.png │ │ ├── touch_action.png │ │ ├── nanos_leftbutton.png │ │ ├── nanos_rightbutton.png │ │ ├── nanosp_leftbutton.png │ │ ├── nanox_leftbutton.png │ │ ├── nanox_rightbutton.png │ │ ├── swipe_left_action.png │ │ ├── nanosp_rightbutton.png │ │ └── swipe_right_action.png │ ├── __init__.py │ └── process.py │ ├── firmware │ ├── touch │ │ ├── __init__.py │ │ ├── element.py │ │ ├── layouts.py │ │ └── use_cases.py │ ├── __init__.py │ └── structs.py │ ├── __init__.py │ ├── utils │ ├── packing.py │ ├── __init__.py │ └── structs.py │ ├── bip │ ├── __init__.py │ ├── path.py │ └── seed.py │ ├── navigator │ ├── __init__.py │ ├── nano_navigator.py │ ├── instruction.py │ └── touch_navigator.py │ ├── backend │ ├── __init__.py │ ├── stub.py │ ├── ledgerwallet.py │ ├── ledgercomm.py │ └── physical_backend.py │ ├── logger.py │ └── error.py ├── .gitattributes ├── MANIFEST.in ├── .codeql-config.yaml ├── doc ├── images │ ├── ragger.png │ ├── stax_infos.png │ ├── stax_welcome.png │ ├── navigate.draw │ └── usage.draw ├── _static │ └── layout.css ├── _templates │ └── layout.html ├── architecture.rst ├── tutorial.rst ├── tutorial_installation.rst ├── index.rst ├── Makefile ├── installation.rst ├── source.rst ├── faq.rst ├── conf.py ├── glossary.rst └── rationale.rst ├── template ├── .dispatch_to_your_test_folder ├── conftest.py └── usage.md ├── ledger_app.toml ├── .gitignore ├── .github └── workflows │ ├── force-rebase.yml │ ├── documentation.yml │ ├── fast-checks.yml │ ├── codeql-analysis.yml │ └── build_and_tests.yml └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ragger/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ragger/conftest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/backend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/conftests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/navigator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/backend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/navigator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | CHANGELOG.md text merge=union 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/ragger/gui/assets/*.png 2 | -------------------------------------------------------------------------------- /.codeql-config.yaml: -------------------------------------------------------------------------------- 1 | paths: 2 | - src 3 | - doc 4 | -------------------------------------------------------------------------------- /doc/images/ragger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/doc/images/ragger.png -------------------------------------------------------------------------------- /doc/images/stax_infos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/doc/images/stax_infos.png -------------------------------------------------------------------------------- /template/.dispatch_to_your_test_folder: -------------------------------------------------------------------------------- 1 | Dispatch all files from this directory to your test folder 2 | -------------------------------------------------------------------------------- /doc/images/stax_welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/doc/images/stax_welcome.png -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | use_on_backend: skip test if not on the specified backend 4 | -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanos_body.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanos_body.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanox_body.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanox_body.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/stax_body.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/stax_body.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanosp_body.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanosp_body.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/touch_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/touch_action.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/generic/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00000.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/generic/00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00001.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/generic/00002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00002.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/generic/00003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00003.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/generic/00004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/generic/00004.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanos_leftbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanos_leftbutton.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanos_rightbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanos_rightbutton.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanosp_leftbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanosp_leftbutton.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanox_leftbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanox_leftbutton.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanox_rightbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanox_rightbutton.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/swipe_left_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/swipe_left_action.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/nanosp_rightbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/nanosp_rightbutton.png -------------------------------------------------------------------------------- /src/ragger/gui/assets/swipe_right_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/src/ragger/gui/assets/swipe_right_action.png -------------------------------------------------------------------------------- /tests/snapshots/flex/waiting_screen/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00000.png -------------------------------------------------------------------------------- /tests/snapshots/flex/waiting_screen/00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00001.png -------------------------------------------------------------------------------- /tests/snapshots/flex/waiting_screen/00002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00002.png -------------------------------------------------------------------------------- /tests/snapshots/flex/waiting_screen/00003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00003.png -------------------------------------------------------------------------------- /tests/snapshots/flex/waiting_screen/00004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/flex/waiting_screen/00004.png -------------------------------------------------------------------------------- /tests/snapshots/stax/waiting_screen/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00000.png -------------------------------------------------------------------------------- /tests/snapshots/stax/waiting_screen/00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00001.png -------------------------------------------------------------------------------- /tests/snapshots/stax/waiting_screen/00002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00002.png -------------------------------------------------------------------------------- /tests/snapshots/stax/waiting_screen/00003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00003.png -------------------------------------------------------------------------------- /tests/snapshots/stax/waiting_screen/00004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/stax/waiting_screen/00004.png -------------------------------------------------------------------------------- /tests/snapshots/apex_p/waiting_screen/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00000.png -------------------------------------------------------------------------------- /tests/snapshots/apex_p/waiting_screen/00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00001.png -------------------------------------------------------------------------------- /tests/snapshots/apex_p/waiting_screen/00002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00002.png -------------------------------------------------------------------------------- /tests/snapshots/apex_p/waiting_screen/00003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00003.png -------------------------------------------------------------------------------- /tests/snapshots/apex_p/waiting_screen/00004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/apex_p/waiting_screen/00004.png -------------------------------------------------------------------------------- /doc/_static/layout.css: -------------------------------------------------------------------------------- 1 | .body { 2 | max-width: 1600px !important; 3 | } 4 | 5 | .wy-nav-content { 6 | max-width: 1200px !important; 7 | } 8 | -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00000.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00001.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00002.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00003.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00004.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00005.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00006.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00006.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare/00007.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare/00007.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare_no_golden/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare_no_golden/00000.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_until_text_and_compare/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00000.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_until_text_and_compare/00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00001.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_until_text_and_compare/00002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00002.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_until_text_and_compare/00003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_until_text_and_compare/00003.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00000.png -------------------------------------------------------------------------------- /tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LedgerHQ/ragger/HEAD/tests/snapshots/nanos/test_navigate_and_compare_wrong_golden/00001.png -------------------------------------------------------------------------------- /ledger_app.toml: -------------------------------------------------------------------------------- 1 | # Fake boilerplate application manifest for ragger tests 2 | [app] 3 | build_directory = "./" 4 | sdk = "C" 5 | devices = ["nanos", "nanos+", "nanox", "stax", "flex", "apex_p"] 6 | -------------------------------------------------------------------------------- /doc/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {% block extrahead %} 3 | 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *pyc 2 | *egg-info/ 3 | *~ 4 | .coverage* 5 | coverage.xml 6 | build/ 7 | dist/ 8 | __version__.py 9 | .python-version 10 | 11 | tests/snapshots-tmp/ 12 | 13 | # doc 14 | doc/images/*generated* 15 | -------------------------------------------------------------------------------- /tests/unit/helpers.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from pathlib import Path 3 | from tempfile import TemporaryDirectory 4 | 5 | 6 | @contextmanager 7 | def temporary_directory(): 8 | with TemporaryDirectory() as dir_path: 9 | yield Path(dir_path).resolve() 10 | -------------------------------------------------------------------------------- /doc/architecture.rst: -------------------------------------------------------------------------------- 1 | Architecture 2 | ============ 3 | 4 | .. thumbnail:: images/packages_generated_ragger_architecture.svg 5 | :align: center 6 | :title: Package relations 7 | :show_caption: true 8 | 9 | .. thumbnail:: images/classes_generated_ragger_architecture.svg 10 | :align: center 11 | :title: Class relations 12 | :show_caption: true 13 | -------------------------------------------------------------------------------- /tests/unit/utils/test_structs.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from ragger.utils.structs import RAPDU 4 | 5 | 6 | class TestRAPDU(TestCase): 7 | 8 | def test_raw(self): 9 | status = 0x9000 10 | data = bytes.fromhex('0123456789abcdef') 11 | expected = data + bytes.fromhex('9000') 12 | self.assertEqual(RAPDU(status, data).raw, expected) 13 | -------------------------------------------------------------------------------- /doc/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _Tutorial: 2 | 3 | Tutorials 4 | ========= 5 | 6 | In this tutorial, we are going to develop glue code to leverage the capabilities 7 | of ``Ragger`` in order to write tests which are able to run either on 8 | :term:`Speculos` or on a physical device. 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | 13 | tutorial_installation 14 | tutorial_conftest 15 | tutorial_screen 16 | -------------------------------------------------------------------------------- /tests/unit/test_error_ApplicationError.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from ragger.error import ExceptionRAPDU 4 | 5 | 6 | class TestExceptionRAPDU(TestCase): 7 | 8 | def test___str__(self): 9 | status, data = 99, b"data" 10 | error = ExceptionRAPDU(status, data) 11 | for word in [f"0x{status:x}", str(data)]: 12 | self.assertIn(word, str(error)) 13 | -------------------------------------------------------------------------------- /tests/unit/firmware/test_structs_Firmware.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from ragger.firmware import Firmware 4 | 5 | 6 | class TestFirmware(TestCase): 7 | 8 | def test_firmware_not_existing_version(self): 9 | with self.assertRaises(ValueError): 10 | Firmware(10) 11 | 12 | def test_is_nano(self): 13 | self.assertTrue(Firmware.NANOSP.is_nano) 14 | self.assertFalse(Firmware.STAX.is_nano) 15 | -------------------------------------------------------------------------------- /tests/unit/utils/test_path.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from ragger.utils import structs 4 | 5 | 6 | class TestStructsRAPDU(TestCase): 7 | 8 | def test___str__data(self): 9 | status = 19 10 | data = "012345" 11 | rapdu = structs.RAPDU(status, bytes.fromhex(data)) 12 | self.assertEqual(f"[0x{status:x}] " + data, str(rapdu)) 13 | 14 | def test___str__no_data(self): 15 | status = 19 16 | rapdu = structs.RAPDU(status, None) 17 | self.assertEqual(f"[0x{status:x}] ", str(rapdu)) 18 | -------------------------------------------------------------------------------- /template/conftest.py: -------------------------------------------------------------------------------- 1 | from ragger.conftest import configuration 2 | 3 | ########################### 4 | ### CONFIGURATION START ### 5 | ########################### 6 | 7 | # You can configure optional parameters by overriding the value of ragger.configuration.OPTIONAL_CONFIGURATION 8 | # Please refer to ragger/conftest/configuration.py for their descriptions and accepted values 9 | 10 | ######################### 11 | ### CONFIGURATION END ### 12 | ######################### 13 | 14 | # Pull all features from the base ragger conftest using the overridden configuration 15 | pytest_plugins = ("ragger.conftest.base_conftest", ) 16 | -------------------------------------------------------------------------------- /doc/tutorial_installation.rst: -------------------------------------------------------------------------------- 1 | ``Ragger`` installation 2 | ======================= 3 | 4 | First step of all is the installation of ``Ragger``. At first, we are going to 5 | test it with :term:`Speculos`, so we are going to install ``Ragger`` with Speculos 6 | dependencies: 7 | 8 | .. code-block:: bash 9 | 10 | $ pip install ragger[speculos] 11 | 12 | ``ragger[speculos]`` means than Speculos and its dependencies will also be 13 | installed. ``Ragger`` tries to uncouple its dependencies so that only what is 14 | needed is installed. All the extras can be seen in the `setup.cfg 15 | `_ file. 16 | -------------------------------------------------------------------------------- /tests/functional/conftest.py: -------------------------------------------------------------------------------- 1 | from ragger.conftest import configuration 2 | 3 | ########################### 4 | ### CONFIGURATION START ### 5 | ########################### 6 | 7 | # You can configure optional parameters by overriding the value of ragger.configuration.OPTIONAL_CONFIGURATION 8 | # Please refer to ragger/conftest/configuration.py for their descriptions and accepted values 9 | 10 | configuration.OPTIONAL.BACKEND_SCOPE = "function" 11 | 12 | ######################### 13 | ### CONFIGURATION END ### 14 | ######################### 15 | 16 | # Pull all features from the base ragger conftest using the overridden configuration 17 | pytest_plugins = ("ragger.conftest.base_conftest", ) 18 | -------------------------------------------------------------------------------- /tests/unit/firmware/touch/test_element.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock, patch 3 | 4 | from ragger.firmware.touch.element import Element 5 | 6 | 7 | class TestElement(TestCase): 8 | 9 | def test___init__(self): 10 | client = MagicMock() 11 | device = MagicMock() 12 | positions = MagicMock() 13 | with patch("ragger.firmware.touch.element.POSITIONS", {Element.__name__: positions}): 14 | element = Element(client, device) 15 | self.assertEqual(element.device, device) 16 | self.assertEqual(element.client, client) 17 | self.assertEqual(element.positions, positions[device.type]) 18 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | The Ragger framework 2 | ==================== 3 | 4 | ``Ragger`` is a Python framework which aims at simplifying the test and overall 5 | automatic manipulation of applications running on Ledger devices. 6 | 7 | It is composed of a tiny wrapper around other Ledger's libraries which abstract 8 | and/or provide access to Ledger's cold wallet, and higher-level classes allowing 9 | to develop complex behaviors on devices. 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: How is this doc structured: 14 | 15 | installation 16 | rationale 17 | tutorial 18 | architecture 19 | source 20 | faq 21 | glossary 22 | 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | -------------------------------------------------------------------------------- /src/ragger/firmware/touch/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from .screen import MetaScreen, FullScreen 17 | 18 | __all__ = ["MetaScreen", "FullScreen"] 19 | -------------------------------------------------------------------------------- /src/ragger/firmware/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from .structs import DEPRECATION_MESSAGE, Firmware 17 | 18 | __all__ = ["DEPRECATION_MESSAGE", "Firmware"] 19 | -------------------------------------------------------------------------------- /src/ragger/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | try: 17 | from ragger.__version__ import __version__ # noqa 18 | except ImportError: 19 | __version__ = "unknown version" # noqa 20 | -------------------------------------------------------------------------------- /src/ragger/utils/packing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from struct import pack 17 | 18 | 19 | def pack_APDU(cla: int, ins: int, p1: int = 0, p2: int = 0, data: bytes = b"") -> bytes: 20 | return pack(">BBBBB", cla, ins, p1, p2, len(data)) + data 21 | -------------------------------------------------------------------------------- /tests/unit/navigator/test_nano_navigator.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from unittest import TestCase 3 | from ledgered.devices import DeviceType, Devices 4 | 5 | from ragger.navigator.nano_navigator import NanoNavigator 6 | from ragger.backend import LedgerCommBackend, LedgerWalletBackend, SpeculosBackend 7 | 8 | 9 | class TestNanoNavigator(TestCase): 10 | 11 | def test___init__ok(self): 12 | for backend_cls in [ 13 | partial(SpeculosBackend, "some app"), LedgerCommBackend, LedgerWalletBackend 14 | ]: 15 | backend = backend_cls(Devices.get_by_type(DeviceType.NANOS)) 16 | NanoNavigator(backend, Devices.get_by_type(DeviceType.NANOS)) 17 | 18 | def test___init__nok(self): 19 | with self.assertRaises(ValueError): 20 | NanoNavigator("whatever", Devices.get_by_type(DeviceType.STAX)) 21 | -------------------------------------------------------------------------------- /tests/unit/navigator/test_touch_navigator.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from ledgered.devices import DeviceType, Devices 3 | from unittest import TestCase 4 | 5 | from ragger.navigator.touch_navigator import TouchNavigator 6 | from ragger.backend import LedgerCommBackend, LedgerWalletBackend, SpeculosBackend 7 | 8 | 9 | class TestTouchNavigator(TestCase): 10 | 11 | def test___init__ok(self): 12 | for backend_cls in [ 13 | partial(SpeculosBackend, "some app"), LedgerCommBackend, LedgerWalletBackend 14 | ]: 15 | backend = backend_cls(Devices.get_by_type(DeviceType.STAX)) 16 | TouchNavigator(backend, Devices.get_by_type(DeviceType.STAX)) 17 | 18 | def test___init__nok(self): 19 | with self.assertRaises(ValueError): 20 | TouchNavigator("whatever", Devices.get_by_type(DeviceType.NANOS)) 21 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | gen_resources: 18 | @pyreverse --output svg --colorized --project generated_ragger_architecture --output-directory "$(SOURCEDIR)"/images/ "$(SOURCEDIR)"/../src/ragger 19 | 20 | # Catch-all target: route all unknown targets to Sphinx using the new 21 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 22 | %: Makefile 23 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 24 | -------------------------------------------------------------------------------- /tests/unit/utils/test_packing.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from ragger.utils import packing 4 | 5 | 6 | class TestPacking(TestCase): 7 | 8 | def test_pack_APDU(self): 9 | cla, ins, p1, p2, data = 1, 2, 3, 4, b"data" 10 | expected = bytes.fromhex("0102030404") + data 11 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p1, p2, data)) 12 | expected = bytes.fromhex("0102030400") 13 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p1, p2)) 14 | expected = bytes.fromhex("0102030004") + data 15 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p1, data=data)) 16 | expected = bytes.fromhex("0102000404") + data 17 | self.assertEqual(expected, packing.pack_APDU(cla, ins, p2=p2, data=data)) 18 | expected = bytes.fromhex("0102000000") 19 | self.assertEqual(expected, packing.pack_APDU(cla, ins)) 20 | -------------------------------------------------------------------------------- /doc/installation.rst: -------------------------------------------------------------------------------- 1 | .. _Installation: 2 | 3 | Installation 4 | ============ 5 | 6 | ``Ragger`` is currently available in the test ``pypi`` repository. It can be 7 | installed with ``pip``: 8 | 9 | .. code-block:: bash 10 | 11 | pip install ragger 12 | 13 | 14 | By default - to avoid wasting useless time and space - the ``ragger`` package 15 | comes with no :term:`backend`. Specific backend can be installed with extras: 16 | ``ragger[speculos]``, ``ragger[ledgercomm]``, ``ragger[ledgerwallet]``. 17 | All backends can be installed with ``ragger[all_backends]``. 18 | 19 | 20 | .. _Installation-Apt: 21 | 22 | .. note:: 23 | 24 | ``Speculos`` uses the ``qemu-arm-static`` executable under the hood, so 25 | you will need to install a system package for this. On Debian-based system, 26 | this executable lies in the ``qemu-user-static`` package, which can be 27 | installed it with: 28 | 29 | .. code-block:: bash 30 | 31 | sudo apt-get update && sudo apt-get install qemu-user-static 32 | -------------------------------------------------------------------------------- /src/ragger/bip/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from .path import BtcDerivationPathFormat, pack_derivation_path, bitcoin_pack_derivation_path 17 | from .seed import CurveChoice, calculate_public_key_and_chaincode 18 | 19 | __all__ = [ 20 | "bitcoin_pack_derivation_path", 21 | "BtcDerivationPathFormat", 22 | "calculate_public_key_and_chaincode", 23 | "CurveChoice", 24 | "pack_derivation_path", 25 | ] 26 | -------------------------------------------------------------------------------- /src/ragger/navigator/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from .instruction import BaseNavInsID, NavInsID, NavIns 17 | from .navigator import Navigator 18 | from .touch_navigator import TouchNavigator 19 | from .nano_navigator import NanoNavigator 20 | from .navigation_scenario import NavigateWithScenario 21 | 22 | __all__ = [ 23 | "BaseNavInsID", "NavInsID", "NavIns", "Navigator", "TouchNavigator", "NanoNavigator", 24 | "NavigateWithScenario" 25 | ] 26 | -------------------------------------------------------------------------------- /tests/unit/firmware/touch/test_screen_MetaScreen.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | 4 | from ragger.firmware.touch import MetaScreen 5 | 6 | 7 | class TestMetaScreen(TestCase): 8 | 9 | def setUp(self): 10 | self.layout = MagicMock() 11 | 12 | class Test(metaclass=MetaScreen): 13 | layout_one = self.layout 14 | 15 | def __init__(self, client, device, some_argument, other=None): 16 | self.some = some_argument 17 | self.other = other 18 | 19 | self.cls = Test 20 | self.assertEqual(self.layout.call_count, 0) 21 | 22 | def test___init__(self): 23 | client, device = MagicMock(), MagicMock() 24 | args = (client, device, "some") 25 | test = self.cls(*args) 26 | self.assertEqual(self.layout.call_count, 1) 27 | self.assertEqual(self.layout.call_args, ((client, device), )) 28 | self.assertEqual(test.one, self.layout()) 29 | self.assertEqual(test.some, args[-1]) 30 | self.assertIsNone(test.other) 31 | -------------------------------------------------------------------------------- /src/ragger/gui/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | try: 17 | from .process import RaggerGUI 18 | except ImportError as e: 19 | # Can be 'QtCore' or 'QtWidgets' 20 | if e.name is None or not e.name.startswith("Qt"): 21 | raise e 22 | 23 | def RaggerGUI(*args, **kwatgs): # type: ignore 24 | raise ImportError( 25 | "This feature needs PyQt6. Please install this package (run `pip install pyqt6`)") 26 | 27 | 28 | __all__ = ["RaggerGUI"] 29 | -------------------------------------------------------------------------------- /src/ragger/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from .structs import RAPDU, Crop 17 | from .packing import pack_APDU 18 | from .misc import find_library_application, prefix_with_len, find_project_root_dir 19 | from .misc import create_currency_config, split_message, find_application 20 | 21 | __all__ = [ 22 | "find_library_application", "create_currency_config", "Crop", "pack_APDU", "prefix_with_len", 23 | "RAPDU", "split_message", "find_project_root_dir", "find_application" 24 | ] 25 | -------------------------------------------------------------------------------- /.github/workflows/force-rebase.yml: -------------------------------------------------------------------------------- 1 | name: Force rebased 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | force-rebase: 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: 'PR commits + 1' 11 | id: pr_commits 12 | run: echo "pr_fetch_depth=$(( ${{ github.event.pull_request.commits }} + 1 ))" >> "${GITHUB_OUTPUT}" 13 | 14 | - name: 'Checkout PR branch and all PR commits' 15 | uses: actions/checkout@v4 16 | with: 17 | ref: ${{ github.event.pull_request.head.sha }} 18 | fetch-depth: ${{ steps.pr_commits.outputs.pr_fetch_depth }} 19 | 20 | - name: Check if PR branch is rebased on target branch 21 | shell: bash 22 | run: | 23 | git merge-base --is-ancestor ${{ github.event.pull_request.base.sha }} HEAD 24 | 25 | - name: Check if PR branch contains merge commits 26 | shell: bash 27 | run: | 28 | merges=$(git log --oneline HEAD~${{ github.event.pull_request.commits }}...HEAD --merges ); \ 29 | echo "--- Merges ---"; \ 30 | echo ${merges}; \ 31 | [[ -z "${merges}" ]] 32 | -------------------------------------------------------------------------------- /tests/unit/backend/test_stub.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from ragger.backend import BackendInterface 4 | from ragger.backend.stub import StubBackend 5 | from ragger.utils.structs import RAPDU 6 | 7 | 8 | class TestStubBackend(TestCase): 9 | 10 | def setUp(self): 11 | self.stub = StubBackend(None) 12 | 13 | def test_can_instantiate(self): 14 | self.assertIsInstance(self.stub, BackendInterface) 15 | 16 | def test_emtpy_methods(self): 17 | for func in (self.stub.handle_usb_reset, self.stub.send_raw, self.stub.right_click, 18 | self.stub.left_click, self.stub.both_click, self.stub.finger_touch, 19 | self.stub.wait_for_screen_change, self.stub.get_current_screen_content, 20 | self.stub.pause_ticker, self.stub.resume_ticker, self.stub.send_tick): 21 | self.assertIsNone(func()) 22 | for func in (self.stub.receive, self.stub.exchange_raw): 23 | self.assertEqual(func(), RAPDU(0x9000, b"")) 24 | for func in (self.stub.compare_screen_with_snapshot, self.stub.compare_screen_with_text): 25 | self.assertTrue(func(None)) 26 | -------------------------------------------------------------------------------- /tests/unit/navigator/test_navigation_scenario.py: -------------------------------------------------------------------------------- 1 | from ledgered.devices import DeviceType, Devices 2 | from pathlib import Path 3 | from tempfile import TemporaryDirectory 4 | from unittest import TestCase 5 | from unittest.mock import MagicMock 6 | 7 | from ragger.navigator import NavigateWithScenario 8 | 9 | 10 | class TestNavigationScenario(TestCase): 11 | 12 | def setUp(self): 13 | self.directory = TemporaryDirectory() 14 | self.backend = MagicMock() 15 | self.device = Devices.get_by_type(DeviceType.NANOS) 16 | self.callbacks = dict() 17 | self.navigator = MagicMock() 18 | self.navigate_with_scenario = NavigateWithScenario(self.backend, self.navigator, 19 | self.device, "test_name", self.directory) 20 | 21 | def tearDown(self): 22 | self.directory.cleanup() 23 | 24 | @property 25 | def pathdir(self) -> Path: 26 | return Path(self.directory.name) 27 | 28 | def test_navigation_scenario(self): 29 | self.assertIsNone(self.navigate_with_scenario.review_approve()) 30 | self.assertIsNone(self.navigate_with_scenario.review_reject()) 31 | self.assertIsNone(self.navigate_with_scenario.address_review_approve()) 32 | self.assertIsNone(self.navigate_with_scenario.address_review_reject()) 33 | -------------------------------------------------------------------------------- /src/ragger/firmware/touch/element.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2024 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from ledgered.devices import Device 17 | 18 | from ragger.backend import BackendInterface 19 | from .positions import POSITIONS 20 | 21 | 22 | class Element: 23 | 24 | def __init__(self, client: BackendInterface, device: Device): 25 | self._client = client 26 | self._device = device 27 | 28 | @property 29 | def client(self) -> BackendInterface: 30 | return self._client 31 | 32 | @property 33 | def device(self) -> Device: 34 | return self._device 35 | 36 | @property 37 | def positions(self): 38 | return POSITIONS[str(self.__class__.__name__)][self.device.type] 39 | 40 | 41 | class Center(Element): 42 | 43 | def swipe_left(self): 44 | self.client.finger_swipe(*self.positions, direction="left") 45 | 46 | def swipe_right(self): 47 | self.client.finger_swipe(*self.positions, direction="right") 48 | -------------------------------------------------------------------------------- /src/ragger/utils/structs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from dataclasses import dataclass 17 | 18 | 19 | @dataclass(frozen=True) 20 | class Crop: 21 | left: int = 0 22 | upper: int = 0 23 | right: int = 0 24 | lower: int = 0 25 | 26 | 27 | @dataclass(frozen=True) 28 | class RAPDU: 29 | """ 30 | The dataclass containing the application's response of an APDU from the 31 | client to the application. 32 | 33 | It is composed of two attributes: 34 | 35 | - ``status`` (``int``): from the two last bytes of the payload. Common 36 | values are 0x9000 for success, other being errors. 37 | - ``data`` (``bytes``): the rest of the response (the entire payload without 38 | the two last bytes) 39 | """ 40 | status: int 41 | data: bytes 42 | 43 | def __str__(self): 44 | return f'[0x{self.status:02x}] {self.data.hex() if self.data else ""}' 45 | 46 | @property 47 | def raw(self): 48 | return self.data + self.status.to_bytes(2, 'big') 49 | -------------------------------------------------------------------------------- /doc/images/navigate.draw: -------------------------------------------------------------------------------- 1 | 7VpZc+MoEP41rso+jEsCSXYex3Yy1+5ktvKwk0csYZkNFloJx/b++gUJdCGvHUe+plKVqkDTIPrrg25wD44X608Jiud/sADTHrCCdQ9OegDYDgA9+WcFm5wytKycECYkUEwl4ZH8ixVRsy1JgNMaI2eMchLXiT6LIuzzGg0lCVvV2WaM1r8aoxAbhEcfUZP6Fwn4XEnhWiX9MybhXH/Z1vItkGZWhHSOAraqkOBdD44TxnjeWqzHmErwNC75vPsto8XGEhzxfSb4s2+rh4eH5z+Dr5h+cbzvg0/fPqhVXhBdKoG/oxcSIs4StWu+0VAkbBkFWK5m9+BoNSccP8bIl6MroXxBm/MFVcMzQumYUbGMnAsnzp197wp6yhP2jCsjnj/E05kYUTvBCcfrrSLaBXDC4jBbYJ5sBIueoLEujC3vrkrNFSzzitY0H1LGEhYrl3iKhoL0FfBCA94vkUBg6XPCIiGL1YMfpfEiSqfIfxYUj4o9jaYCfS+UrQo/MPjB//JDgx+a/P1+31D061QbuHgYOG2qHYIp9LxuVAsbqr1tUa3XolrvWKp1DNUaOAqPj2VzRvH6o4xFAgocBao58SlKU+LXwS2dzMq4xVZ/qqGs8yRH+q7uTtZVzslG9RoqwrZQ0qBNRbfeAKJSRTgwomFDQUJAtkx8vNvoOUpCzHfFHlPhFY26LQrVtARTxMlLfbttWlZf+MGIEKSwJ6dhT7AZA3Ix1axqWG0uNGgsBBsL5TgYC2VGV4h9uB0OdtthxeYiFgniKEDpvIjkFeuT9B+Ic5xEGQVYsLAaffwBw0r3tZ2z6bquIftQVXvWjoWOrOrCAyuqFaA/qi5L+JyFLEL0rqQ2VFXy/M5YrLT+N+Z8o1IvtOSsbhN4TfjPMuqI3lNlpAxAsqPjj2lxgnJPpLj1uFYJZU+1SNYe1zqMUsN9o9QW032jTTaNElqntaXh9sSvH+Ut/AtkgEX2vSsFPFqecNsSn/MkLCAvNXi9f5ayFhjVWqH6jxYS3miaxlnfaiO5I7202Gm2ejH99R/MZqQxilqnyGwyzMzhg59rUuaZJCKcIPoWGQ4i3dSz3nFW+4Wp/VvWLiDJpalL2AFSJxELlGKBhljXtn+o9t+Yfl3y2KU8zrXo41JIWc15UrjcybYPvq70RZSEMjOmeMbNI+48lTDQmUP1iNOZafWIs5spRmdnnL6su8xiuEhge2X6Wiaz7QnsWQvo2z1T0+FFFdBOw7oOLaCLq51TVVUtBfOlVlWHVU27qrEOTde297RdG76xrHpbwDLvvYs70hudOnZY/AQID2d+W/zotPjxGi65b/HTdN3OcAbw3be68629L1ads/qW+ehRvFfc6Prl+n1rcHbfct59qzvfcvb0LbAl6TqRb5mvTnXfgr+CbzlnP7ds18C57aH0+qB1zw4t8AxoW541KCVxineDitI4/9XHjKylIrrAzK1DZtsmZG2IgaMhZj7xtZQsF4RYYTxnQ+z2/Wjs7GgEe7+Uuec8GoH5olX8rOZGX9Neffx2jph2im75a7T8dqX8TR+8+w8= -------------------------------------------------------------------------------- /src/ragger/navigator/nano_navigator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from time import sleep 17 | from typing import Callable, Dict 18 | from ledgered.devices import Device 19 | 20 | from ragger.backend import BackendInterface 21 | from .navigator import BaseNavInsID, Navigator, NavInsID 22 | 23 | 24 | class NanoNavigator(Navigator): 25 | 26 | def __init__(self, backend: BackendInterface, device: Device, golden_run: bool = False): 27 | if device.touchable: 28 | raise ValueError(f"'{self.__class__.__name__}' does not work on touchable devices") 29 | callbacks: Dict[BaseNavInsID, Callable] = { 30 | NavInsID.WAIT: sleep, 31 | NavInsID.WAIT_FOR_SCREEN_CHANGE: backend.wait_for_screen_change, 32 | NavInsID.WAIT_FOR_HOME_SCREEN: backend.wait_for_home_screen, 33 | NavInsID.WAIT_FOR_TEXT_ON_SCREEN: backend.wait_for_text_on_screen, 34 | NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN: backend.wait_for_text_not_on_screen, 35 | NavInsID.RIGHT_CLICK: backend.right_click, 36 | NavInsID.LEFT_CLICK: backend.left_click, 37 | NavInsID.BOTH_CLICK: backend.both_click 38 | } 39 | super().__init__(backend, device, callbacks, golden_run) 40 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation generation & update 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | branches: 8 | - develop 9 | - master 10 | pull_request: 11 | branches: 12 | - develop 13 | - master 14 | # Cancel previous runs on this reference 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | 20 | jobs: 21 | generate: 22 | name: Generate the documentation 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Clone 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | - name: Install APT dependencies 30 | run: | 31 | sudo apt-get update 32 | sudo apt-get install graphviz 33 | - name: Install Python dependencies 34 | run: | 35 | pip install -U pip 36 | pip install .[doc] 37 | - name: Generating automatic resources 38 | run: (cd doc && make gen_resources) 39 | - name: Generate the documentation 40 | run: (cd doc && make html) 41 | - name: Upload documentation bundle 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: documentation 45 | path: doc/build/html/ 46 | 47 | deploy: 48 | name: Deploy the documentation on Github pages 49 | runs-on: ubuntu-latest 50 | needs: generate 51 | if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')) 52 | steps: 53 | - name: Download documentation bundle 54 | uses: actions/download-artifact@v4 55 | - name: Deploy documentation on pages 56 | uses: peaceiris/actions-gh-pages@v4 57 | with: 58 | github_token: ${{ secrets.GITHUB_TOKEN }} 59 | publish_dir: documentation/ 60 | -------------------------------------------------------------------------------- /.github/workflows/fast-checks.yml: -------------------------------------------------------------------------------- 1 | name: Fast checks 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - develop 8 | - master 9 | pull_request: 10 | # Cancel previous runs on this reference 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | 16 | jobs: 17 | lint: 18 | name: Linting 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Clone 22 | uses: actions/checkout@v4 23 | - run: pip install flake8 flake8-pyproject 24 | - name: Flake8 lint Python code 25 | run: flake8 src/ 26 | 27 | yapf: 28 | name: Formatting 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Clone 32 | uses: actions/checkout@v4 33 | - run: pip install yapf toml 34 | - name: Yapf source formatting 35 | run: | 36 | yapf src/ --recursive -d 37 | yapf tests/ --recursive -d 38 | yapf template/ --recursive -d 39 | 40 | mypy: 41 | name: Type checking 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Clone 45 | uses: actions/checkout@v4 46 | - run: pip install mypy types-toml 47 | - name: Mypy type checking 48 | run: mypy src 49 | 50 | bandit: 51 | name: Security checking 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Clone 55 | uses: actions/checkout@v4 56 | - run: pip install bandit 57 | - name: Bandit security checking 58 | run: bandit -r src -ll 59 | 60 | misspell: 61 | name: Check misspellings 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: Clone 65 | uses: actions/checkout@v4 66 | - name: Check misspellings 67 | uses: codespell-project/actions-codespell@v2 68 | with: 69 | builtin: clear,rare 70 | check_filenames: true 71 | ignore_words_list: assertIn,crate, 72 | -------------------------------------------------------------------------------- /src/ragger/bip/path.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from bip_utils import Bip32Utils 17 | from enum import IntEnum 18 | 19 | 20 | class BtcDerivationPathFormat(IntEnum): 21 | LEGACY = 0x00 22 | P2SH = 0x01 23 | BECH32 = 0x02 24 | CASHADDR = 0x03 # Deprecated 25 | BECH32M = 0x04 26 | 27 | 28 | def pack_derivation_path(derivation_path: str) -> bytes: 29 | split = derivation_path.split("/") 30 | 31 | if split[0] != "m": 32 | raise ValueError("Error master expected") 33 | 34 | path_bytes: bytes = (len(split) - 1).to_bytes(1, byteorder='big') 35 | for value in split[1:]: 36 | if value == "": 37 | raise ValueError(f'Error missing value in split list "{split}"') 38 | if value.endswith('\''): 39 | path_bytes += Bip32Utils.HardenIndex(int(value[:-1])).to_bytes(4, byteorder='big') 40 | else: 41 | path_bytes += int(value).to_bytes(4, byteorder='big') 42 | return path_bytes 43 | 44 | 45 | def bitcoin_pack_derivation_path(format: BtcDerivationPathFormat, derivation_path: str) -> bytes: 46 | if not isinstance(format, BtcDerivationPathFormat): 47 | raise ValueError(f'"{format}" must be a BtcDerivationPathFormat enum') 48 | return format.to_bytes(1, "big") + pack_derivation_path(derivation_path) 49 | -------------------------------------------------------------------------------- /src/ragger/bip/seed.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Sequence 2 | from enum import Enum, auto 3 | 4 | from bip_utils import Bip32Secp256k1 5 | from bip_utils import Bip32Nist256p1 6 | from bip_utils import Bip32Ed25519Slip 7 | from bip_utils import Bip32Ed25519Kholaw 8 | from bip_utils import Bip32Ed25519Blake2bSlip 9 | from bip_utils import Bip39SeedGenerator 10 | 11 | 12 | class CurveChoice(Enum): 13 | Secp256k1 = auto() 14 | Nist256p1 = auto() 15 | Ed25519Slip = auto() 16 | Ed25519Kholaw = auto() 17 | Ed25519Blake2bSlip = auto() 18 | 19 | 20 | GET_CURVE_OBJ = { 21 | CurveChoice.Secp256k1: Bip32Secp256k1, 22 | CurveChoice.Nist256p1: Bip32Nist256p1, 23 | CurveChoice.Ed25519Slip: Bip32Ed25519Slip, 24 | CurveChoice.Ed25519Kholaw: Bip32Ed25519Kholaw, 25 | CurveChoice.Ed25519Blake2bSlip: Bip32Ed25519Blake2bSlip, 26 | } 27 | 28 | SPECULOS_MNEMONIC = ( 29 | "glory promote mansion idle axis finger extra february uncover one trip resource " 30 | "lawn turtle enact monster seven myth punch hobby comfort wild raise skin") 31 | 32 | 33 | def calculate_public_key_and_chaincode(curve: CurveChoice, 34 | path: str, 35 | mnemonic: Sequence[str] = SPECULOS_MNEMONIC, 36 | compress_public_key: bool = False) -> Tuple[str, str]: 37 | if not isinstance(curve, CurveChoice): 38 | raise ValueError(f'"{curve}" must be a CurveChoice enum') 39 | 40 | seed = Bip39SeedGenerator(mnemonic).Generate() 41 | root_node = GET_CURVE_OBJ[curve].FromSeed(seed_bytes=seed) 42 | child_node = root_node.DerivePath(path=path) 43 | public_key = child_node.PublicKey() 44 | chaincode = child_node.ChainCode() 45 | 46 | if compress_public_key: 47 | raw_public_key = public_key.RawCompressed() 48 | else: 49 | raw_public_key = public_key.RawUncompressed() 50 | return raw_public_key.ToHex(), chaincode.ToHex() 51 | -------------------------------------------------------------------------------- /doc/images/usage.draw: -------------------------------------------------------------------------------- 1 | 7VtRd6I6EP41Pu49hADiY2273T3b3tNbe7btY4SIbIFwILbaX3+DJAgkqF0Rbe2TZEhinO/7ZoZRe/A8nF8lKJ7eEBcHPV1z5z140dN1XbN09pJZFrkFAEvLLV7iu9y2Moz8N8yNYtrMd3FamUgJCagfV40OiSLs0IoNJQl5rU6bkKD6rjHysGQYOSiQrQ++S6e51Ta1lf0H9r2peGeg8TshEpO5IZ0il7yWTPCyB88TQmh+Fc7PcZB5T/glX/e94W5xsARHdJsFd7+f7kkE8Q209OHl7JHET1ffdJMfji7EJ07ILHJxtkjrwSFJ6JR4JELBNSExMwJm/IMpXXCs0IwSZprSMOB38dynj6Xrp2yrf0w+upjznZeDhRhENFk8lgelVdlwtWw5EutkJ3C/pGSWOHjNJ4ecTCjxMF0zb5DPw26FJ9zFV5iEmJ2HTUhwgKj/UqUN4uzzinkrgNgFx+g9eNkSXtnJRnwYkYi9DE8Ewv6WEOr6cWE4+MKwcIZ9MAz50lviszPrGk9ZUOQnnrCgSERii/ykfFWNCcUx/p4cYL3AVzy4XFlPhSuDLbkiIDsSvfNjv6Bgxt/px/397Ya8+zr1KR7FaOmQV1ZcVQGb+EFwTgKSLNdCDFwT95k9pQl5xqU7A6sPkbXO7S84oXi+1k8N2gAGH7+uKiNhmpaKIkvbk2fh8UbSv2d5++xVRzpLq6Kpdx3pNEkXoxg7s4CkzJpLREtxwvi5Rivg3VqZ2A52HJVWxrZpmGvB214rhrFZK0DvUiwFAT9OZgFdZRYgHi03ppb9lCFnSYIWpQlxJrq0Wbt9S63d71vOB6ZW41N+gnYFLkSjFDgOZ8xDpGVtT7Cl1rbbH4y1lrRdj5zAPrS2BS1Lrj6L48B3GAdJ1LKLJ3qDi62xZbZUakgu1g/tYvgVPpvpt20zBRhHGT6h+b7wqQ+6CJ9Q0vTtdJEyTS9brPjFZ5h8/OAJD65sQcpPGzwL1x0ueGqSG489eHbW1RCdrc3BE7YdPHfDVO5UfSGmroCPBDFx7nIn6ufFBhQ/QiNKlUO6bUTJnpUDXuSeZV9I9orGlIvSaZFISj6tAtDor42sKnnDVHhD2HbsIxl6FQxDq3k515PUR9q4UZGiOmpIQbngOg98vDxi6rtyscXYSqvIocD3InbtsFWYEX+YcTqr2M74jdB33Ty54dR/Q+PlVhnKvGRl+5rDnnmR7cWiY5oHSiAJijOorD5uakFeumFWgLBNSV22gk9wb+qSC7QR7wueFjAGPDJgrNMNezaoFdlmS2FP2mjPYU8kyhKIl8venOrh57Mqq19r1+uKiqJTaRly8/QOr3vy/7TI1GJeIY8SMoMukTFPuNaDRku1Xn2jrms9U1HrkTCcRUXXxwoyOY1ZjWF52dU1Wii+iPysqoP6xkoDAAXPjBZUBx7u/3uiN7//TW/iEXx7Dt9+zb7J4VDCwmNaihs+aPEDUe7vXhHgJTc1+6RG2b4ciPS+wifF10Ptdy4VTrFQmD2iR+M0e6k0MjWZ1fX5+aRNJoc/FjU2DrbojAqdBHhC5T6Ci7A9UTZKLcfG4wm7E5IXDiaoiSmPqxTR0tjFAS6PseuXh1V2ALWI1vFys7IORxNVwqoCeoc8b9efWWwA1MS2a6gANS9szRhUANUU0bEMqFYDVKsCqkmANuTW3QAVBUotLCh67ar6BOyvGSXBLb52HyLnmZUmu+F8MsCC+oOZAlm4pxy47vfOJWCvs7owySqXL2h3gVaRyruF1m6A9gEFzGVf4O4CrvK3Iy2hy4arf/DkDyerP0LBy/8B -------------------------------------------------------------------------------- /src/ragger/firmware/structs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from enum import IntEnum 17 | from ledgered.devices import Devices, DeviceType 18 | from warnings import warn 19 | 20 | 21 | DEPRECATION_MESSAGE = "`ragger.firmware.Firmware` is deprecated, use `ledgered.devices.Devices` " \ 22 | "or `ledgered.devices.DeviceType` instead" 23 | 24 | 25 | class Firmware(IntEnum): 26 | NANOS = DeviceType.NANOS 27 | NANOSP = DeviceType.NANOSP 28 | NANOX = DeviceType.NANOX 29 | STAX = DeviceType.STAX 30 | FLEX = DeviceType.FLEX 31 | APEX_P = DeviceType.APEX_P 32 | APEX_M = DeviceType.APEX_M 33 | 34 | @staticmethod 35 | def deprec_warning() -> None: 36 | warn(DEPRECATION_MESSAGE) 37 | 38 | @property 39 | def name(self) -> str: 40 | """ 41 | Returns the name of the current firmware's device 42 | """ 43 | self.deprec_warning() 44 | return Devices.get_by_type(self).name 45 | 46 | @property 47 | def device(self) -> str: 48 | """ 49 | A proxy property for :attr:`.Firmware.name`. 50 | This property is deprecated. It is advise to not use it. 51 | """ 52 | self.deprec_warning() 53 | return self.name 54 | 55 | @property 56 | def is_nano(self): 57 | """ 58 | States if the firmware's name starts with 'nano' or not. 59 | """ 60 | self.deprec_warning() 61 | return Devices.get_by_type(self).is_nano 62 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | # The branches below must be a subset of the branches above 10 | branches: 11 | - master 12 | - develop 13 | schedule: 14 | - cron: '35 0 * * 5' 15 | # Cancel previous runs on this reference 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ubuntu-latest 25 | permissions: 26 | actions: read 27 | contents: read 28 | security-events: write 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v4 33 | 34 | # Initializes the CodeQL tools for scanning. 35 | - name: Initialize CodeQL 36 | uses: github/codeql-action/init@v3 37 | with: 38 | languages: "python" 39 | # If you wish to specify custom queries, you can do so here or in a config file. 40 | # By default, queries listed here will override any specified in a config file. 41 | # Prefix the list here with "+" to use these queries and those in the config file. 42 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 43 | queries: +security-and-quality 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v3 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v3 63 | with: 64 | config-file: .codeql-config.yaml 65 | -------------------------------------------------------------------------------- /src/ragger/backend/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from .interface import BackendInterface, RaisePolicy 17 | from .stub import StubBackend 18 | 19 | ERROR_MSG = ("This backend needs {}. Please install this package (run `pip install ragger[{}]` or " 20 | "check this address: '{}')") 21 | 22 | try: 23 | from .speculos import SpeculosBackend 24 | except ImportError as e: 25 | if "speculos" not in str(e): 26 | raise e 27 | 28 | def SpeculosBackend(*args, **kwargs): # type: ignore 29 | raise ImportError( 30 | ERROR_MSG.format("Speculos", "speculos", "https://github.com/LedgerHQ/speculos/")) 31 | 32 | 33 | try: 34 | from .ledgercomm import LedgerCommBackend 35 | except ImportError as e: 36 | if "ledgercomm" not in str(e): 37 | raise e 38 | 39 | def LedgerCommBackend(*args, **kwargs): # type: ignore 40 | raise ImportError( 41 | ERROR_MSG.format("LedgerComm", "ledgercomm", "https://github.com/LedgerHQ/ledgercomm/")) 42 | 43 | 44 | try: 45 | from .ledgerwallet import LedgerWalletBackend 46 | except ImportError as e: 47 | if "ledgerwallet" not in str(e): 48 | raise e 49 | 50 | def LedgerWalletBackend(*args, **kwargs): # type: ignore 51 | raise ImportError( 52 | ERROR_MSG.format("LedgerWallet", "ledgerwallet", 53 | "https://github.com/LedgerHQ/ledgerctl/")) 54 | 55 | 56 | __all__ = [ 57 | "SpeculosBackend", "LedgerCommBackend", "LedgerWalletBackend", "BackendInterface", 58 | "RaisePolicy", "StubBackend" 59 | ] 60 | -------------------------------------------------------------------------------- /src/ragger/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | import atexit 18 | import logging 19 | from pathlib import Path 20 | 21 | SHORT_FORMAT = '[%(levelname)s] %(name)s - %(message)s' 22 | LONG_FORMAT = '[%(asctime)s][%(levelname)s] %(name)s - %(message)s' 23 | 24 | 25 | def get_default_logger(): 26 | return logging.getLogger("ragger.logger") 27 | 28 | 29 | def get_conf_logger(): 30 | return logging.getLogger("ragger.configuration") 31 | 32 | 33 | def get_apdu_logger(): 34 | return logging.getLogger("ragger.apdu_logger") 35 | 36 | 37 | def get_gui_logger(): 38 | return logging.getLogger("ragger.gui") 39 | 40 | 41 | def _init_logger(logger: logging.Logger, level, format: str): 42 | logger.handlers.clear() 43 | logger.setLevel(level=level) 44 | handler = logging.StreamHandler() 45 | handler.setFormatter(logging.Formatter(format)) 46 | logger.addHandler(handler) 47 | 48 | 49 | def init_loggers(level): 50 | if level == logging.DEBUG: 51 | display_format = LONG_FORMAT 52 | else: 53 | display_format = SHORT_FORMAT 54 | for logger in [get_default_logger(), get_apdu_logger(), get_gui_logger()]: 55 | _init_logger(logger, level, display_format) 56 | 57 | 58 | def standalone_conf_logger(): 59 | logger = get_conf_logger() 60 | _init_logger(logger, logging.DEBUG, SHORT_FORMAT) 61 | return logger 62 | 63 | 64 | def set_apdu_logger_file(log_apdu_file: Path): 65 | apdu_logger = get_apdu_logger() 66 | 67 | # Only one file handler supported 68 | for handler in apdu_logger.handlers: 69 | if isinstance(handler, logging.FileHandler): 70 | apdu_logger.removeHandler(handler) 71 | 72 | apdu_handler = logging.FileHandler(filename=log_apdu_file, mode='w', delay=True) 73 | apdu_handler.setFormatter(logging.Formatter('%(message)s')) 74 | apdu_logger.addHandler(apdu_handler) 75 | 76 | def cleanup(): 77 | for handler in apdu_logger.handlers: 78 | if isinstance(handler, logging.FileHandler): 79 | handler.close() 80 | 81 | atexit.register(cleanup) 82 | 83 | 84 | # Runs on import of logger module to enable the default log template 85 | init_loggers(logging.INFO) 86 | -------------------------------------------------------------------------------- /tests/unit/bip/test_path.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from ragger.bip import path as p 4 | 5 | MAX_VALUE = 2**31 - 1 6 | 7 | HARDENED_INDEX = 2**31 8 | 9 | 10 | class TestPath(TestCase): 11 | 12 | def _test_level_n(self, n: int): 13 | for variant in [0, 255, 81845, 887587, MAX_VALUE]: 14 | for hardening in [False, True]: 15 | hardening_char = '\'' if hardening else '' 16 | previous_levels = "0/" * (n - 1) 17 | path = f'm/{previous_levels}{variant}{hardening_char}' 18 | packed = p.pack_derivation_path(path) 19 | self.assertEqual(packed[0], n) 20 | self.assertEqual(len(packed), 1 + n * 4) 21 | last_value = int.from_bytes(packed[len(packed) - 4:len(packed)], byteorder="big") 22 | self.assertEqual(last_value, variant | (HARDENED_INDEX if hardening else 0)) 23 | 24 | def test_errors(self): 25 | with self.assertRaises(ValueError): 26 | p.pack_derivation_path("") 27 | with self.assertRaises(ValueError): 28 | p.pack_derivation_path("m/0/") 29 | with self.assertRaises(ValueError): 30 | p.pack_derivation_path("m//0") 31 | with self.assertRaises(ValueError): 32 | p.pack_derivation_path("m/a") 33 | 34 | def test_level_path_0(self): 35 | packed = p.pack_derivation_path("m") 36 | self.assertEqual(packed[0], 0) 37 | self.assertEqual(len(packed), 1) 38 | 39 | def test_level_path_1(self): 40 | self._test_level_n(1) 41 | 42 | def test_level_path_2(self): 43 | self._test_level_n(2) 44 | 45 | def test_level_path_3(self): 46 | self._test_level_n(3) 47 | 48 | def test_level_path_4(self): 49 | self._test_level_n(4) 50 | 51 | def test_level_path_5(self): 52 | self._test_level_n(5) 53 | 54 | def test_level_path_6(self): 55 | self._test_level_n(6) 56 | 57 | def test_level_path_7(self): 58 | self._test_level_n(7) 59 | 60 | def test_level_path_8(self): 61 | self._test_level_n(8) 62 | 63 | def test_level_path_9(self): 64 | self._test_level_n(9) 65 | 66 | def test_level_path_10(self): 67 | self._test_level_n(10) 68 | 69 | def test_bitcoin_pack_derivation_path(self): 70 | base = b"\x03\x80\x00\x00\x2c\x80\x00\x00\x00\x00\x00\x00\x00" 71 | prefix = [b"\x00", b"\x01", b"\x02", b"\x03", b"\x04"] 72 | for i, format in enumerate(p.BtcDerivationPathFormat): 73 | self.assertEqual(prefix[i] + base, p.bitcoin_pack_derivation_path(format, "m/44'/0'/0")) 74 | 75 | def test_bitcoin_pack_derivation_path_nok(self): 76 | with self.assertRaises(ValueError): 77 | p.bitcoin_pack_derivation_path(max(p.BtcDerivationPathFormat) + 1, "") 78 | -------------------------------------------------------------------------------- /doc/source.rst: -------------------------------------------------------------------------------- 1 | Code documentation 2 | ================== 3 | 4 | .. contents:: 5 | :local: 6 | :backlinks: none 7 | 8 | 9 | ``ragger.backend`` 10 | ------------------ 11 | 12 | ``ragger.backend.interface`` 13 | ++++++++++++++++++++++++++++ 14 | 15 | Interface contract 16 | '''''''''''''''''' 17 | 18 | The contract a backend must respect: 19 | 20 | .. autoclass:: ragger.backend.BackendInterface 21 | :members: 22 | 23 | Response management policy 24 | '''''''''''''''''''''''''' 25 | 26 | To change the behavior of the backend on :term:`response APDU `, one can 27 | tinker with the ``RaisePolicy``: 28 | 29 | .. autoclass:: ragger.backend.interface.RaisePolicy 30 | :members: 31 | :undoc-members: 32 | 33 | Concrete backends 34 | +++++++++++++++++ 35 | 36 | Speculos backend (emulator) 37 | ''''''''''''''''''''''''''' 38 | 39 | .. autoclass:: ragger.backend.SpeculosBackend 40 | :members: 41 | 42 | Physical backends 43 | ''''''''''''''''' 44 | 45 | .. autoclass:: ragger.backend.LedgerCommBackend 46 | :members: 47 | 48 | .. autoclass:: ragger.backend.LedgerWalletBackend 49 | :members: 50 | 51 | ``ragger.error`` 52 | ---------------- 53 | 54 | .. autoclass:: ragger.error.ExceptionRAPDU 55 | 56 | 57 | ``ragger.firmware`` 58 | ------------------- 59 | 60 | Most ``Ragger`` high-level class needs to know which :term:`Firmware` they 61 | should expect. This is declared with this class: 62 | 63 | .. autoclass:: ragger.firmware.Firmware 64 | :members: 65 | :show-inheritance: 66 | 67 | .. autoattribute:: NANOS 68 | 69 | .. autoattribute:: NANOSP 70 | 71 | .. autoattribute:: NANOX 72 | 73 | .. autoattribute:: STAX 74 | 75 | .. autoattribute:: FLEX 76 | 77 | .. autoattribute:: APEX_P 78 | 79 | .. autoattribute:: APEX_M 80 | 81 | ``ragger.firmware.touch`` 82 | ++++++++++++++++++++++++ 83 | 84 | ``ragger.firmware.touch.screen`` 85 | ''''''''''''''''''''''''''''''' 86 | 87 | .. automodule:: ragger.firmware.touch.screen 88 | :members: 89 | 90 | ``ragger.firmware.touch.layouts`` 91 | ''''''''''''''''''''''''''''''''' 92 | 93 | .. automodule:: ragger.firmware.touch.layouts 94 | :members: 95 | :undoc-members: 96 | 97 | ``ragger.firmware.touch.use_cases`` 98 | '''''''''''''''''''''''''''''''''' 99 | 100 | .. automodule:: ragger.firmware.touch.use_cases 101 | :members: 102 | :undoc-members: 103 | 104 | ``ragger.navigator`` 105 | -------------------- 106 | 107 | Interface and instructions 108 | ++++++++++++++++++++++++++ 109 | 110 | .. automodule:: ragger.navigator.navigator 111 | :members: 112 | :undoc-members: 113 | 114 | ``ragger.utils`` 115 | ---------------- 116 | 117 | ``ragger.utils.structs`` 118 | ++++++++++++++++++++++++ 119 | 120 | .. autoclass:: ragger.utils.structs.RAPDU 121 | 122 | ``ragger.utils.misc`` 123 | +++++++++++++++++++++ 124 | 125 | .. autofunction:: ragger.utils.misc.app_path_from_app_name 126 | -------------------------------------------------------------------------------- /doc/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | .. contents:: 5 | :local: 6 | :backlinks: none 7 | 8 | Installation / integration 9 | -------------------------- 10 | 11 | Why all my tests are raising a ``ConnectionError`` when using SpeculosBackend? 12 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 13 | 14 | This indicates that :term:`Speculos` could not reach its server, generally 15 | because the emulator could not be started. 16 | 17 | With verbose options, you should be able to find in the logs the Speculos 18 | command line, which should look like: 19 | 20 | .. code-block:: bash 21 | 22 | /usr/bin/python3 -m speculos --model nanos --sdk 2.1 --display headless //.elf 23 | 24 | Try and launch this command line by itself to try and see the original error. It 25 | could be that: 26 | 27 | - the application ELF file does not exists 28 | - the ``model`` or ``sdk`` version does not exists 29 | - Speculos is already started elsewhere, and the network port are not available 30 | - Speculos is not installed 31 | - ``qemu-arm-static`` (used by Speculos under the hook) is not installed 32 | 33 | ...and if I'm getting a ``NotADirectoryError``? 34 | +++++++++++++++++++++++++++++++++++++++++++++++ 35 | 36 | This is a rare case that can occur if the user's ``$PATH`` contains 37 | dubiously-formed directories. (Re-)Installing ``qemu-arm-static`` (see 38 | :ref:`here `) seems to solve the issue. 39 | 40 | Architecture / code 41 | ------------------- 42 | 43 | Can I control how the backend behaves when receiving a response from the application? 44 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 45 | 46 | Backends can be instantiated with a 47 | :py:class:`RaisePolicy `, which controls 48 | how a backend should react when receiving a response from the 49 | :term:`application` it is communicating with. 50 | 51 | By default, this policy is ``RaisePolicy.RAISE_ALL_BUT_0x9000``, which means the 52 | backend will raise an :py:class:`ExceptionRAPDU ` 53 | if the :term:`APDU` returned by the :term:`application` does not end with 54 | ``b'0x9000'``, else returns a :py:class:`RAPDU ` 55 | instance. This behavior can be change with the three other options: 56 | 57 | - ``RaisePolicy.RAISE_NOTHING``, where the backend will never raise, and always 58 | returns a proper :py:class:`RAPDU `. 59 | - ``RaisePolicy.RAISE_ALL``, where the backend will always raise a 60 | :py:class:`ExceptionAPDU `, whatever the status. 61 | - ``RaisePolicy.RAISE_CUSTOM``, where the backend will raise a 62 | :py:class:`ExceptionAPDU `, for :term:`APDU` ending with 63 | status defined in ``whitelisted_status``. 64 | 65 | From that, every higher-level error management can be performed on top of 66 | ``Ragger``. 67 | -------------------------------------------------------------------------------- /src/ragger/navigator/instruction.py: -------------------------------------------------------------------------------- 1 | from enum import auto, Enum 2 | from typing import Any, Dict 3 | 4 | 5 | class BaseNavInsID(Enum): 6 | """ 7 | Base NavInsID class, allowing to define NavInsID specific to one's application 8 | while being compatible with all Navigator methods. 9 | """ 10 | pass 11 | 12 | 13 | class NavInsID(BaseNavInsID): 14 | """ 15 | Pre-defined instruction ID to navigate into a device UI. 16 | """ 17 | WAIT = auto() 18 | 19 | # Navigation instructions that embedded a call to 20 | # wait_for_screen_change() 21 | WAIT_FOR_SCREEN_CHANGE = auto() 22 | WAIT_FOR_HOME_SCREEN = auto() 23 | WAIT_FOR_TEXT_ON_SCREEN = auto() 24 | WAIT_FOR_TEXT_NOT_ON_SCREEN = auto() 25 | 26 | # Navigation instructions for Nano devices 27 | RIGHT_CLICK = auto() 28 | LEFT_CLICK = auto() 29 | BOTH_CLICK = auto() 30 | 31 | # Navigation instructions for Stax devices 32 | TOUCH = auto() 33 | SWIPE = auto() 34 | SWIPE_CENTER_TO_LEFT = auto() 35 | SWIPE_CENTER_TO_RIGHT = auto() 36 | 37 | # possible headers 38 | RIGHT_HEADER_TAP = auto() 39 | EXIT_HEADER_TAP = auto() 40 | INFO_HEADER_TAP = auto() 41 | LEFT_HEADER_TAP = auto() 42 | NAVIGATION_HEADER_TAP = auto() 43 | # possible centers 44 | CHOICE_CHOOSE = auto() 45 | SUGGESTION_CHOOSE = auto() 46 | TAPPABLE_CENTER_TAP = auto() 47 | KB_LETTER_ONLY_WRITE = auto() 48 | KB_LETTERS_WRITE = auto() 49 | KB_SPECIAL_CHAR_1_WRITE = auto() 50 | KB_SPECIAL_CHAR_2_WRITE = auto() 51 | # possible footers 52 | CENTERED_FOOTER_TAP = auto() 53 | CANCEL_FOOTER_TAP = auto() 54 | EXIT_FOOTER_TAP = auto() 55 | INFO_FOOTER_TAP = auto() 56 | # use cases 57 | USE_CASE_HOME_INFO = auto() 58 | USE_CASE_HOME_SETTINGS = auto() 59 | USE_CASE_HOME_QUIT = auto() 60 | USE_CASE_SETTINGS_SINGLE_PAGE_EXIT = auto() 61 | USE_CASE_SETTINGS_MULTI_PAGE_EXIT = auto() 62 | USE_CASE_SETTINGS_PREVIOUS = auto() 63 | USE_CASE_SETTINGS_NEXT = auto() 64 | USE_CASE_SUB_SETTINGS_EXIT = auto() 65 | USE_CASE_SUB_SETTINGS_PREVIOUS = auto() 66 | USE_CASE_SUB_SETTINGS_NEXT = auto() 67 | USE_CASE_CHOICE_CONFIRM = auto() 68 | USE_CASE_CHOICE_REJECT = auto() 69 | USE_CASE_STATUS_DISMISS = auto() 70 | USE_CASE_REVIEW_TAP = auto() 71 | USE_CASE_REVIEW_NEXT = auto() 72 | USE_CASE_REVIEW_PREVIOUS = auto() 73 | USE_CASE_REVIEW_REJECT = auto() 74 | USE_CASE_REVIEW_CONFIRM = auto() 75 | USE_CASE_VIEW_DETAILS_EXIT = auto() 76 | USE_CASE_VIEW_DETAILS_PREVIOUS = auto() 77 | USE_CASE_VIEW_DETAILS_NEXT = auto() 78 | USE_CASE_ADDRESS_CONFIRMATION_TAP = auto() 79 | USE_CASE_ADDRESS_CONFIRMATION_EXIT_QR = auto() 80 | USE_CASE_ADDRESS_CONFIRMATION_CONFIRM = auto() 81 | USE_CASE_ADDRESS_CONFIRMATION_CANCEL = auto() 82 | 83 | 84 | class NavIns: 85 | 86 | def __init__(self, id: BaseNavInsID, args=(), kwargs: Dict[str, Any] = {}): 87 | self.id = id 88 | self.args = args 89 | self.kwargs = kwargs 90 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=75", 4 | "setuptools_scm[toml]>=6.2", 5 | "wheel" 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [project] 10 | name = "ragger" 11 | authors = [{name = "Ledger", email = "hello@ledger.fr"}] 12 | description = "Testing framework using Speculos and LedgerComm as backends" 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "License :: OSI Approved :: Apache Software License", 21 | "Operating System :: POSIX :: Linux", 22 | "Operating System :: MacOS :: MacOS X", 23 | ] 24 | requires-python = ">=3.9" 25 | dependencies = [ 26 | "bip_utils>=2.4.0", 27 | "py-sr25519-bindings>=0.2.0,<0.3.0", 28 | "ledgered>=0.10.1", 29 | "toml" 30 | ] 31 | dynamic = ["version"] 32 | 33 | [project.readme] 34 | file = "README.md" 35 | content-type = "text/markdown" 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/LedgerHQ/ragger" 39 | "Bug Tracker" = "https://github.com/LedgerHQ/ragger/issues" 40 | 41 | [project.optional-dependencies] 42 | tests = [ 43 | "pytest", 44 | "pytest-cov", 45 | ] 46 | checkers = [ 47 | "yapf", 48 | "toml", 49 | "flake8", 50 | "flake8-pyproject", 51 | "mypy", 52 | "types-toml", 53 | "bandit", 54 | ] 55 | doc = [ 56 | # embeds pyreverse 57 | "pylint", 58 | "sphinx", 59 | "sphinx-rtd-theme", 60 | "sphinxcontrib-images", 61 | "sphinx-copybutton", 62 | "Jinja2>=3.0", 63 | # higher versions trigger build bugs with the RTD theme 64 | "docutils==0.16", 65 | ] 66 | speculos = [ 67 | "speculos>=0.13.1", 68 | "mnemonic", 69 | ] 70 | ledgercomm = [ 71 | "ledgercomm>=1.2.1", 72 | "ledgercomm[hid]>=1.2.1", 73 | "pyqt6", 74 | "pytesseract", 75 | "pillow", 76 | ] 77 | ledgerwallet = [ 78 | "ledgerwallet>=0.4.0", 79 | "pyqt6", 80 | "pytesseract", 81 | "pillow", 82 | ] 83 | all_backends = [ 84 | "ragger[speculos]", 85 | "ragger[ledgercomm]", 86 | "ragger[ledgerwallet]", 87 | ] 88 | 89 | [tool.setuptools] 90 | package-dir = {"" = "src"} 91 | include-package-data = true 92 | 93 | [tool.setuptools.packages.find] 94 | where = ["src"] 95 | exclude = ["tests"] 96 | namespaces = false 97 | 98 | [tool.setuptools.package-data] 99 | ragger = ["py.typed"] 100 | 101 | [tool.setuptools_scm] 102 | write_to = "src/ragger/__version__.py" 103 | local_scheme = "no-local-version" 104 | 105 | [tool.mypy] 106 | ignore_missing_imports = true 107 | 108 | [tool.yapf] 109 | based_on_style = "pep8" 110 | column_limit = 100 111 | 112 | [tool.coverage.report] 113 | show_missing = true 114 | exclude_lines = [ 115 | "@abstractmethod", 116 | "pragma: no cover" 117 | ] 118 | 119 | [tool.flake8] 120 | max-line-length = 120 121 | extend-ignore = "E502" 122 | -------------------------------------------------------------------------------- /src/ragger/firmware/touch/layouts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | import logging 17 | 18 | from .element import Element 19 | 20 | # Center 21 | ######## 22 | 23 | 24 | class ChoiceList(Element): 25 | 26 | def choose(self, index: int): 27 | assert 1 <= index <= 6, "Choice index must be in [1, 6]" 28 | self.client.finger_touch(*self.positions[index]) 29 | 30 | 31 | class Suggestions(Element): 32 | 33 | def choose(self, index: int): 34 | assert 1 <= index <= 4, "Suggestion index must be in [1, 4]" 35 | self.client.finger_touch(*self.positions[index]) 36 | 37 | 38 | # Keyboards 39 | class _GenericKeyboard(Element): 40 | 41 | def write(self, word: str): 42 | for letter in word.lower(): 43 | logging.info("Writing letter '%s', position '%s'", letter, self.positions[letter]) 44 | self.client.finger_touch(*self.positions[letter]) 45 | 46 | def back(self): 47 | self.client.finger_touch(*self.positions["back"]) 48 | 49 | 50 | class LetterOnlyKeyboard(_GenericKeyboard): 51 | pass 52 | 53 | 54 | class _FullKeyboard(_GenericKeyboard): 55 | 56 | def change_layout(self): 57 | self.client.finger_touch(*self.positions["change_layout"]) 58 | 59 | 60 | class FullKeyboardLetters(_FullKeyboard): 61 | 62 | def change_case(self): 63 | self.client.finger_touch(*self.positions["change_case"]) 64 | 65 | 66 | class _FullKeyboardSpecialCharacters(_FullKeyboard): 67 | 68 | def more_specials(self): 69 | self.client.finger_touch(*self.positions["more_specials"]) 70 | 71 | 72 | class FullKeyboardSpecialCharacters1(_FullKeyboardSpecialCharacters): 73 | pass 74 | 75 | 76 | class FullKeyboardSpecialCharacters2(_FullKeyboardSpecialCharacters): 77 | pass 78 | 79 | 80 | class _TappableElement(Element): 81 | 82 | def tap(self): 83 | self.client.finger_touch(*self.positions) 84 | 85 | 86 | # Center Info 87 | class TappableCenter(_TappableElement): 88 | pass 89 | 90 | 91 | # Headers 92 | ######### 93 | class RightHeader(_TappableElement): 94 | pass 95 | 96 | 97 | ExitHeader = RightHeader 98 | InfoHeader = RightHeader 99 | 100 | 101 | class LeftHeader(_TappableElement): 102 | pass 103 | 104 | 105 | NavigationHeader = LeftHeader 106 | 107 | 108 | # Footers 109 | ######### 110 | class CenteredFooter(_TappableElement): 111 | pass 112 | 113 | 114 | class LeftFooter(_TappableElement): 115 | pass 116 | 117 | 118 | class CancelFooter(_TappableElement): 119 | pass 120 | 121 | 122 | ExitFooter = CancelFooter 123 | InfoFooter = CancelFooter 124 | SettingsFooter = CancelFooter 125 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Ragger' 21 | copyright = '2022, bow' 22 | author = 'bow' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | html_favicon = "images/ragger.png" 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx_copybutton', 35 | 'sphinxcontrib.images', 36 | ] 37 | 38 | images_config = { 39 | "default_image_width": "90%", 40 | } 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | html_sidebars = { '**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'] } 46 | 47 | # List of patterns, relative to source directory, that match files and 48 | # directories to ignore when looking for source files. 49 | # This pattern also affects html_static_path and html_extra_path. 50 | exclude_patterns = ['build', 'Thumbs.db', '.DS_Store'] 51 | 52 | import os 53 | import sys 54 | 55 | sys.path.insert(0, os.path.abspath('../src/')) 56 | 57 | from importlib.metadata import version as get_version 58 | release = get_version('ragger') 59 | version = '.'.join(release.split('.')[:2]) 60 | 61 | ## Autodoc conf ## 62 | 63 | # Do not skip __init__ methods by default 64 | def skip(app, what, name, obj, would_skip, options): 65 | if name == "__init__": 66 | return False 67 | return would_skip 68 | 69 | # Remove every module documentation string. 70 | # Prevents to integrate the Licence when using automodule. 71 | # It is possible to limit the impacted module by filtering with the 'name' 72 | # argument: 73 | # `if what == "module" and name in ["ragger.firmware.stax.layouts", ...]:` 74 | def remove_module_docstring(app, what, name, obj, options, lines): 75 | if what == "module": 76 | del lines[:] 77 | 78 | ## Setup ## 79 | 80 | def setup(app): 81 | app.connect("autodoc-process-docstring", remove_module_docstring) 82 | app.connect("autodoc-skip-member", skip) 83 | 84 | # -- Options for HTML output ------------------------------------------------- 85 | 86 | # The theme to use for HTML and HTML Help pages. See the documentation for 87 | # a list of builtin themes. 88 | # 89 | html_theme = 'sphinx_rtd_theme' 90 | 91 | # Add any paths that contain custom static files (such as style sheets) here, 92 | # relative to this directory. They are copied after the builtin static files, 93 | # so a file named "default.css" will overwrite the builtin "default.css". 94 | html_static_path = ['_static'] 95 | -------------------------------------------------------------------------------- /src/ragger/conftest/configuration.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional, List 3 | 4 | 5 | @dataclass 6 | class OptionalOptions: 7 | APP_NAME: str 8 | MAIN_APP_DIR: Optional[str] 9 | SIDELOADED_APPS: dict 10 | SIDELOADED_APPS_DIR: Optional[str] 11 | BACKEND_SCOPE: str 12 | CUSTOM_SEED: str 13 | ALLOWED_SETUPS: List[str] 14 | 15 | 16 | OPTIONAL = OptionalOptions( 17 | # Use this parameter if you want physical Ragger backends (LedgerWallet and LedgerComm) to start 18 | # your application from the Dashboard at test start. 19 | APP_NAME=str(), 20 | 21 | # If not None, the application being tested with Ragger should be loaded as a library and not as 22 | # a standalone application. This parameter points to the repository holding the "main app", i.e 23 | # the application started from the Dashboard, which will then use the "local app" as a library. 24 | # Example use case: your application is an Ethereum plugin, which will be used as a library by 25 | # the Ethereum application. 26 | # MAIN_APP_DIR is the directory where the Ethereum application is cloned. 27 | # 28 | # example: configuration.OPTIONAL.MAIN_APP_DIR = "tests/.test_dependencies/" 29 | # Speculos will then start "tests/.test_dependencies/ethereum/build//bin/app.elf" 30 | # There must be exactly one application cloned inside this directory. 31 | MAIN_APP_DIR=None, 32 | 33 | # Deprecated 34 | SIDELOADED_APPS=dict(), 35 | 36 | # Relative path to the directory that will store library applications needed for the test. 37 | # They will be sideloaded by Speculos and can then be called by the main application. This 38 | # emulates the applications being installed on the device alongside the main application. 39 | # 40 | # example: configuration.OPTIONAL.SIDELOADED_APPS_DIR = "tests/.test_dependencies/libraries" 41 | # Speculos will then sideload "tests/.test_dependencies/libraries/*/build//bin/app.elf" 42 | SIDELOADED_APPS_DIR=None, 43 | 44 | # As the backend instantiation may take some time, Ragger supports multiple backend scopes. 45 | # You can choose to share the backend instance between {session / module / class / function} 46 | # When using "session" all your tests will share a single backend instance (faster) 47 | # When using "function" each test will have its independent backend instance (no collusion) 48 | BACKEND_SCOPE="class", 49 | 50 | # Use this parameter if you want speculos to use a custom seed instead of the default one. 51 | # This would result in speculos being launched with --seed 52 | # If a seed is provided through the "--seed" pytest command line option, it will override this one. 53 | # /!\ DO NOT USE SEEDS WITH REAL FUNDS /!\ 54 | CUSTOM_SEED=str(), 55 | 56 | # /!\ DEPRECATED /!\ 57 | # Use this parameter if you want ragger to handle running different test suites depending on setup 58 | # Useful when some tests need certain build options and other tests need other build options, or a 59 | # different Speculos command line 60 | # Adding a setup will allow you to decorate your tests with it using the following syntax 61 | # @pytest.mark.needs_setup('') 62 | # And run marked tests and only them using the --setup 63 | # 64 | # "default" setup is always allowed, all tests without explicit decoration depend on default 65 | # and the --setup option defaults to "default" 66 | # /!\ DEPRECATED /!\ 67 | ALLOWED_SETUPS=["default"], 68 | ) 69 | -------------------------------------------------------------------------------- /src/ragger/firmware/touch/use_cases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from .element import Element 17 | 18 | 19 | class UseCaseHome(Element): 20 | 21 | def info(self): 22 | self.client.finger_touch(*self.positions["info"]) 23 | 24 | def settings(self): 25 | self.client.finger_touch(*self.positions["settings"]) 26 | 27 | def quit(self): 28 | self.client.finger_touch(*self.positions["quit"]) 29 | 30 | 31 | class UseCaseHomeExt(UseCaseHome): 32 | 33 | def action(self): 34 | self.client.finger_touch(*self.positions["action"]) 35 | 36 | 37 | class UseCaseSettings(Element): 38 | 39 | def single_page_exit(self): 40 | self.client.finger_touch(*self.positions["single_page_exit"]) 41 | 42 | def multi_page_exit(self): 43 | self.client.finger_touch(*self.positions["multi_page_exit"]) 44 | 45 | def previous(self): 46 | self.client.finger_touch(*self.positions["previous"]) 47 | 48 | def next(self): 49 | self.client.finger_touch(*self.positions["next"]) 50 | 51 | 52 | class UseCaseSubSettings(Element): 53 | 54 | def exit(self): 55 | self.client.finger_touch(*self.positions["exit"]) 56 | 57 | def previous(self): 58 | self.client.finger_touch(*self.positions["previous"]) 59 | 60 | def next(self): 61 | self.client.finger_touch(*self.positions["next"]) 62 | 63 | 64 | class UseCaseChoice(Element): 65 | 66 | def confirm(self): 67 | self.client.finger_touch(*self.positions["confirm"]) 68 | 69 | def reject(self): 70 | self.client.finger_touch(*self.positions["reject"]) 71 | 72 | 73 | class UseCaseStatus(Element): 74 | 75 | def dismiss(self): 76 | self.client.finger_touch(*self.positions["dismiss"]) 77 | 78 | 79 | class UseCaseReview(Element): 80 | 81 | def tap(self): 82 | self.client.finger_touch(*self.positions["tap"]) 83 | 84 | def previous(self): 85 | self.client.finger_touch(*self.positions["previous"]) 86 | 87 | def reject(self): 88 | self.client.finger_touch(*self.positions["reject"]) 89 | 90 | def confirm(self): 91 | # SDK needs at least 2.4s for long press. 92 | self.client.finger_touch(*self.positions["confirm"], 3.0) 93 | 94 | 95 | class UseCaseViewDetails(Element): 96 | 97 | def exit(self): 98 | self.client.finger_touch(*self.positions["exit"]) 99 | 100 | def previous(self): 101 | self.client.finger_touch(*self.positions["previous"]) 102 | 103 | def next(self): 104 | self.client.finger_touch(*self.positions["next"]) 105 | 106 | 107 | class UseCaseAddressConfirmation(Element): 108 | 109 | def tap(self): 110 | self.client.finger_touch(*self.positions["tap"]) 111 | 112 | def exit_qr(self): 113 | self.client.finger_touch(*self.positions["exit_qr"]) 114 | 115 | def confirm(self): 116 | self.client.finger_touch(*self.positions["confirm"]) 117 | 118 | def cancel(self): 119 | self.client.finger_touch(*self.positions["cancel"]) 120 | -------------------------------------------------------------------------------- /template/usage.md: -------------------------------------------------------------------------------- 1 | # How to use the Ragger test framework 2 | 3 | This framework allows testing the application on the Speculos emulator or on a real device using LedgerComm or LedgerWallet 4 | 5 | 6 | ## Quickly get started with Ragger and Speculos 7 | 8 | ### Install ragger and dependencies 9 | 10 | ``` 11 | sudo apt-get update && sudo apt-get install qemu-user-static 12 | pip install .[speculos] 13 | ``` 14 | 15 | ### Compile the application 16 | 17 | The application to test must be compiled for all required devices. 18 | You can use for this the container `ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite`: 19 | ``` 20 | docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite:latest 21 | cd # replace with the name of your app, (eg boilerplate) 22 | 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 23 | make clean && make BOLOS_SDK=$_SDK # replace with one of [NANOS, NANOX, NANOSP, STAX] 24 | exit 25 | ``` 26 | 27 | ### Run a simple test using the Speculos emulator 28 | 29 | You can use the following command to get your first experience with Ragger and Speculos 30 | ``` 31 | pytest -v --tb=short --device nanox --display 32 | ``` 33 | Or you can refer to the section `Available pytest options` to configure the options you want to use 34 | 35 | 36 | ### Run a simple test using a real device 37 | 38 | The application to test must be loaded and started on a Ledger device plugged in USB. 39 | You can use for this the container `ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite`: 40 | ``` 41 | docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder-lite:latest 42 | cd app-/ # replace with the name of your app, (eg boilerplate) 43 | 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 44 | make clean && make BOLOS_SDK=$_SDK load # replace with one of [NANOS, NANOX, NANOSP, STAX] 45 | exit 46 | ``` 47 | 48 | You can use the following command to get your first experience with Ragger and Ledgerwallet on a NANOX. 49 | Make sure that the device is plugged, unlocked, and that the tested application is open. 50 | ``` 51 | pytest -v --tb=short --device nanox --backend ledgerwallet 52 | ``` 53 | Or you can refer to the section `Available pytest options` to configure the options you want to use 54 | 55 | 56 | ## Available pytest options 57 | 58 | Standard useful pytest options 59 | ``` 60 | -v formats the test summary in a readable way 61 | -s enable logs for successful tests, on Speculos it will enable app logs if compiled with DEBUG=1 62 | -k only run the tests that contain in their names 63 | --tb=short in case of errors, formats the test traceback in a readable way 64 | ``` 65 | 66 | Custom pytest options 67 | ``` 68 | --device run the test on the specified device [nanos,nanox,nanosp,stax,all]. This parameter is mandatory 69 | --backend run the tests against the backend [speculos, ledgercomm, ledgerwallet]. Speculos is the default 70 | --display on Speculos, enables the display of the app screen using QT 71 | --golden_run on Speculos, screen comparison functions will save the current screen instead of comparing 72 | --log_apdu_file log all apdu exchanges to the file in parameter. The previous file content is erased 73 | --seed on Speculos, use the seed (mnemonic) provided. 74 | ``` 75 | -------------------------------------------------------------------------------- /src/ragger/backend/stub.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2023 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from contextlib import contextmanager 17 | from pathlib import Path 18 | from types import TracebackType 19 | from typing import Any, Generator, Optional, Type 20 | 21 | from ragger.error import StatusWords 22 | from ragger.utils.structs import Crop, RAPDU 23 | from .interface import BackendInterface 24 | 25 | 26 | class StubBackend(BackendInterface): 27 | """ 28 | A stub implementation of the BackendInterface. 29 | 30 | Could be used everywhere a stub is needed, for instance when injecting a backend of various 31 | type in a broader service, which could use the backend, or not, without cluttering the code 32 | flow with conditional branches. 33 | """ 34 | 35 | def __enter__(self): 36 | pass 37 | 38 | def __exit__(self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], 39 | exc_tb: Optional[TracebackType]): 40 | pass 41 | 42 | def handle_usb_reset(self) -> None: 43 | pass 44 | 45 | def send_raw(self, data: bytes = b""): 46 | pass 47 | 48 | def receive(self) -> RAPDU: 49 | return RAPDU(StatusWords.SWO_SUCCESS, b"") 50 | 51 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 5 * 60 * 10) -> RAPDU: 52 | return RAPDU(StatusWords.SWO_SUCCESS, b"") 53 | 54 | @contextmanager 55 | def exchange_async_raw(self, data: bytes = b"") -> Generator[None, None, None]: 56 | yield 57 | 58 | def right_click(self) -> None: 59 | pass 60 | 61 | def left_click(self) -> None: 62 | pass 63 | 64 | def both_click(self) -> None: 65 | pass 66 | 67 | def finger_touch(self, x: int = 0, y: int = 0, delay: float = 0.5) -> None: 68 | pass 69 | 70 | def finger_swipe(self, 71 | x: int = 0, 72 | y: int = 0, 73 | direction: str = "left", 74 | delay: float = 0.5) -> None: 75 | pass 76 | 77 | def compare_screen_with_snapshot(self, 78 | golden_snap_path: Path, 79 | crop: Optional[Crop] = None, 80 | tmp_snap_path: Optional[Path] = None, 81 | golden_run: bool = False) -> bool: 82 | return True 83 | 84 | def wait_for_home_screen(self, timeout: float = 10.0) -> None: 85 | return 86 | 87 | def wait_for_screen_change(self, timeout: float = 10.0) -> None: 88 | pass 89 | 90 | def wait_for_text_on_screen(self, text: str, timeout: float = 10.0) -> None: 91 | return 92 | 93 | def wait_for_text_not_on_screen(self, text: str, timeout: float = 10.0) -> None: 94 | return 95 | 96 | def compare_screen_with_text(self, text: str) -> bool: 97 | return True 98 | 99 | def get_current_screen_content(self) -> Any: 100 | pass 101 | 102 | def pause_ticker(self) -> None: 103 | pass 104 | 105 | def resume_ticker(self) -> None: 106 | pass 107 | 108 | def send_tick(self) -> None: 109 | pass 110 | -------------------------------------------------------------------------------- /src/ragger/backend/ledgerwallet.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from contextlib import contextmanager 17 | from time import sleep 18 | from typing import Generator, Optional 19 | 20 | from ledgered.devices import Device 21 | from ledgerwallet.client import LedgerClient, CommException 22 | from ledgerwallet.transport import HidDevice 23 | 24 | from ragger.utils import RAPDU 25 | from ragger.error import ExceptionRAPDU 26 | from .physical_backend import PhysicalBackend 27 | 28 | 29 | def raise_policy_enforcer(function): 30 | 31 | def decoration(self: 'LedgerWalletBackend', *args, **kwargs) -> RAPDU: 32 | # Catch backend raise 33 | try: 34 | rapdu: RAPDU = function(self, *args, **kwargs) 35 | except CommException as error: 36 | rapdu = RAPDU(error.sw, error.data) 37 | 38 | self.apdu_logger.info("<= %s%4x", rapdu.data.hex(), rapdu.status) 39 | 40 | if self.is_raise_required(rapdu): 41 | raise ExceptionRAPDU(rapdu.status, rapdu.data) 42 | else: 43 | return rapdu 44 | 45 | return decoration 46 | 47 | 48 | class LedgerWalletBackend(PhysicalBackend): 49 | 50 | def __init__(self, device: Device, *args, with_gui: bool = False, **kwargs): 51 | super().__init__(device, *args, with_gui=with_gui, **kwargs) 52 | self._client: Optional[LedgerClient] = None 53 | 54 | def __enter__(self) -> "LedgerWalletBackend": 55 | self.logger.info(f"Starting {self.__class__.__name__} stream") 56 | try: 57 | self._client = LedgerClient() 58 | except Exception: 59 | # Give some time for the USB stack to power up and to be enumerated 60 | # Might be needed in successive tests where app is exited at the end of the test 61 | sleep(1) 62 | self._client = LedgerClient() 63 | return self 64 | 65 | def __exit__(self, *args): 66 | super().__exit__(*args) 67 | assert self._client is not None 68 | self._client.close() 69 | 70 | def handle_usb_reset(self) -> None: 71 | self.logger.info(f"Re-starting {self.__class__.__name__} stream") 72 | self.__exit__() 73 | self.__enter__() 74 | 75 | def send_raw(self, data: bytes = b"") -> None: 76 | self.apdu_logger.info("=> %s", data.hex()) 77 | assert self._client is not None 78 | self._client.device.write(data) 79 | 80 | @raise_policy_enforcer 81 | def receive(self) -> RAPDU: 82 | assert self._client is not None 83 | # TODO: remove this checked with LedgerWallet > 0.1.3 84 | if isinstance(self._client.device, HidDevice): 85 | raw_result = self._client.device.read(1000) 86 | else: 87 | raw_result = self._client.device.read() 88 | status, payload = int.from_bytes(raw_result[-2:], "big"), raw_result[:-2] or b"" 89 | result = RAPDU(status, payload) 90 | return result 91 | 92 | @raise_policy_enforcer 93 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 0) -> RAPDU: 94 | self.apdu_logger.info("=> %s", data.hex()) 95 | assert self._client is not None 96 | raw_result = self._client.raw_exchange(data) 97 | result = RAPDU(int.from_bytes(raw_result[-2:], "big"), raw_result[:-2] or b"") 98 | return result 99 | 100 | @contextmanager 101 | def exchange_async_raw(self, data: bytes = b"") -> Generator[bool, None, None]: 102 | self.send_raw(data) 103 | yield True 104 | self._last_async_response = self.receive() 105 | -------------------------------------------------------------------------------- /src/ragger/backend/ledgercomm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from contextlib import contextmanager 17 | from pathlib import Path 18 | from time import sleep 19 | from typing import Optional, Generator 20 | 21 | from ledgercomm import Transport 22 | from ledgered.devices import Device 23 | 24 | from ragger.utils import RAPDU 25 | from ragger.error import ExceptionRAPDU 26 | from .physical_backend import PhysicalBackend 27 | 28 | 29 | def raise_policy_enforcer(function): 30 | 31 | def decoration(self: 'LedgerCommBackend', *args, **kwargs) -> RAPDU: 32 | rapdu: RAPDU = function(self, *args, **kwargs) 33 | 34 | self.apdu_logger.info("<= %s%4x", rapdu.data.hex(), rapdu.status) 35 | 36 | if self.is_raise_required(rapdu): 37 | raise ExceptionRAPDU(rapdu.status, rapdu.data) 38 | else: 39 | return rapdu 40 | 41 | return decoration 42 | 43 | 44 | class LedgerCommBackend(PhysicalBackend): 45 | 46 | def __init__(self, 47 | device: Device, 48 | *args, 49 | host: str = "127.0.0.1", 50 | port: int = 9999, 51 | interface: str = 'hid', 52 | log_apdu_file: Optional[Path] = None, 53 | with_gui: bool = False, 54 | **kwargs): 55 | super().__init__(device, *args, log_apdu_file=log_apdu_file, with_gui=with_gui, **kwargs) 56 | self._host = host 57 | self._port = port 58 | self._client: Optional[Transport] = None 59 | kwargs['interface'] = interface 60 | self._args = (args, kwargs) 61 | 62 | def __enter__(self) -> "LedgerCommBackend": 63 | self.logger.info(f"Starting {self.__class__.__name__} stream") 64 | 65 | try: 66 | self._client = Transport(server=self._host, 67 | port=self._port, 68 | *self._args[0], 69 | **self._args[1]) 70 | except Exception: 71 | # Give some time for the USB stack to power up and to be enumerated 72 | # Might be needed in successive tests where app is exited at the end of the test 73 | sleep(1) 74 | self._client = Transport(server=self._host, 75 | port=self._port, 76 | *self._args[0], 77 | **self._args[1]) 78 | return self 79 | 80 | def __exit__(self, *args): 81 | super().__exit__(*args) 82 | assert self._client is not None 83 | self._client.close() 84 | 85 | def handle_usb_reset(self) -> None: 86 | self.logger.info(f"Re-starting {self.__class__.__name__} stream") 87 | self.__exit__() 88 | self.__enter__() 89 | 90 | def send_raw(self, data: bytes = b"") -> None: 91 | self.apdu_logger.info("=> %s", data.hex()) 92 | assert self._client is not None 93 | self._client.send_raw(data) 94 | 95 | @raise_policy_enforcer 96 | def receive(self) -> RAPDU: 97 | assert self._client is not None 98 | result = RAPDU(*self._client.recv()) 99 | return result 100 | 101 | @raise_policy_enforcer 102 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 0) -> RAPDU: 103 | self.apdu_logger.info("=> %s", data.hex()) 104 | assert self._client is not None 105 | result = RAPDU(*self._client.exchange_raw(data)) 106 | return result 107 | 108 | @contextmanager 109 | def exchange_async_raw(self, data: bytes = b"") -> Generator[bool, None, None]: 110 | self.send_raw(data) 111 | yield True 112 | self._last_async_response = self.receive() 113 | -------------------------------------------------------------------------------- /tests/stubs.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from io import BytesIO 3 | from multiprocessing import Process 4 | from pathlib import Path 5 | 6 | from flask import Flask, request 7 | from PIL import Image 8 | from requests import get 9 | from requests.exceptions import ConnectionError 10 | 11 | 12 | class APDUStatus(IntEnum): 13 | SUCCESS = 0x9000 14 | ERROR = 0x8000 15 | 16 | 17 | class EndPoint: 18 | ROOT = "01" 19 | APDU = "02" 20 | EVENTS = "03" 21 | 22 | 23 | class Events: 24 | back = [{"text": "Back", "x": 51, "y": 19}] 25 | info = [{ 26 | "text": "Boilerplate App", 27 | "x": 20, 28 | "y": 3 29 | }, { 30 | "text": "(c) 2020 Ledger", 31 | "x": 26, 32 | "y": 17 33 | }] 34 | home = [{"text": "Boilerplate", "x": 41, "y": 3}, {"text": "is ready", "x": 41, "y": 17}] 35 | version = [{"text": "Version", "x": 43, "y": 3}, {"text": "1.0.1", "x": 52, "y": 17}] 36 | about = [{"text": "About", "x": 47, "y": 19}] 37 | indexed = [home, version, about, info, back] 38 | 39 | 40 | # can't use lambdas: Flask stores functions using their names (and lambdas have none, so they'll 41 | # override each other) 42 | # Returned value must be a JSON, embedding a 'data' field with an hexa string ending with 9000 for 43 | # success 44 | def root(*args): 45 | return {"data": EndPoint.ROOT + f"{APDUStatus.SUCCESS:x}"} 46 | 47 | 48 | def apdu(*args): 49 | apdu = bytes.fromhex(request.get_json().get("data", "00")) 50 | status = f"{APDUStatus.SUCCESS:x}" if apdu[0] == 0x00 else f"{APDUStatus.ERROR:x}" 51 | return {"data": EndPoint.APDU + status} 52 | 53 | 54 | class Actions: 55 | 56 | def __init__(self): 57 | self.idx = 0 58 | 59 | def button(self, *args): 60 | path = request.path 61 | if "right" in path: 62 | if self.idx == 2: 63 | self.idx = 0 64 | elif self.idx == 4: 65 | self.idx = 3 66 | else: 67 | self.idx = self.idx + 1 68 | elif "left" in path: 69 | if self.idx == 0: 70 | self.idx = 2 71 | elif self.idx == 3: 72 | self.idx = 4 73 | else: 74 | self.idx = self.idx - 1 75 | elif "both" in path: 76 | if self.idx == 2: 77 | self.idx = 3 78 | if self.idx == 4: 79 | self.idx = 0 80 | 81 | return {} 82 | 83 | def events(self, *args): 84 | return {"events": Events.indexed[self.idx]}, 200 85 | 86 | def screenshot(self, *args): 87 | path = Path( 88 | __file__).parent.resolve() / "snapshots/nanos/generic" / f"{str(self.idx).zfill(5)}.png" 89 | img_temp = Image.open(path) 90 | iobytes = BytesIO() 91 | img_temp.save(iobytes, format="PNG") 92 | return iobytes.getvalue(), 200 93 | 94 | def ticker(self, *args): 95 | return {} 96 | 97 | 98 | class SpeculosServerStub: 99 | 100 | def __init__(self): 101 | actions = Actions() 102 | self.app = Flask('stub') 103 | self.app.add_url_rule("/", view_func=root) 104 | self.app.add_url_rule("/apdu", methods=["GET", "POST"], view_func=apdu) 105 | self.app.add_url_rule("/button/right", methods=["GET", "POST"], view_func=actions.button) 106 | self.app.add_url_rule("/button/left", methods=["GET", "POST"], view_func=actions.button) 107 | self.app.add_url_rule("/button/both", methods=["GET", "POST"], view_func=actions.button) 108 | self.app.add_url_rule("/events", view_func=actions.events) 109 | self.app.add_url_rule("/screenshot", methods=["GET"], view_func=actions.screenshot) 110 | self.app.add_url_rule("/ticker", methods=["GET", "POST"], view_func=actions.ticker) 111 | self.process = None 112 | 113 | def __enter__(self): 114 | self.process = Process(target=self.app.run) 115 | self.process.start() 116 | started = False 117 | while not started: 118 | try: 119 | get("http://127.0.0.1:5000") 120 | started = True 121 | except ConnectionError: 122 | pass 123 | 124 | def __exit__(self, *args, **kwargs): 125 | self.process.terminate() 126 | self.process.join() 127 | self.process.close() 128 | self.process = None 129 | -------------------------------------------------------------------------------- /.github/workflows/build_and_tests.yml: -------------------------------------------------------------------------------- 1 | name: Build, test and deploy Ragger 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - '*' 8 | branches: 9 | - master 10 | - develop 11 | pull_request: 12 | branches: 13 | - master 14 | - develop 15 | 16 | # Cancel previous runs on this reference 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.ref }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | 23 | build_boilerplate_application: 24 | name: Build boilerplate application using the reusable workflow 25 | uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_build.yml@v1 26 | with: 27 | app_repository: LedgerHQ/app-boilerplate 28 | app_branch_name: master 29 | upload_app_binaries_artifact: boilerplate_binaries 30 | 31 | build_boilerplate_application_nanos: 32 | name: Build boilerplate application for Nanos S using the reusable workflow 33 | uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_build.yml@v1 34 | with: 35 | app_repository: LedgerHQ/app-boilerplate 36 | app_branch_name: nanos_baseline 37 | upload_app_binaries_artifact: boilerplate_binaries_nanos 38 | 39 | prepare_matrix: 40 | name: Prepare matrix from TOML file 41 | runs-on: ubuntu-latest 42 | outputs: 43 | devices: ${{ steps.get_devices.outputs.devices }} 44 | env: 45 | APP_MANIFEST: "ledger_app.toml" 46 | 47 | steps: 48 | - name: Clone 49 | uses: actions/checkout@v4 50 | - name: Install ledgered 51 | run: pip install ledgered 52 | - name: Get devices from TOML 53 | id: get_devices 54 | run: | 55 | # get device list as a single line json formatted value 56 | compatible_devices="$(ledger-manifest --output-devices "${APP_MANIFEST}" -j | jq -rc '.devices')" 57 | 58 | echo "devices=${compatible_devices}" | sed 's/+/p/' >> "${GITHUB_OUTPUT}" 59 | 60 | build_install_test: 61 | name: Install and test the library 62 | needs: [prepare_matrix, build_boilerplate_application, build_boilerplate_application_nanos] 63 | runs-on: ubuntu-latest 64 | strategy: 65 | fail-fast: false 66 | matrix: 67 | python_version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 68 | device: ${{ fromJson(needs.prepare_matrix.outputs.devices) }} 69 | 70 | steps: 71 | - name: Clone 72 | uses: actions/checkout@v4 73 | with: 74 | fetch-depth: 0 75 | 76 | - name: Setup Python version 77 | uses: actions/setup-python@v5 78 | with: 79 | python-version: ${{ matrix.python_version }} 80 | 81 | - name: Speculos dependencies 82 | run: sudo apt-get update && sudo apt-get install -y qemu-user-static tesseract-ocr libtesseract-dev python3-pyqt6 83 | 84 | - name: Build & install 85 | run: | 86 | pip install -U .[tests,all_backends] 87 | pip install -U "click>=8" 88 | 89 | - name: Install speculos for Nanos 90 | if: ${{ matrix.device == 'nanos' }} 91 | run: | 92 | pip install -U speculos==v0.25.5 93 | 94 | - name: Download app binaries 95 | if: ${{ matrix.device != 'nanos' }} 96 | uses: actions/download-artifact@v4 97 | with: 98 | name: boilerplate_binaries 99 | path: ./build/ 100 | 101 | - name: Download app binaries for Nano S 102 | if: ${{ matrix.device == 'nanos' }} 103 | uses: actions/download-artifact@v4 104 | with: 105 | name: boilerplate_binaries_nanos 106 | path: ./build/ 107 | 108 | - name: Check the downloaded files 109 | run: tree . 110 | 111 | - name: Run unit tests and generate coverage 112 | run: pytest -v --tb=short tests/unit --cov ragger --cov-report xml 113 | 114 | - name: Run functional tests and generate coverage 115 | run: pytest -v --tb=short tests/functional --cov ragger --cov-report xml --cov-append --device ${{ matrix.device }} 116 | 117 | - name: Upload to codecov.io 118 | uses: codecov/codecov-action@v5 119 | env: 120 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 121 | with: 122 | name: codecov-ragger 123 | flags: ${{ matrix.device }}-py${{ matrix.python_version }} 124 | 125 | package_and_deploy: 126 | name: Build and deploy the Ragger Python package 127 | needs: [build_install_test] 128 | uses: LedgerHQ/ledger-app-workflows/.github/workflows/reusable_pypi_deployment.yml@v1 129 | with: 130 | package_name: ragger 131 | publish: ${{ startsWith(github.ref, 'refs/tags/') }} 132 | secrets: 133 | pypi_token: ${{ secrets.PYPI_PUBLIC_API_TOKEN }} 134 | -------------------------------------------------------------------------------- /src/ragger/navigator/touch_navigator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from time import sleep 17 | from typing import Callable, Dict 18 | from ledgered.devices import Device 19 | 20 | from ragger.backend import BackendInterface 21 | from ragger.firmware.touch import FullScreen 22 | from .navigator import Navigator 23 | from .instruction import BaseNavInsID, NavInsID 24 | 25 | 26 | class TouchNavigator(Navigator): 27 | 28 | def __init__(self, backend: BackendInterface, device: Device, golden_run: bool = False): 29 | if not device.touchable: 30 | raise ValueError(f"'{self.__class__.__name__}' only works with touchable devices") 31 | screen = FullScreen(backend, device) 32 | callbacks: Dict[BaseNavInsID, Callable] = { 33 | NavInsID.WAIT: sleep, 34 | NavInsID.WAIT_FOR_SCREEN_CHANGE: backend.wait_for_screen_change, 35 | NavInsID.WAIT_FOR_HOME_SCREEN: backend.wait_for_home_screen, 36 | NavInsID.WAIT_FOR_TEXT_ON_SCREEN: backend.wait_for_text_on_screen, 37 | NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN: backend.wait_for_text_not_on_screen, 38 | NavInsID.TOUCH: backend.finger_touch, 39 | NavInsID.SWIPE: backend.finger_swipe, 40 | NavInsID.SWIPE_CENTER_TO_LEFT: screen.center.swipe_left, 41 | NavInsID.SWIPE_CENTER_TO_RIGHT: screen.center.swipe_right, 42 | # possible headers 43 | NavInsID.RIGHT_HEADER_TAP: screen.right_header.tap, 44 | NavInsID.EXIT_HEADER_TAP: screen.exit_header.tap, 45 | NavInsID.INFO_HEADER_TAP: screen.info_header.tap, 46 | NavInsID.LEFT_HEADER_TAP: screen.left_header.tap, 47 | NavInsID.NAVIGATION_HEADER_TAP: screen.navigation_header.tap, 48 | # possible centers 49 | NavInsID.CHOICE_CHOOSE: screen.choice_list.choose, 50 | NavInsID.SUGGESTION_CHOOSE: screen.suggestions.choose, 51 | NavInsID.TAPPABLE_CENTER_TAP: screen.tappable_center.tap, 52 | NavInsID.KB_LETTER_ONLY_WRITE: screen.letter_only_keyboard.write, 53 | NavInsID.KB_LETTERS_WRITE: screen.full_keyboard_letters.write, 54 | NavInsID.KB_SPECIAL_CHAR_1_WRITE: screen.full_keyboard_special_characters_1.write, 55 | NavInsID.KB_SPECIAL_CHAR_2_WRITE: screen.full_keyboard_special_characters_2.write, 56 | # possible footers 57 | NavInsID.CENTERED_FOOTER_TAP: screen.centered_footer.tap, 58 | NavInsID.CANCEL_FOOTER_TAP: screen.cancel_footer.tap, 59 | NavInsID.EXIT_FOOTER_TAP: screen.exit_footer.tap, 60 | NavInsID.INFO_FOOTER_TAP: screen.info_footer.tap, 61 | # possible use cases 62 | NavInsID.USE_CASE_HOME_INFO: screen.home.info, 63 | NavInsID.USE_CASE_HOME_SETTINGS: screen.home.settings, 64 | NavInsID.USE_CASE_HOME_QUIT: screen.home.quit, 65 | NavInsID.USE_CASE_SETTINGS_SINGLE_PAGE_EXIT: screen.settings.single_page_exit, 66 | NavInsID.USE_CASE_SETTINGS_MULTI_PAGE_EXIT: screen.settings.multi_page_exit, 67 | NavInsID.USE_CASE_SETTINGS_PREVIOUS: screen.settings.previous, 68 | NavInsID.USE_CASE_SETTINGS_NEXT: screen.settings.next, 69 | NavInsID.USE_CASE_SUB_SETTINGS_EXIT: screen.sub_settings.exit, 70 | NavInsID.USE_CASE_SUB_SETTINGS_PREVIOUS: screen.sub_settings.previous, 71 | NavInsID.USE_CASE_SUB_SETTINGS_NEXT: screen.sub_settings.next, 72 | NavInsID.USE_CASE_CHOICE_CONFIRM: screen.choice.confirm, 73 | NavInsID.USE_CASE_CHOICE_REJECT: screen.choice.reject, 74 | NavInsID.USE_CASE_STATUS_DISMISS: screen.status.dismiss, 75 | NavInsID.USE_CASE_REVIEW_TAP: screen.review.tap, 76 | NavInsID.USE_CASE_REVIEW_NEXT: screen.center.swipe_left, 77 | NavInsID.USE_CASE_REVIEW_PREVIOUS: screen.review.previous, 78 | NavInsID.USE_CASE_REVIEW_REJECT: screen.review.reject, 79 | NavInsID.USE_CASE_REVIEW_CONFIRM: screen.review.confirm, 80 | NavInsID.USE_CASE_VIEW_DETAILS_EXIT: screen.view_details.exit, 81 | NavInsID.USE_CASE_VIEW_DETAILS_PREVIOUS: screen.view_details.previous, 82 | NavInsID.USE_CASE_VIEW_DETAILS_NEXT: screen.view_details.next, 83 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_TAP: screen.address_confirmation.tap, 84 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_EXIT_QR: screen.address_confirmation.exit_qr, 85 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_CONFIRM: screen.address_confirmation.confirm, 86 | NavInsID.USE_CASE_ADDRESS_CONFIRMATION_CANCEL: screen.address_confirmation.cancel, 87 | } 88 | super().__init__(backend, device, callbacks, golden_run) 89 | -------------------------------------------------------------------------------- /tests/unit/backend/test_interface.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import tempfile 3 | from ledgered.devices import DeviceType, Devices 4 | from pathlib import Path 5 | from unittest import TestCase 6 | from unittest.mock import MagicMock 7 | 8 | from ragger.error import ExceptionRAPDU 9 | from ragger.backend import BackendInterface 10 | from ragger.backend import RaisePolicy 11 | 12 | 13 | class DummyBackend(BackendInterface): 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | self.mock = MagicMock() 18 | 19 | def __enter__(self): 20 | yield self 21 | 22 | def __exit__(self, *args, **kwargs): 23 | pass 24 | 25 | def both_click(self): 26 | self.mock.both_click() 27 | 28 | def right_click(self): 29 | self.mock.right_click() 30 | 31 | def left_click(self): 32 | self.mock.left_click() 33 | 34 | def exchange_async_raw(self, *args, **kwargs): 35 | return self.mock.exchange_async_raw(*args, **kwargs) 36 | 37 | def exchange_raw(self, data: bytes, tick_timeout: int): 38 | return self.mock.exchange_raw(data, tick_timeout) 39 | 40 | def receive(self): 41 | return self.mock.receive() 42 | 43 | def send_raw(self, *args, **kwargs): 44 | self.mock.send_raw(*args, **kwargs) 45 | 46 | def finger_touch(self, *args, **kwargs): 47 | self.mock.finger_touch(*args, **kwargs) 48 | 49 | def finger_swipe(self, *args, **kwargs): 50 | self.mock.finger_touch(*args, **kwargs) 51 | 52 | def compare_screen_with_snapshot(self, snap_path, crop=None) -> bool: 53 | return self.mock.compare_screen_with_snapshot() 54 | 55 | def save_screen_snapshot(self, path) -> None: 56 | self.mock.save_screen_snapshot() 57 | 58 | def wait_for_home_screen(self, timeout: float = 10.0, context: list = []): 59 | return self.mock.wait_for_home_screen() 60 | 61 | def wait_for_screen_change(self, timeout: float = 10.0, context: list = []): 62 | return self.mock.wait_for_screen_change() 63 | 64 | def wait_for_text_on_screen(self, text: str, timeout: float = 10.0) -> None: 65 | self.mock.wait_for_text_on_screen() 66 | 67 | def wait_for_text_not_on_screen(self, text: str, timeout: float = 10.0) -> None: 68 | self.mock.wait_for_text_not_on_screen() 69 | 70 | def compare_screen_with_text(self, text: str): 71 | return self.mock.compare_screen_with_text() 72 | 73 | def get_current_screen_content(self): 74 | return self.mock.get_current_screen_content() 75 | 76 | def pause_ticker(self) -> None: 77 | pass 78 | 79 | def resume_ticker(self) -> None: 80 | pass 81 | 82 | def send_tick(self) -> None: 83 | pass 84 | 85 | 86 | class TestBackendInterface(TestCase): 87 | 88 | def setUp(self): 89 | self.device = Devices.get_by_type(DeviceType.NANOS) 90 | self.errors = (ExceptionRAPDU(0x8888, "ERROR1"), ExceptionRAPDU(0x7777, "ERROR2")) 91 | self.valid_statuses = (0x9000, 0x9001, 0x9002) 92 | self.backend = DummyBackend(device=self.device) 93 | 94 | def test_init(self): 95 | self.assertEqual(self.backend.device, self.device) 96 | self.assertIsNone(self.backend.last_async_response) 97 | 98 | # Default value 99 | self.assertEqual(self.backend.raise_policy, RaisePolicy.RAISE_ALL_BUT_0x9000) 100 | 101 | def test_send(self): 102 | cla, ins, p1, p2 = 1, 2, 3, 4 103 | expected = struct.pack(">BBBBB", cla, ins, p1, p2, 0) 104 | self.assertFalse(self.backend.mock.send_raw.called) 105 | self.backend.send(cla, ins, p1, p2) 106 | self.assertTrue(self.backend.mock.send_raw.called) 107 | self.assertEqual(self.backend.mock.send_raw.call_args, ((expected, ), )) 108 | 109 | def test_exchange(self): 110 | cla, ins, p1, p2 = 1, 2, 3, 4 111 | expected = struct.pack(">BBBBB", cla, ins, p1, p2, 0) 112 | self.assertFalse(self.backend.mock.send_raw.called) 113 | result = self.backend.exchange(cla, ins, p1, p2) 114 | self.assertTrue(self.backend.mock.exchange_raw.called) 115 | self.assertEqual(self.backend.mock.exchange_raw.call_args, ((expected, 5 * 60 * 10), )) 116 | self.assertEqual(result, self.backend.mock.exchange_raw()) 117 | 118 | def test_exchange_async(self): 119 | cla, ins, p1, p2 = 1, 2, 3, 4 120 | expected = struct.pack(">BBBBB", cla, ins, p1, p2, 0) 121 | self.assertFalse(self.backend.mock.send_raw.called) 122 | with self.backend.exchange_async(cla, ins, p1, p2): 123 | pass 124 | self.assertTrue(self.backend.mock.exchange_async_raw.called) 125 | self.assertEqual(self.backend.mock.exchange_async_raw.call_args, ((expected, ), )) 126 | 127 | 128 | class TestBackendInterfaceLogging(TestCase): 129 | 130 | def test_log_apdu(self): 131 | self.device = Devices.get_by_type(DeviceType.NANOS) 132 | with tempfile.TemporaryDirectory() as td: 133 | test_file = (Path(td) / "test_log_file.log").resolve() 134 | self.backend = DummyBackend(device=self.device, log_apdu_file=test_file) 135 | ref_lines = ["Test logging", "hello world", "Lorem Ipsum"] 136 | for l in ref_lines: 137 | self.backend.apdu_logger.info(l) 138 | with open(test_file, mode='r') as fp: 139 | read_lines = [l.strip() for l in fp.readlines()] 140 | self.assertEqual(read_lines, ref_lines) 141 | -------------------------------------------------------------------------------- /tests/unit/backend/test_ledgercomm.py: -------------------------------------------------------------------------------- 1 | from ledgered.devices import DeviceType, Devices 2 | from unittest import TestCase 3 | from unittest.mock import patch 4 | 5 | from ragger.error import ExceptionRAPDU 6 | from ragger.utils import RAPDU 7 | from ragger.backend import LedgerCommBackend 8 | from ragger.backend import RaisePolicy 9 | 10 | 11 | class TestLedgerCommbackend(TestCase): 12 | """ 13 | Test patterns explained: 14 | 15 | ``` 16 | def test_something(self): 17 | # patches the HID transport layer used in LedgerComm to communicate with the USB 18 | with patch("ledgercomm.transport.HID") as mock: 19 | # saving the mock somewhere to check .open .send .recv methods 20 | self.hid = mock 21 | # starts the backend 22 | with self.backend: 23 | ``` 24 | """ 25 | 26 | def setUp(self): 27 | self.device = Devices.get_by_type(DeviceType.NANOS) 28 | self.backend = LedgerCommBackend(self.device) 29 | 30 | def check_rapdu(self, rapdu: RAPDU, status: int = 0x9000, payload: bytes = None): 31 | self.assertIsInstance(rapdu, RAPDU) 32 | self.assertEqual(rapdu.status, status) 33 | self.assertEqual(rapdu.data, payload) 34 | 35 | def test_contextmanager(self): 36 | with patch("ledgercomm.transport.HID") as mock: 37 | self.hid = mock 38 | with self.backend: 39 | self.assertTrue(self.hid.called) 40 | self.assertTrue(self.hid().open.called) 41 | self.assertFalse(self.hid().close.called) 42 | self.assertTrue(self.hid().close.called) 43 | self.assertEqual(self.backend._client.com, self.hid()) 44 | 45 | def test_send_raw(self): 46 | with patch("ledgercomm.transport.HID") as mock: 47 | self.hid = mock 48 | with self.backend: 49 | self.assertFalse(self.hid().send.called) 50 | self.backend.send_raw(bytes.fromhex("00000000")) 51 | self.assertTrue(self.hid().send.called) 52 | 53 | def test_receive_ok(self): 54 | success, payload = 0x9000, b"something" 55 | with patch("ledgercomm.transport.HID") as mock: 56 | self.hid = mock 57 | self.hid().recv.return_value = (success, payload) 58 | with self.backend: 59 | rapdu = self.backend.receive() 60 | self.check_rapdu(rapdu, payload=payload) 61 | 62 | def test_receive_ok_raises(self): 63 | failure, payload = 0x9000, b"something" 64 | with patch("ledgercomm.transport.HID") as mock: 65 | self.hid = mock 66 | self.hid().recv.return_value = (failure, payload) 67 | with self.backend: 68 | self.backend.raise_policy = RaisePolicy.RAISE_ALL 69 | with self.assertRaises(ExceptionRAPDU) as error: 70 | self.backend.receive() 71 | self.assertEqual(error.exception.status, failure) 72 | 73 | def test_receive_nok(self): 74 | failure, payload = 0x8000, b"something" 75 | with patch("ledgercomm.transport.HID") as mock: 76 | self.hid = mock 77 | self.hid().recv.return_value = (failure, payload) 78 | with self.backend: 79 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING 80 | rapdu = self.backend.receive() 81 | self.check_rapdu(rapdu, status=failure, payload=payload) 82 | 83 | def test_receive_nok_raises(self): 84 | failure, payload = 0x8000, b"something" 85 | with patch("ledgercomm.transport.HID") as mock: 86 | self.hid = mock 87 | self.hid().recv.return_value = (failure, payload) 88 | with self.backend: 89 | with self.assertRaises(ExceptionRAPDU) as error: 90 | self.backend.receive() 91 | self.assertEqual(error.exception.status, failure) 92 | 93 | def test_exchange_raw(self): 94 | success, payload = 0x9000, b"something" 95 | with patch("ledgercomm.transport.HID") as mock: 96 | self.hid = mock 97 | self.hid().exchange.return_value = (success, payload) 98 | with self.backend: 99 | self.assertFalse(self.hid().exchange.called) 100 | rapdu = self.backend.exchange_raw(b"") 101 | self.assertTrue(self.hid().exchange.called) 102 | self.check_rapdu(rapdu, payload=payload) 103 | 104 | def test_exchange_raw_raises(self): 105 | failure, payload = 0x8000, b"something" 106 | with patch("ledgercomm.transport.HID") as mock: 107 | self.hid = mock 108 | self.hid().exchange.return_value = (failure, payload) 109 | with self.backend: 110 | with self.assertRaises(ExceptionRAPDU): 111 | self.backend.exchange_raw(b"") 112 | 113 | def test_exchange_async_raw(self): 114 | success, payload = 0x9000, b"something" 115 | with patch("ledgercomm.transport.HID") as mock: 116 | self.hid = mock 117 | self.hid().recv.return_value = (success, payload) 118 | with self.backend: 119 | self.assertIsNone(self.backend.last_async_response) 120 | with self.backend.exchange_async_raw(b""): 121 | self.backend.right_click() 122 | self.backend.left_click() 123 | self.backend.both_click() 124 | self.assertIsNotNone(self.backend.last_async_response) 125 | self.check_rapdu(self.backend.last_async_response, payload=payload) 126 | -------------------------------------------------------------------------------- /tests/functional/test_boilerplate.py: -------------------------------------------------------------------------------- 1 | from requests.exceptions import ConnectionError 2 | 3 | import pytest 4 | import time 5 | from pathlib import Path 6 | 7 | from ragger.error import ExceptionRAPDU 8 | from ragger.utils import RAPDU 9 | from ragger.backend import RaisePolicy 10 | from ragger.navigator import NavInsID, NavIns 11 | 12 | ROOT_SCREENSHOT_PATH = Path(__file__).parent.parent.resolve() 13 | 14 | 15 | def test_error_returns_not_raises(backend): 16 | backend.raise_policy = RaisePolicy.RAISE_NOTHING 17 | result = backend.exchange(0x01, 0x00) 18 | assert isinstance(result, RAPDU) 19 | assert result.status == 0x6e00 20 | assert not result.data 21 | 22 | 23 | def test_error_raises_not_returns(backend): 24 | try: 25 | backend.exchange(0x01, 0x00) 26 | except ExceptionRAPDU as e: 27 | assert e.status == 0x6e00 28 | assert not e.data 29 | 30 | 31 | @pytest.mark.use_on_backend("speculos") 32 | def test_quit_app(backend, device, navigator): 33 | if not device.touchable: 34 | right_clicks = {'nanos': 3, 'nanox': 3, 'nanosp': 3} 35 | backend.get_current_screen_content() 36 | 37 | for _ in range(right_clicks[device.name]): 38 | backend.right_click() 39 | backend.wait_for_screen_change() 40 | 41 | with pytest.raises(ConnectionError): 42 | # clicking on "Quit", Speculos then stops 43 | backend.both_click() 44 | time.sleep(1) 45 | 46 | # Then a new dummy click should raise a ConnectionError 47 | backend.right_click() 48 | 49 | else: 50 | backend.get_current_screen_content() 51 | 52 | with pytest.raises(ConnectionError): 53 | # clicking on "Quit", Speculos then stops and raises 54 | navigator.navigate([NavInsID.USE_CASE_HOME_QUIT], 55 | screen_change_before_first_instruction=False, 56 | screen_change_after_last_instruction=False) 57 | time.sleep(1) 58 | 59 | # Then a new dummy touch should raise a ConnectionError 60 | backend.finger_touch() 61 | 62 | 63 | @pytest.mark.use_on_backend("speculos") 64 | def test_waiting_screen(backend, device, navigator): 65 | if not device.touchable: 66 | pytest.skip("Don't apply") 67 | 68 | prep_tx_apdu = bytes.fromhex("e006008015058000002c80000001800000000000000000000000") 69 | 70 | sign_tx_apdu = bytes.fromhex("e0060100310000000000000001de0b29" 71 | "5669a9fd93d5f28d9ec85e40f4cb697b" 72 | "ae000000000000029a0c466f72207520" 73 | "457468446576") 74 | 75 | # Test multiple way to wait for the return for the Home screen after a review. 76 | 77 | # Using USE_CASE_STATUS_DISMISS instruction 78 | backend.exchange_raw(prep_tx_apdu) 79 | with backend.exchange_async_raw(sign_tx_apdu): 80 | navigator.navigate_until_text_and_compare( 81 | NavInsID.USE_CASE_REVIEW_NEXT, 82 | [NavInsID.USE_CASE_REVIEW_CONFIRM, NavInsID.USE_CASE_STATUS_DISMISS], "Hold to sign", 83 | ROOT_SCREENSHOT_PATH, "waiting_screen") 84 | 85 | # Using WAIT_FOR_HOME_SCREEN instruction 86 | backend.exchange_raw(prep_tx_apdu) 87 | with backend.exchange_async_raw(sign_tx_apdu): 88 | navigator.navigate_until_text_and_compare( 89 | NavInsID.USE_CASE_REVIEW_NEXT, 90 | [NavInsID.USE_CASE_REVIEW_CONFIRM, NavInsID.WAIT_FOR_HOME_SCREEN], "Hold to sign", 91 | ROOT_SCREENSHOT_PATH, "waiting_screen") 92 | 93 | # Using WAIT_FOR_TEXT_ON_SCREEN instruction 94 | backend.exchange_raw(prep_tx_apdu) 95 | with backend.exchange_async_raw(sign_tx_apdu): 96 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [ 97 | NavInsID.USE_CASE_REVIEW_CONFIRM, 98 | NavIns(NavInsID.WAIT_FOR_TEXT_ON_SCREEN, ("This app enables signing", )) 99 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen") 100 | 101 | # Using WAIT_FOR_TEXT_NOT_ON_SCREEN instruction 102 | backend.exchange_raw(prep_tx_apdu) 103 | with backend.exchange_async_raw(sign_tx_apdu): 104 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [ 105 | NavInsID.USE_CASE_REVIEW_CONFIRM, 106 | NavIns(NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN, ("Transaction", )) 107 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen") 108 | 109 | # Verify the error flow of WAIT_FOR_TEXT_ON_SCREEN instruction 110 | backend.exchange_raw(prep_tx_apdu) 111 | with backend.exchange_async_raw(sign_tx_apdu): 112 | with pytest.raises(TimeoutError) as error: 113 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [ 114 | NavInsID.USE_CASE_REVIEW_CONFIRM, 115 | NavIns(NavInsID.WAIT_FOR_TEXT_ON_SCREEN, ("WILL NOT BE FOUND", )) 116 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen") 117 | assert "Timeout waiting for screen change" in str(error.value) 118 | 119 | # Verify the error flow of WAIT_FOR_TEXT_ON_SCREEN instruction 120 | backend.exchange_raw(prep_tx_apdu) 121 | with backend.exchange_async_raw(sign_tx_apdu): 122 | with pytest.raises(TimeoutError) as error: 123 | navigator.navigate_until_text_and_compare(NavInsID.USE_CASE_REVIEW_NEXT, [ 124 | NavInsID.USE_CASE_REVIEW_CONFIRM, 125 | NavIns(NavInsID.WAIT_FOR_TEXT_NOT_ON_SCREEN, ("T", )) 126 | ], "Hold to sign", ROOT_SCREENSHOT_PATH, "waiting_screen") 127 | assert "Timeout waiting for screen change" in str(error.value) 128 | -------------------------------------------------------------------------------- /tests/unit/backend/test_ledgerwallet.py: -------------------------------------------------------------------------------- 1 | from ledgered.devices import DeviceType, Devices 2 | from typing import Optional 3 | from unittest import TestCase 4 | from unittest.mock import patch, MagicMock 5 | 6 | from ragger.error import ExceptionRAPDU 7 | from ragger.utils import RAPDU 8 | from ragger.backend import LedgerWalletBackend 9 | from ragger.backend import RaisePolicy 10 | 11 | 12 | class TestLedgerWalletBackend(TestCase): 13 | 14 | def setUp(self): 15 | self.device = MagicMock() 16 | self.backend = LedgerWalletBackend(Devices.get_by_type(DeviceType.NANOS)) 17 | 18 | def check_rapdu(self, rapdu: RAPDU, status: int = 0x9000, payload: Optional[bytes] = None): 19 | self.assertIsInstance(rapdu, RAPDU) 20 | self.assertEqual(rapdu.status, status) 21 | self.assertEqual(rapdu.data, payload) 22 | 23 | def test_contextmanager(self): 24 | with patch("ledgerwallet.client.enumerate_devices") as devices: 25 | devices.return_value = [MagicMock()] 26 | self.assertIsNone(self.backend._client) 27 | with self.backend as backend: 28 | self.assertEqual(self.backend, backend) 29 | self.assertIsNotNone(self.backend._client) 30 | 31 | def test_send_raw(self): 32 | with patch("ledgerwallet.client.enumerate_devices") as devices: 33 | devices.return_value = [MagicMock()] 34 | with self.backend: 35 | self.assertIsNone(self.backend.send_raw(b"")) 36 | 37 | def test_receive_ok(self): 38 | status, payload = 0x9000, b"something" 39 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}") 40 | with patch("ledgerwallet.client.enumerate_devices") as devices: 41 | devices.return_value = [self.device] 42 | with self.backend: 43 | rapdu = self.backend.receive() 44 | self.check_rapdu(rapdu, payload=payload) 45 | 46 | def test_receive_ok_raises(self): 47 | status, payload = 0x9000, b"something" 48 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}") 49 | with patch("ledgerwallet.client.enumerate_devices") as devices: 50 | devices.return_value = [self.device] 51 | with self.backend: 52 | self.backend.raise_policy = RaisePolicy.RAISE_ALL 53 | with self.assertRaises(ExceptionRAPDU) as error: 54 | self.backend.receive() 55 | self.assertEqual(error.exception.status, status) 56 | 57 | def test_receive_nok_no_raises(self): 58 | status, payload = 0x8000, b"something" 59 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}") 60 | with patch("ledgerwallet.client.enumerate_devices") as devices: 61 | devices.return_value = [self.device] 62 | with self.backend: 63 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING 64 | rapdu = self.backend.receive() 65 | self.check_rapdu(rapdu, status=status, payload=payload) 66 | 67 | def test_receive_nok_raises(self): 68 | status, payload = 0x8000, b"something" 69 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}") 70 | with patch("ledgerwallet.client.enumerate_devices") as devices: 71 | devices.return_value = [self.device] 72 | with self.backend: 73 | with self.assertRaises(ExceptionRAPDU) as error: 74 | self.backend.receive() 75 | self.assertEqual(error.exception.status, status) 76 | 77 | def test_exchange_raw_ok(self): 78 | status, payload = 0x9000, b"something" 79 | self.device.exchange.return_value = payload + bytes.fromhex(f"{status:x}") 80 | with patch("ledgerwallet.client.enumerate_devices") as devices: 81 | devices.return_value = [self.device] 82 | with self.backend: 83 | rapdu = self.backend.exchange_raw(b"") 84 | self.check_rapdu(rapdu, payload=payload) 85 | 86 | def test_exchange_raw_nok_no_raises(self): 87 | status, payload = 0x8000, b"something" 88 | self.device.exchange.return_value = payload + bytes.fromhex(f"{status:x}") 89 | with patch("ledgerwallet.client.enumerate_devices") as devices: 90 | devices.return_value = [self.device] 91 | with self.backend: 92 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING 93 | rapdu = self.backend.exchange_raw(b"") 94 | self.check_rapdu(rapdu, status=status, payload=payload) 95 | 96 | def test_exchange_raw_nok_raises(self): 97 | status, payload = 0x8000, b"something" 98 | self.device.exchange.return_value = payload + bytes.fromhex(f"{status:x}") 99 | with patch("ledgerwallet.client.enumerate_devices") as devices: 100 | devices.return_value = [self.device] 101 | with self.backend: 102 | with self.assertRaises(ExceptionRAPDU) as error: 103 | self.backend.exchange_raw(b"") 104 | self.assertEqual(error.exception.status, status) 105 | 106 | def test_exchange_async_raw_ok(self): 107 | status, payload = 0x9000, b"something" 108 | self.device.read.return_value = payload + bytes.fromhex(f"{status:x}") 109 | with patch("ledgerwallet.client.enumerate_devices") as devices: 110 | devices.return_value = [self.device] 111 | with self.backend: 112 | self.assertIsNone(self.backend.last_async_response) 113 | with self.backend.exchange_async_raw(b""): 114 | self.backend.right_click() 115 | self.backend.left_click() 116 | self.backend.both_click() 117 | self.check_rapdu(self.backend.last_async_response, payload=payload) 118 | -------------------------------------------------------------------------------- /tests/unit/backend/test_physical_backend.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from ledgered.devices import DeviceType, Devices 3 | from typing import Generator 4 | from unittest import TestCase 5 | from unittest.mock import MagicMock 6 | 7 | from ragger.backend import BackendInterface 8 | from ragger.backend.physical_backend import PhysicalBackend 9 | from ragger.gui import RaggerGUI 10 | from ragger.navigator.instruction import NavInsID 11 | from ragger.utils.structs import RAPDU 12 | 13 | 14 | class StubPhysicalBackend(PhysicalBackend): 15 | 16 | def __enter__(self): 17 | pass 18 | 19 | def send_raw(self, data: bytes = b""): 20 | pass 21 | 22 | def receive(self) -> RAPDU: 23 | return RAPDU(0x9000, b"") 24 | 25 | def exchange_raw(self, data: bytes = b"", tick_timeout: int = 0) -> RAPDU: 26 | return RAPDU(0x9000, b"") 27 | 28 | @contextmanager 29 | def exchange_async_raw(self, data: bytes = b"") -> Generator[None, None, None]: 30 | yield 31 | 32 | 33 | class TestPhysicalBackend(TestCase): 34 | 35 | def setUp(self): 36 | self.device = Devices.get_by_type(DeviceType.NANOS) 37 | self.backend = StubPhysicalBackend(self.device, with_gui=True) 38 | # patching the `start` function to avoid triggering a real interface 39 | self.backend._ui.start = MagicMock() 40 | 41 | def test___init__no_gui(self): 42 | backend = StubPhysicalBackend(self.device) 43 | self.assertIsInstance(backend, BackendInterface) 44 | self.assertIsNone(backend._ui) 45 | 46 | def test__init__with_gui(self): 47 | self.assertIsInstance(self.backend._ui, RaggerGUI) 48 | self.assertFalse(self.backend._ui.is_alive()) 49 | 50 | def test_init_gui_no_ui(self): 51 | backend = StubPhysicalBackend(self.device) 52 | with self.assertRaises(AssertionError): 53 | backend.init_gui() 54 | 55 | def test_init_gui_with_gui(self): 56 | self.assertIsNone(self.backend.init_gui()) 57 | self.assertTrue(self.backend._ui.start.called) 58 | 59 | def test_navigation_methods_no_gui_None(self): 60 | backend = StubPhysicalBackend(self.device) 61 | for method in [ 62 | backend.right_click, backend.left_click, backend.both_click, backend.finger_touch 63 | ]: 64 | self.assertIsNone(method()) 65 | 66 | def test_click_methods_with_gui(self): 67 | for (method, expected_arg) in [(self.backend.right_click, NavInsID.RIGHT_CLICK), 68 | (self.backend.left_click, NavInsID.LEFT_CLICK), 69 | (self.backend.both_click, NavInsID.BOTH_CLICK)]: 70 | # mocking the underlying called method 71 | self.backend._ui.ask_for_click_action = MagicMock() 72 | 73 | self.assertIsNone(method()) 74 | self.assertTrue(self.backend._ui.ask_for_click_action.called) 75 | self.assertEqual(self.backend._ui.ask_for_click_action.call_args, ((expected_arg, ), )) 76 | 77 | def test_finger_touch_with_gui(self): 78 | x, y = 3, 7 79 | # mocking the underlying called method 80 | self.backend._ui.ask_for_touch_action = MagicMock() 81 | 82 | self.assertIsNone(self.backend.finger_touch(x, y)) 83 | self.assertTrue(self.backend._ui.ask_for_touch_action.called) 84 | self.assertEqual(self.backend._ui.ask_for_touch_action.call_args, ((x, y), )) 85 | 86 | def test_compare_methods_no_gui_bool(self): 87 | backend = StubPhysicalBackend(self.device) 88 | self.assertTrue(backend.compare_screen_with_snapshot(None)) 89 | self.assertTrue(backend.compare_screen_with_text(None)) 90 | 91 | def test_compare_screen_with_snapshot_with_gui(self): 92 | # mocking the underlying called method 93 | oracle = MagicMock() 94 | oracle.return_value = True 95 | self.backend._ui.check_screenshot = oracle 96 | 97 | path = "some/patch" 98 | self.assertIsNone(self.backend._last_valid_snap_path) 99 | self.assertTrue(self.backend.compare_screen_with_snapshot(path)) 100 | self.assertTrue(oracle.called) 101 | self.assertEqual(self.backend._last_valid_snap_path, path) 102 | 103 | # comparing same snapshot, the underlying check function is not called 104 | oracle.reset_mock() 105 | self.assertTrue(self.backend.compare_screen_with_snapshot(path)) 106 | self.assertFalse(oracle.called) 107 | self.assertEqual(self.backend._last_valid_snap_path, path) 108 | 109 | def test_compare_screen_with_snapshot_with_gui_error(self): 110 | # mocking the underlying called method 111 | oracle = MagicMock() 112 | oracle.return_value = False 113 | self.backend._ui.check_screenshot = oracle 114 | 115 | path = "some/patch" 116 | self.backend._last_valid_snap_path = None 117 | self.assertFalse(self.backend.compare_screen_with_snapshot(path)) 118 | self.assertTrue(oracle.called) 119 | self.assertIsNone(self.backend._last_valid_snap_path) 120 | 121 | def test_compare_screen_with_text_with_gui_last_valid_snap_path_exists(self): 122 | path = "tests/snapshots/nanos/generic/00000.png" 123 | text = "Boilerplate" 124 | self.backend._last_valid_snap_path = path 125 | 126 | self.assertTrue(self.backend.compare_screen_with_text(text)) 127 | self.assertFalse(self.backend.compare_screen_with_text("this text does not exist here")) 128 | 129 | def test_compare_screen_with_text_with_gui_last_valid_snap_path_does_not_exist(self): 130 | # mocking the underlying called method 131 | oracle = MagicMock() 132 | oracle.return_value = True 133 | self.backend._ui.check_text = oracle 134 | 135 | text = "sometext" 136 | self.assertTrue(self.backend.compare_screen_with_text(text)) 137 | self.assertTrue(oracle.called) 138 | self.assertEqual(oracle.call_args, ((text, ), )) 139 | 140 | def test_wait_for_screen_change(self): 141 | self.assertIsNone(self.backend.wait_for_screen_change()) 142 | 143 | def test_get_current_screen_content(self): 144 | self.assertListEqual(self.backend.get_current_screen_content(), list()) 145 | -------------------------------------------------------------------------------- /src/ragger/gui/process.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | import sys 17 | from multiprocessing import Process, Queue 18 | from pathlib import Path 19 | from PyQt6.QtCore import QObject, QThread, pyqtSignal 20 | from PyQt6.QtWidgets import QApplication 21 | from typing import Tuple, Any 22 | 23 | from .interface import RaggerMainWindow 24 | from ragger.logger import get_gui_logger 25 | from ragger.navigator.instruction import NavInsID 26 | 27 | NAVIGATION_ACTIONS = { 28 | NavInsID.RIGHT_CLICK: "right button", 29 | NavInsID.LEFT_CLICK: "left button", 30 | NavInsID.BOTH_CLICK: "both buttons" 31 | } 32 | 33 | 34 | class ProcessCommunicationWorker(QObject): 35 | finished = pyqtSignal() 36 | progress = pyqtSignal(int) 37 | 38 | def __init__(self, queues: Tuple[Queue, Queue], main_window: RaggerMainWindow): 39 | super().__init__() 40 | self.logger = get_gui_logger().getChild("CommunicationThread") 41 | self._queues = queues 42 | self._main_window = main_window 43 | self.logger.info("Initiated") 44 | 45 | def _send(self, obj: Any): 46 | self._queues[1].put(obj) 47 | 48 | def _receive(self) -> Any: 49 | return self._queues[0].get() 50 | 51 | def run(self): 52 | self.logger.info("Worker: started") 53 | while True: 54 | self.logger.debug("Waiting instruction...") 55 | command, argument = self._receive() 56 | if command == "screenshot": 57 | self.logger.debug("Image received") 58 | self._main_window.display_screenshot(argument) 59 | elif command == "text_search": 60 | self.logger.debug(f"Text search received : '{argument}'") 61 | self._main_window.display_text_search(argument) 62 | elif command == "click_action": 63 | self.logger.debug("Click action required") 64 | self._main_window.display_action(NAVIGATION_ACTIONS[argument]) 65 | elif command == "touch_action": 66 | self.logger.debug("Touch action required") 67 | self._main_window.display_action("touch", *argument) 68 | elif command == "swipe_left_action": 69 | self.logger.debug("Swipe left action required") 70 | self._main_window.display_action("swipe_left", *argument) 71 | elif command == "swipe_right_action": 72 | self.logger.debug("Swipe right action required") 73 | self._main_window.display_action("swipe_right", *argument) 74 | elif command == "action_done": 75 | self.logger.debug("Action done") 76 | self._main_window.action_done() 77 | elif command == "kill": 78 | self.logger.debug("Kill object") 79 | self._main_window.close() 80 | 81 | self.logger.debug("Interface updated") 82 | 83 | def __del__(self): 84 | self.finished.emit() 85 | 86 | 87 | class RaggerGUI(Process): 88 | 89 | def __init__(self, device: str, args=None): 90 | super().__init__(name="RaggerGUI") 91 | self.thread: QThread 92 | self.worker: ProcessCommunicationWorker 93 | self.logger = get_gui_logger().getChild("RaggerGUI") 94 | self._queues: Tuple[Queue, Queue] = (Queue(), Queue()) 95 | self._device = device 96 | self.logger.info("Initiated") 97 | 98 | def _send(self, obj: Any): 99 | self.logger.debug("Sending '%s'", obj) 100 | self._queues[0].put(obj) 101 | 102 | def _receive(self) -> Any: 103 | result = self._queues[1].get() 104 | self.logger.debug("Receiving '%s'", result) 105 | return result 106 | 107 | def kill(self): 108 | self.logger.info("Killing the interface and myself") 109 | self._send(("kill", None)) 110 | if self.is_alive(): 111 | super().kill() 112 | 113 | def check_screenshot(self, image: Path): 114 | self._send(("screenshot", image)) 115 | return self._receive() 116 | 117 | def check_text(self, text: str): 118 | self._send(("text_search", text)) 119 | return self._receive() 120 | 121 | def ask_for_click_action(self, ins_id: NavInsID): 122 | self._send(("click_action", ins_id)) 123 | return self._receive() 124 | 125 | def ask_for_touch_action(self, x: int = 0, y: int = 0): 126 | self._send(("touch_action", (x, y))) 127 | return self._receive() 128 | 129 | def ask_for_swipe_action(self, x: int = 0, y: int = 0, direction: str = "left"): 130 | if direction == "left": 131 | self._send(("swipe_left_action", (x, y))) 132 | else: 133 | self._send(("swipe_right_action", (x, y))) 134 | return self._receive() 135 | 136 | def _configure_worker(self): 137 | self.thread = QThread() 138 | self.worker = ProcessCommunicationWorker(self._queues, self._main_window) 139 | self.worker.moveToThread(self.thread) 140 | self.thread.started.connect(self.worker.run) 141 | self.worker.finished.connect(self.thread.quit) 142 | self.thread.start() 143 | self.logger.info("Worker started") 144 | 145 | def _button_cb(self, obj: Any): 146 | self._queues[1].put(obj) 147 | self._send(("action_done", "")) 148 | 149 | def run(self): 150 | self._app = QApplication([]) 151 | self._app.setStyle("Fusion") 152 | self._main_window = RaggerMainWindow(device=self._device) 153 | self._main_window.set_button_cb(self._button_cb) 154 | self._configure_worker() 155 | self.logger.info("Starting the interface...") 156 | sys.exit(self._app.exec()) 157 | self.logger.info("Interface ended") 158 | -------------------------------------------------------------------------------- /tests/unit/backend/test_speculos.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from ledgered.devices import DeviceType, Devices 3 | from typing import List 4 | from unittest import TestCase 5 | from unittest.mock import MagicMock, patch 6 | 7 | from ragger.backend import SpeculosBackend 8 | 9 | APPNAME = "some app" 10 | 11 | 12 | def get_next_in_list(the_list: List, elt: str) -> str: 13 | index = the_list.index(elt) 14 | return the_list[index + 1] 15 | 16 | 17 | class TestSpeculosBackend(TestCase): 18 | 19 | maxDiff = None 20 | 21 | def setUp(self): 22 | self.nanos = Devices.get_by_type(DeviceType.NANOS) 23 | 24 | def test___init__ok(self): 25 | b = SpeculosBackend(APPNAME, self.nanos) 26 | self.assertEqual(b._api_port, b._DEFAULT_API_PORT) 27 | self.assertEqual(b._apdu_port, b._DEFAULT_API_PORT + 1) 28 | 29 | def test___init__ports_ok(self): 30 | api_port, apdu_port = 4567, 9876 31 | b1 = SpeculosBackend(APPNAME, 32 | self.nanos, 33 | args=["--api-port", 34 | str(api_port), "--apdu-port", 35 | str(apdu_port)]) 36 | self.assertEqual(b1._api_port, api_port) 37 | self.assertEqual(b1._apdu_port, apdu_port) 38 | 39 | b2 = SpeculosBackend(APPNAME, self.nanos, args=["--api-port", str(api_port)]) 40 | self.assertEqual(b2._api_port, api_port) 41 | self.assertEqual(b2._apdu_port, api_port + 1) 42 | 43 | b3 = SpeculosBackend(APPNAME, self.nanos, args=["--apdu-port", str(apdu_port)]) 44 | self.assertEqual(b3._api_port, b3._DEFAULT_API_PORT) 45 | self.assertEqual(b3._apdu_port, apdu_port) 46 | 47 | def test___init__args_ok(self): 48 | SpeculosBackend(APPNAME, self.nanos, args=["some", "specific", "arguments"]) 49 | 50 | def test___init__args_nok(self): 51 | with self.assertRaises(AssertionError): 52 | SpeculosBackend(APPNAME, self.nanos, args="not a list") 53 | 54 | def test_context_manager(self): 55 | expected_image = b"1234" 56 | with patch("ragger.backend.speculos.SpeculosClient"): 57 | backend = SpeculosBackend(APPNAME, self.nanos) 58 | self.assertIsNone(backend._last_screenshot) 59 | self.assertIsNone(backend._home_screenshot) 60 | # patching SpeculosClient.get_screenshot 61 | backend._client.get_screenshot.return_value = expected_image 62 | # patching method so that __enter__ is not stuck for 20s 63 | backend._retrieve_client_screen_content = lambda: {"events": True} 64 | with backend as yielded: 65 | self.assertTrue(backend._client.__enter__.called) 66 | self.assertEqual(backend, yielded) 67 | self.assertEqual(backend._last_screenshot, backend._home_screenshot) 68 | self.assertEqual(backend._last_screenshot.getvalue(), 69 | BytesIO(expected_image).getvalue()) 70 | self.assertFalse(backend._client.__exit__.called) 71 | self.assertTrue(backend._client.__exit__.called) 72 | 73 | def test_batch_ok(self): 74 | with patch("ragger.backend.speculos.SpeculosClient") as patched_client: 75 | client_number = 2 76 | arg_api_port = 1234 77 | arg_apdu_port = 4321 78 | 79 | clients = SpeculosBackend.batch( 80 | APPNAME, 81 | self.nanos, 82 | client_number, 83 | different_attestation=True, 84 | args=['--apdu-port', arg_apdu_port, '--api-port', arg_api_port]) 85 | 86 | self.assertEqual(len(clients), client_number) 87 | self.assertEqual(patched_client.call_count, client_number) 88 | all_client_args = patched_client.call_args_list 89 | 90 | client_seeds = set() 91 | client_rngs = set() 92 | client_priv_keys = set() 93 | client_attestations = set() 94 | client_api_ports = set() 95 | client_apdu_ports = set() 96 | 97 | for index, client in enumerate(clients): 98 | args, kwargs = all_client_args[index] 99 | self.assertEqual(args, ()) 100 | self.assertEqual(kwargs["app"], APPNAME) 101 | self.assertEqual(kwargs["api_url"], f"http://127.0.0.1:{client._api_port}") 102 | speculos_args = kwargs["args"] 103 | client_seeds.add(get_next_in_list(speculos_args, "--seed")) 104 | client_rngs.add(get_next_in_list(speculos_args, "--deterministic-rng")) 105 | client_priv_keys.add(get_next_in_list(speculos_args, "--user-private-key")) 106 | client_attestations.add(get_next_in_list(speculos_args, "--attestation-key")) 107 | api_port = int(get_next_in_list(speculos_args, "--api-port")) 108 | client_api_ports.add(client._api_port) 109 | apdu_port = int(get_next_in_list(speculos_args, "--apdu-port")) 110 | client_apdu_ports.add(client._api_port) 111 | 112 | # ports given as original arguments are overridden 113 | self.assertNotEqual(api_port, arg_api_port) 114 | self.assertNotEqual(apdu_port, arg_api_port) 115 | # API port has been overridden by a generated port 116 | # (APDU port too, but it is not stored into SpeculosBackend) 117 | self.assertEqual(client._api_port, api_port) 118 | 119 | self.assertIsInstance(client, SpeculosBackend) 120 | self.assertIsInstance(client._client, MagicMock) 121 | 122 | # all API ports are different 123 | self.assertEqual(len(client_api_ports), client_number) 124 | # all APDU ports are different 125 | self.assertEqual(len(client_apdu_ports), client_number) 126 | # all seeds ports are different 127 | self.assertEqual(len(client_seeds), client_number) 128 | # all RNGs are different 129 | self.assertEqual(len(client_rngs), client_number) 130 | # all private keys are different 131 | self.assertEqual(len(client_priv_keys), client_number) 132 | # all attestations are different 133 | self.assertEqual(len(client_attestations), client_number) 134 | -------------------------------------------------------------------------------- /tests/unit/firmware/touch/test_screen_FullScreen.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | from ledgered.devices import Devices, DeviceType 4 | 5 | from ragger.firmware.touch import FullScreen 6 | from ragger.firmware.touch.positions import POSITIONS 7 | 8 | 9 | class TestFullScreen(TestCase): 10 | 11 | def setUp(self): 12 | self.backend = MagicMock() 13 | self.device = Devices.get_by_type(DeviceType.STAX) 14 | self.screen = FullScreen(self.backend, self.device) 15 | 16 | def test_non_variable_layouts(self): 17 | # all of this layouts only have a 'tap' method with no argument, 18 | # which translate to a backend.touch_finger on a fixed position 19 | layout_positions = [ 20 | (self.screen.right_header, POSITIONS["RightHeader"][self.device.type]), 21 | (self.screen.exit_header, POSITIONS["RightHeader"][self.device.type]), 22 | (self.screen.info_header, POSITIONS["RightHeader"][self.device.type]), 23 | (self.screen.left_header, POSITIONS["LeftHeader"][self.device.type]), 24 | (self.screen.navigation_header, POSITIONS["LeftHeader"][self.device.type]), 25 | (self.screen.tappable_center, POSITIONS["TappableCenter"][self.device.type]), 26 | (self.screen.centered_footer, POSITIONS["CenteredFooter"][self.device.type]), 27 | (self.screen.cancel_footer, POSITIONS["CancelFooter"][self.device.type]), 28 | (self.screen.exit_footer, POSITIONS["CancelFooter"][self.device.type]), 29 | (self.screen.info_footer, POSITIONS["CancelFooter"][self.device.type]), 30 | (self.screen.settings_footer, POSITIONS["CancelFooter"][self.device.type]), 31 | ] 32 | call_number = 0 33 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 34 | for (layout, position) in layout_positions: 35 | # each of this 36 | layout.tap() 37 | call_number += 1 38 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 39 | self.assertEqual(self.backend.finger_touch.call_args, ((*position, ), )) 40 | 41 | def test_choosing_layouts(self): 42 | layout_index_positions = [ 43 | (self.screen.choice_list, 1, POSITIONS["ChoiceList"][self.device.type]), 44 | (self.screen.suggestions, 2, POSITIONS["Suggestions"][self.device.type]), 45 | ] 46 | call_number = 0 47 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 48 | for (layout, index, position) in layout_index_positions: 49 | # each of this 50 | layout.choose(index) 51 | call_number += 1 52 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 53 | self.assertEqual(self.backend.finger_touch.call_args, ((*position[index], ), )) 54 | 55 | def test_keyboards_common_functions(self): 56 | layouts_word_positions = [ 57 | (self.screen.letter_only_keyboard, "basicword", 58 | POSITIONS["LetterOnlyKeyboard"][self.device.type]), 59 | (self.screen.full_keyboard_letters, "still basic", 60 | POSITIONS["FullKeyboardLetters"][self.device.type]), 61 | (self.screen.full_keyboard_special_characters_1, "12)&@'.", 62 | POSITIONS["FullKeyboardSpecialCharacters1"][self.device.type]), 63 | (self.screen.full_keyboard_special_characters_2, "[$?~+*|", 64 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]), 65 | ] 66 | self.assertEqual(self.backend.finger_touch.call_count, 0) 67 | for (layout, word, positions) in layouts_word_positions: 68 | 69 | layout.write(word) 70 | argument_list = [((*positions[letter], ), ) for letter in word] 71 | call_number = len(word) 72 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 73 | self.assertEqual(self.backend.finger_touch.call_args_list, argument_list) 74 | 75 | layout.back() 76 | self.assertEqual(self.backend.finger_touch.call_count, call_number + 1) 77 | self.assertEqual(self.backend.finger_touch.call_args, ((*positions["back"], ), )) 78 | 79 | self.backend.finger_touch.reset_mock() 80 | 81 | def test_keyboards_change_layout(self): 82 | layouts_positions = [ 83 | (self.screen.full_keyboard_letters, POSITIONS["FullKeyboardLetters"][self.device.type]), 84 | (self.screen.full_keyboard_special_characters_1, 85 | POSITIONS["FullKeyboardSpecialCharacters1"][self.device.type]), 86 | (self.screen.full_keyboard_special_characters_2, 87 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]), 88 | ] 89 | call_number = 0 90 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 91 | for layout, positions in layouts_positions: 92 | layout.change_layout() 93 | call_number += 1 94 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 95 | self.assertEqual(self.backend.finger_touch.call_args, 96 | ((*positions["change_layout"], ), )) 97 | 98 | def test_keyboards_change_case(self): 99 | self.assertEqual(self.backend.finger_touch.call_count, 0) 100 | self.screen.full_keyboard_letters.change_case() 101 | self.assertEqual(self.backend.finger_touch.call_count, 1) 102 | self.assertEqual(self.backend.finger_touch.call_args, 103 | ((*POSITIONS["FullKeyboardLetters"][self.device.type]["change_case"], ), )) 104 | 105 | def test_keyboards_change_special_characters(self): 106 | layouts_positions = [ 107 | (self.screen.full_keyboard_special_characters_1, 108 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]), 109 | (self.screen.full_keyboard_special_characters_2, 110 | POSITIONS["FullKeyboardSpecialCharacters2"][self.device.type]), 111 | ] 112 | call_number = 0 113 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 114 | for layout, positions in layouts_positions: 115 | layout.more_specials() 116 | call_number += 1 117 | self.assertEqual(self.backend.finger_touch.call_count, call_number) 118 | self.assertEqual(self.backend.finger_touch.call_args, 119 | ((*positions["more_specials"], ), )) 120 | -------------------------------------------------------------------------------- /doc/glossary.rst: -------------------------------------------------------------------------------- 1 | .. _Glossary: 2 | 3 | Glossary 4 | ======== 5 | 6 | .. glossary:: 7 | 8 | APDU 9 | **APDU** stands for 10 | "`Application Protocol Data Unit `_". 11 | It designate a message which is transmitted from a :term:`client` to an 12 | :term:`application`, or the other way around. APDUs transit through USB or 13 | Bluetooth. 14 | 15 | Application 16 | In this documentation, an **application** is a piece of software, build 17 | around the Ledger Secure SDK, which runs on a Ledger cold wallet device, 18 | such as a NanoS, NanoS+ or NanoX. 19 | 20 | Backend 21 | In ``Ragger``, a **backend** is a library which allows to communicate with 22 | a Ledger cold wallet device, either a real one (like a physical 23 | NanoS/S+/X), or an emulated one (thanks to ``Speculos``). 24 | 25 | BAGL 26 | **BAGL** stands for "BOLOS Application Graphics Library", and is a library 27 | integrated into the C SDK managing the UI of the NanoS, NanoX and NanoS+ 28 | devices. It is embedded into the :term:`SDK` 29 | 30 | BOLOS 31 | **BOLOS** is `the operating system running on all Ledger hardware wallets 32 | `_. 33 | Its code is not open-source. Its capabilities can be used through the 34 | open-source :term:`SDK`. 35 | 36 | Client 37 | A client is any piece of software accessing a service made available by a 38 | server; but in this documentation, a **client** refers to a piece of code 39 | able to communicate with an :term:`application`. 40 | 41 | Typically, the client of an application programmatically enables the 42 | capabilities of its application: signing a payload, triggering a 43 | transaction, changing the configuration, ... 44 | 45 | Firmware 46 | A **firmware** usually designates the Ledger device *device type* and SDK 47 | *version*. Currently, *device types* are NanoS, NanoS+ and NanoX. The 48 | *version* depends on the type. 49 | 50 | ``Ragger`` stores this information in the 51 | :py:class:`Firmware ` class. 52 | 53 | See also :term:`SDK`. 54 | 55 | Golden snapshot 56 | In ``Ragger``, **golden snapshots** are :term:`application` screen's 57 | snapshots which are considered as references. They are used when testing an 58 | application to check the application behaves as expected, by comparing them 59 | if actual snapshots. 60 | 61 | Layout 62 | In the :term:`Stax` SDK, a **Layout** refers to an element displayed on a 63 | Stax screen. Examples of Layouts can be a button, on a specific 64 | location, a keyboard, or just a centered text. This name is used in 65 | ``Ragger`` to designate the class allowing to interact with a displayed 66 | layout. 67 | 68 | Layouts are used to create :term:`Pages ` and 69 | :term:`Use Cases ` 70 | 71 | LedgerComm 72 | **LedgerComm** is the original open-source library allowing to communicate 73 | with a Ledger device. It is hosted 74 | `on GitHub `__. 75 | 76 | LedgerWallet 77 | **LedgerWallet** is the newer open-source library allowing to communicate 78 | with a Ledger device. It is hosted 79 | `on GitHub `__ 80 | 81 | NBGL 82 | **NBGL** stands for "New BOLOS Graphic Library", and is the successor of 83 | :term:`BAGL` for more recent devices such as :term:`Stax`. It is embedded 84 | into the :term:`SDK`. 85 | 86 | It has also been back-ported to older devices such as NanoS+ and NanoX (but 87 | not NanoS) to ease interface flow development. 88 | 89 | Page 90 | In the :term:`Stax` SDK, a **Page** refers to a specific displayed Stax 91 | screen.A welcome page, a setting page are example of Pages. This name is 92 | also used in ``Ragger`` to designate the class allowing to interact with a 93 | displayed page. 94 | 95 | Pages are created from :term:`Layouts ` and are used to create 96 | :term:`Use Cases ` 97 | 98 | Pytest 99 | `Pytest `_ is a largely used, open-source Python 100 | testing tool. Its ``fixture`` mechanism is integrated into ``Ragger``. 101 | 102 | RAPDU 103 | "Response APDU" (**RAPDU**), designates the response of an 104 | :term:`application` following an :term:`APDU` from the :term:`client` to 105 | the application. 106 | 107 | SDK 108 | The **SDK** is the open-source code allowing an application to be compiled 109 | for a Ledger cold wallet device. On top of an interface to exploit 110 | :term:`BOLOS` capabilities, it provides boilerplate functions, graphic 111 | abstractions and other useful libraries for developing apps. 112 | 113 | It is written in C. Its code for the various devices has been unified into 114 | `this GitHub repository `_, 115 | although the current NanoS SDK is still based on an older version, 116 | versioned on `here `_. 117 | 118 | A Rust SDK also exists in 119 | `this repository `_, 120 | but should not yet be taken as production ready. 121 | 122 | 123 | 124 | .. previous sentence should be linked to a centralized SDK repo 125 | 126 | Speculos 127 | **Speculos** is an open-source Ledger device emulator, allowing easy and 128 | fast testing of an :term:`application`. It is hosted 129 | `on GitHub `__. 130 | 131 | It is composed of the emulator itself, and a HTTP client-server module 132 | allowing to easily control and communicate with said emulator. 133 | 134 | Stax 135 | **Stax** is the most premium Ledger device which, in a programmatic point 136 | of view, mostly differs from previous devices by its richer UI and a touch 137 | screen, justifying the usage of the new graphic library, :term:`NBGL`. 138 | 139 | Use Case 140 | In the :term:`Stax` SDK, a **Use Case** refers to a pre-designed 141 | :term:`Page` or group of Pages. For instance, the settings Use Case manages 142 | one or several :term:`Pages ` in order to display and change the 143 | settings. This name is used in ``Ragger`` to designate the class allowing 144 | to interact with a Use Case. 145 | 146 | Use Cases are created from :term:`Pages ` and sometimes 147 | :term:`Layouts ` 148 | -------------------------------------------------------------------------------- /src/ragger/backend/physical_backend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from ledgered.devices import Device 17 | from pathlib import Path 18 | from types import TracebackType 19 | from typing import List, Optional, Type 20 | from re import match, search 21 | 22 | from ragger.gui import RaggerGUI 23 | from ragger.navigator.instruction import NavInsID 24 | from ragger.utils import Crop 25 | from .interface import BackendInterface 26 | 27 | 28 | class PhysicalBackend(BackendInterface): 29 | 30 | def __init__(self, device: Device, *args, with_gui: bool = False, **kwargs): 31 | super().__init__(device, *args, **kwargs) 32 | self._ui: Optional[RaggerGUI] = RaggerGUI(device=device.name) if with_gui else None 33 | self._last_valid_snap_path: Optional[Path] = None 34 | 35 | def __exit__(self, 36 | exc_type: Optional[Type[BaseException]] = None, 37 | exc_val: Optional[BaseException] = None, 38 | exc_tb: Optional[TracebackType] = None): 39 | if self._ui is not None: 40 | self._ui.kill() 41 | 42 | def init_gui(self) -> None: 43 | """ 44 | Initialize the GUI if needed. 45 | """ 46 | assert self._ui is not None, \ 47 | "This method should only be called if the backend manages an GUI" 48 | if not self._ui.is_alive(): 49 | self._ui.start() 50 | 51 | def right_click(self) -> None: 52 | if self._ui is None: 53 | return 54 | self.init_gui() 55 | self._ui.ask_for_click_action(NavInsID.RIGHT_CLICK) 56 | 57 | def left_click(self) -> None: 58 | if self._ui is None: 59 | return 60 | self.init_gui() 61 | self._ui.ask_for_click_action(NavInsID.LEFT_CLICK) 62 | 63 | def both_click(self) -> None: 64 | if self._ui is None: 65 | return 66 | self.init_gui() 67 | self._ui.ask_for_click_action(NavInsID.BOTH_CLICK) 68 | 69 | def finger_touch(self, x: int = 0, y: int = 0, delay: float = 0.5) -> None: 70 | if self._ui is None: 71 | return 72 | self.init_gui() 73 | self._ui.ask_for_touch_action(x, y) 74 | 75 | def finger_swipe(self, 76 | x: int = 0, 77 | y: int = 0, 78 | direction: str = "left", 79 | delay: float = 0.5) -> None: 80 | if self._ui is None: 81 | return 82 | self.init_gui() 83 | self._ui.ask_for_swipe_action(x, y, direction) 84 | 85 | def compare_screen_with_snapshot(self, 86 | golden_snap_path: Path, 87 | crop: Optional[Crop] = None, 88 | tmp_snap_path: Optional[Path] = None, 89 | golden_run: bool = False) -> bool: 90 | 91 | # If the file has no size, it's because we are within a NamedTemporaryFile 92 | # We do nothing and return False to exit the while loop of 93 | # NavInsID.USE_CASE_REVIEW_CONFIRM 94 | if isinstance(golden_snap_path, Path) and golden_snap_path.stat().st_size == 0: 95 | return False 96 | 97 | if self._ui is None: 98 | return True 99 | self.init_gui() 100 | 101 | # If for some reason we ask to compare the same snapshot twice, just return True. 102 | if self._last_valid_snap_path == golden_snap_path: 103 | return True 104 | 105 | if self._ui.check_screenshot(golden_snap_path): 106 | self._last_valid_snap_path = golden_snap_path 107 | return True 108 | else: 109 | self._last_valid_snap_path = None 110 | return False 111 | 112 | def compare_screen_with_text(self, text: str) -> bool: 113 | # Only this method needs these dependencies, which needs at least one physical backend to 114 | # be installed. By postponing the imports, we avoid an import error when using only Speculos 115 | try: 116 | from PIL import Image, ImageOps, ImageFilter 117 | from pytesseract import image_to_data, Output 118 | except ImportError as error: 119 | raise ImportError( 120 | "This feature needs at least one physical backend. " 121 | "Please install ragger[ledgercomm] or ragger[ledgerwallet]") from error 122 | 123 | if self._ui is None: 124 | return True 125 | self.init_gui() 126 | if self._last_valid_snap_path: 127 | image = Image.open(self._last_valid_snap_path) 128 | # Nano (s,sp,x) snapshots are white/blue text on black background, 129 | # tesseract cannot do OCR on these. Invert image so it has 130 | # dark text on white background. 131 | if self.device.is_nano: 132 | image = ImageOps.invert(image) 133 | image = image.filter(ImageFilter.SHARPEN) 134 | data = image_to_data(image, output_type=Output.DICT) 135 | if search(text.strip("^").strip("$"), " ".join(data["text"])): 136 | return True 137 | if any(text in item or match(text, item) for item in data["text"]): 138 | return True 139 | return False 140 | else: 141 | return self._ui.check_text(text) 142 | 143 | def wait_for_home_screen(self, timeout: float = 10.0) -> None: 144 | return 145 | 146 | def wait_for_screen_change(self, timeout: float = 10.0) -> None: 147 | return 148 | 149 | def wait_for_text_on_screen(self, text: str, timeout: float = 10.0) -> None: 150 | return 151 | 152 | def wait_for_text_not_on_screen(self, text: str, timeout: float = 10.0) -> None: 153 | return 154 | 155 | def get_current_screen_content(self) -> List: 156 | return list() 157 | 158 | def pause_ticker(self) -> None: 159 | pass 160 | 161 | def resume_ticker(self) -> None: 162 | pass 163 | 164 | def send_tick(self) -> None: 165 | pass 166 | -------------------------------------------------------------------------------- /src/ragger/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2022 Ledger SAS 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | from dataclasses import dataclass 17 | from enum import IntEnum 18 | 19 | 20 | class StatusWords(IntEnum): 21 | """ISO7816 Status Words for APDU responses. 22 | 23 | Refer to: 24 | - SDK https://github.com/LedgerHQ/ledger-secure-sdk/blob/master/include/status_words.h 25 | - https://www.eftlab.com/knowledge-base/complete-list-of-apdu-responses 26 | 27 | Notes: 28 | - The SWO codes are defined based on the ISO/IEC 7816-4 standard for smart cards. 29 | 30 | - 61XX and 6CXX are different. 31 | When a command returns 61XX, its process is normally completed, 32 | and it indicates the number of available bytes; 33 | it then expects a GET RESPONSE command with the appropriate length. 34 | When a command returns 6CXX, its process has been aborted, 35 | and it expects the command to be reissued. 36 | As mentioned above, 61XX indicates a normal completion, 37 | and 6CXX is considered as a transport error (defined in ISO7817-3). 38 | 39 | - Except for 63XX and 65XX, which warn that the persistent content has been changed, 40 | other status word should be used when the persistent content of the application is unchanged. 41 | 42 | - Status words 67XX, 6BXX, 6DXX, 6EXX, and 6FXX, where XX is not 0, are proprietary status words, 43 | as well as 9YYY, where YYY is not 000. 44 | """ 45 | 46 | # 61 -- Normal processing, lower byte indicates the amount of data to be retrieved 47 | SWO_RESPONSE_BYTES_AVAILABLE = 0x6100 48 | 49 | # 62 -- Warning, the state of persistent memory is unchanged 50 | SWO_DATA_MAY_BE_CORRUPTED = 0x6281 51 | SWO_EOF_REACHED_BEFORE_LE = 0x6282 52 | SWO_SELECTED_FILE_INVALIDATED = 0x6283 53 | SWO_FILE_INVALID = 0x6284 54 | SWO_NO_INPUT_DATA_AVAILABLE = 0x6285 55 | 56 | # 63 -- Warning, the state of persistent memory is changed 57 | SWO_CARD_KEY_NOT_SUPPORTED = 0x6382 58 | SWO_READER_KEY_NOT_SUPPORTED = 0x6383 59 | SWO_PLAINTEXT_TRANS_NOT_SUPPORTED = 0x6384 60 | SWO_SECURED_TRANS_NOT_SUPPORTED = 0x6385 61 | SWO_VOLATILE_MEM_NOT_AVAILABLE = 0x6386 62 | SWO_NON_VOLATILE_MEM_NOT_AVAILABLE = 0x6387 63 | SWO_KEY_NUMBER_INVALID = 0x6388 64 | SWO_KEY_LENGTH_INCORRECT = 0x6389 65 | SWO_VERIFY_FAILED = 0x63C0 # lower 4-bits indicates the number of attempts left 66 | SWO_MORE_DATA_EXPECTED = 0x63F1 67 | 68 | # 64 -- Execution error, the state of persistent memory is unchanged 69 | SWO_EXECUTION_ERROR = 0x6400 70 | SWO_COMMAND_TIMEOUT = 0x6401 71 | 72 | # 65 -- Execution error, the state of persistent memory is changed 73 | SWO_MEMORY_WRITE_ERROR = 0x6501 74 | SWO_MEMORY_FAILURE = 0x6581 75 | 76 | # 66 -- Security-related issues 77 | SWO_SECURITY_ISSUE = 0x6600 78 | SWO_RECEIVE_ERROR_PARITY = 0x6601 79 | SWO_WRONG_CHECKSUM = 0x6602 80 | SWO_INCORRECT_PADDING = 0x6669 81 | 82 | # 67 -- Transport error. The length is incorrect 83 | SWO_WRONG_LENGTH = 0x6700 84 | 85 | # 68 -- Functions in CLA not supported 86 | SWO_NOT_SUPPORTED_ERROR_NO_INFO = 0x6800 87 | SWO_LOGICAL_CHANNEL_NOT_SUPPORTED = 0x6881 88 | SWO_SECURE_MESSAGING_NOT_SUPPORTED = 0x6882 89 | SWO_LAST_COMMAND_OF_CHAIN_EXPECTED = 0x6883 90 | SWO_COMMAND_CHAINING_NOT_SUPPORTED = 0x6884 91 | 92 | # 69 -- Command not allowed 93 | SWO_COMMAND_ERROR_NO_INFO = 0x6900 94 | SWO_COMMAND_NOT_ACCEPTED = 0x6901 95 | SWO_COMMAND_NOT_ALLOWED = 0x6980 96 | SWO_SUBCOMMAND_NOT_ALLOWED = 0x6981 97 | SWO_SECURITY_CONDITION_NOT_SATISFIED = 0x6982 98 | SWO_AUTH_METHOD_BLOCKED = 0x6983 99 | SWO_REFERENCED_DATA_BLOCKED = 0x6984 100 | SWO_CONDITIONS_NOT_SATISFIED = 0x6985 101 | SWO_COMMAND_NOT_ALLOWED_EF = 0x6986 102 | SWO_EXPECTED_SECURE_MSG_OBJ_MISSING = 0x6987 103 | SWO_INCORRECT_SECURE_MSG_DATA_OBJ = 0x6988 104 | SWO_PERMISSION_DENIED = 0x69F0 105 | SWO_MISSING_PRIVILEGES = 0x69F1 106 | 107 | # 6A -- Wrong parameters (with details) 108 | SWO_PARAMETER_ERROR_NO_INFO = 0x6A00 109 | SWO_INCORRECT_DATA = 0x6A80 110 | SWO_FUNCTION_NOT_SUPPORTED = 0x6A81 111 | SWO_FILE_NOT_FOUND = 0x6A82 112 | SWO_RECORD_NOT_FOUND = 0x6A83 113 | SWO_INSUFFICIENT_MEMORY = 0x6A84 114 | SWO_INCONSISTENT_TLV_STRUCT = 0x6A85 115 | SWO_INCORRECT_P1_P2 = 0x6A86 116 | SWO_WRONG_DATA_LENGTH = 0x6A87 117 | SWO_REFERENCED_DATA_NOT_FOUND = 0x6A88 118 | SWO_FILE_ALREADY_EXISTS = 0x6A89 119 | SWO_DF_NAME_ALREADY_EXISTS = 0x6A8A 120 | SWO_WRONG_PARAMETER_VALUE = 0x6AF0 121 | 122 | # 6B -- Wrong parameters P1-P2 123 | SWO_WRONG_P1_P2 = 0x6B00 124 | 125 | # 6C -- Wrong Le field. lower byte indicates the appropriate length 126 | SWO_INCORRECT_P3_LENGTH = 0x6C00 127 | 128 | # 6D -- The instruction code is not supported 129 | SWO_INVALID_INS = 0x6D00 130 | 131 | # 6E -- The instruction class is not supported 132 | SWO_INVALID_CLA = 0x6E00 133 | 134 | # 6F -- No precise diagnosis is given 135 | SWO_UNKNOWN = 0x6F00 136 | 137 | # 9- -- 138 | SWO_SUCCESS = 0x9000 139 | SWO_BUSY = 0x9001 140 | SWO_PIN_NOT_SUCCESFULLY_VERIFIED = 0x9004 141 | SWO_RESULT_OK = 0x9100 142 | SWO_STATES_STATUS_WRONG = 0x9101 143 | SWO_TRANSACTION_LIMIT_REACHED = 0x9102 144 | SWO_INSUFFICIENT_MEM_FOR_CMD = 0x910E 145 | SWO_COMMAND_CODE_NOT_SUPPORTED = 0x911C 146 | SWO_INVALID_KEY_NUMBER = 0x9140 147 | SWO_WRONG_LENGTH_FOR_INS = 0x917E 148 | SWO_NO_EF_SELECTED = 0x9400 149 | SWO_ADDRESS_RANGE_EXCEEDED = 0x9402 150 | SWO_FID_NOT_FOUND = 0x9404 151 | SWO_PARSE_ERROR = 0x9405 152 | SWO_NO_PIN_DEFINED = 0x9802 153 | SWO_ACCESS_CONDITION_NOT_FULFILLED = 0x9804 154 | 155 | 156 | @dataclass 157 | class ExceptionRAPDU(Exception): 158 | """ 159 | Depending on the :class:`RaisePolicy `, 160 | communication with an application can raise this exception. 161 | 162 | Just like :class:`RAPDU `, it is composed of two 163 | attributes: 164 | 165 | - ``status`` (``int``), which is extracted from the two last bytes of the 166 | response, 167 | - ``data`` (``bytes``), which is the entire response payload, except the two 168 | last bytes. 169 | """ 170 | 171 | status: int 172 | data: bytes = bytes() 173 | 174 | def __str__(self): 175 | return f"Error [0x{self.status:x}] {str(self.data)}" 176 | -------------------------------------------------------------------------------- /tests/unit/utils/test_misc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from pathlib import Path 4 | from unittest import TestCase 5 | from unittest.mock import MagicMock 6 | 7 | from ragger.error import ExceptionRAPDU 8 | from ragger.utils import misc 9 | 10 | from ..helpers import temporary_directory 11 | 12 | 13 | class TestMisc(TestCase): 14 | 15 | def test_find_application_ok_c(self): 16 | device, sdk = "device", "sdk" 17 | with temporary_directory() as dir_path: 18 | tmp_dir = (dir_path / "build" / device / "bin") 19 | tmp_dir.mkdir(parents=True, exist_ok=True) 20 | expected = tmp_dir / "app.elf" 21 | expected.touch() 22 | result = misc.find_application(dir_path, device, sdk) 23 | self.assertEqual(result, expected) 24 | 25 | def test_find_application_ok_rust(self): 26 | device, sdk, appname = "device", "rust", "rustapp" 27 | with temporary_directory() as dir_path: 28 | cmd = ["cargo", "new", appname] 29 | subprocess.check_output(cmd, cwd=dir_path) 30 | app_path = dir_path / appname 31 | tmp_dir = (app_path / "target" / device / "release") 32 | tmp_dir.mkdir(parents=True, exist_ok=True) 33 | expected = tmp_dir / appname 34 | expected.touch() 35 | result = misc.find_application(app_path, device, sdk) 36 | self.assertEqual(result, expected) 37 | 38 | def test_find_application_nok_not_dir(self): 39 | directory, device, sdk = Path("does not exist"), "device", "sdk" 40 | with self.assertRaises(AssertionError) as error: 41 | misc.find_application(directory, device, sdk) 42 | self.assertIn(str(directory), str(error.exception)) 43 | 44 | def test_find_application_nok_not_file(self): 45 | device, sdk = "device", "sdk" 46 | with temporary_directory() as dir_path: 47 | tmp_dir = (dir_path / "build" / device / "bin") 48 | tmp_dir.mkdir(parents=True, exist_ok=True) 49 | expected = tmp_dir / "app.elf" 50 | with self.assertRaises(AssertionError) as error: 51 | misc.find_application(dir_path, device, sdk) 52 | self.assertIn(str(expected), str(error.exception)) 53 | 54 | def test_find_project_root_dir_ok(self): 55 | with temporary_directory() as dir_path: 56 | os.mkdir(Path(dir_path / ".git").resolve()) 57 | nested_dir = Path(dir_path / "subfolder").resolve() 58 | os.mkdir(nested_dir) 59 | nested_dir = Path(nested_dir / "another_subfolder").resolve() 60 | os.mkdir(nested_dir) 61 | self.assertEqual( 62 | Path(dir_path).resolve(), 63 | Path(misc.find_project_root_dir(nested_dir)).resolve()) 64 | 65 | def test_find_project_root_dir_nok(self): 66 | with temporary_directory() as dir_path: 67 | nested_dir = Path(dir_path / "subfolder").resolve() 68 | os.mkdir(nested_dir) 69 | nested_dir = Path(nested_dir / "another_subfolder").resolve() 70 | os.mkdir(nested_dir) 71 | with self.assertRaises(ValueError): 72 | misc.find_project_root_dir(nested_dir) 73 | 74 | def test_prefix_with_len(self): 75 | buffer = bytes.fromhex("0123456789") 76 | self.assertEqual(b"\x05" + buffer, misc.prefix_with_len(buffer)) 77 | 78 | def test_create_currency_config_no_sub(self): 79 | ticker = "ticker" # size 6 80 | name = "name" # size 4 81 | expected = b"\x06" + ticker.encode() + b"\x04" + name.encode() + b"\x00" 82 | self.assertEqual(expected, misc.create_currency_config(ticker, name, None)) 83 | 84 | def test_create_currency_config_full(self): 85 | ticker = "ticker" # size 6 86 | name = "name" # size 4 87 | subconfig = ("subconfig", 13) # size 9 + 1 88 | expected = b"\x06" + ticker.encode() + b"\x04" + name.encode() + b"\x0b" \ 89 | + b"\x09" + subconfig[0].encode() + subconfig[1].to_bytes(1, byteorder="big") 90 | self.assertEqual(expected, misc.create_currency_config(ticker, name, subconfig)) 91 | 92 | def test_split_message(self): 93 | message = b"some message to split" 94 | expected = [b"some ", b"messa", b"ge to", b" spli", b"t"] 95 | self.assertEqual(expected, misc.split_message(message, 5)) 96 | 97 | def test_get_current_app_name_and_version_ok(self): 98 | name, version = "appname", "12.345.6789" 99 | backend = MagicMock() 100 | # format (=1) 101 | # 102 | # 103 | # 104 | backend.exchange().data = bytes.fromhex("01") \ 105 | + len(name.encode()).to_bytes(1, "big") + name.encode() \ 106 | + len(version.encode()).to_bytes(1, "big") + version.encode() \ 107 | + bytes.fromhex("0112") 108 | result_name, result_version = misc.get_current_app_name_and_version(backend) 109 | self.assertEqual(name, result_name) 110 | self.assertEqual(version, result_version) 111 | 112 | def test_get_current_app_name_and_version_nok(self): 113 | error_status, backend = 0x1234, MagicMock() 114 | backend.exchange.side_effect = ExceptionRAPDU(error_status) 115 | with self.assertRaises(ExceptionRAPDU) as error: 116 | misc.get_current_app_name_and_version(backend) 117 | self.assertEqual(error.exception.status, error_status) 118 | # specific behavior: device locked 119 | backend.exchange.side_effect = ExceptionRAPDU(misc.ERROR_BOLOS_DEVICE_LOCKED) 120 | with self.assertRaises(ValueError) as error: 121 | misc.get_current_app_name_and_version(backend) 122 | self.assertEqual(misc.ERROR_MSG_DEVICE_LOCKED, str(error.exception)) 123 | 124 | def test_exit_current_app(self): 125 | backend = MagicMock() 126 | self.assertIsNone(misc.exit_current_app(backend)) 127 | 128 | def test_open_app_from_dashboard_ok(self): 129 | backend = MagicMock() 130 | self.assertIsNone(misc.open_app_from_dashboard(backend, "some app")) 131 | 132 | def test_open_app_from_dashboard_nok(self): 133 | error_status, backend = 0x1234, MagicMock() 134 | backend.exchange.side_effect = ExceptionRAPDU(error_status) 135 | with self.assertRaises(ExceptionRAPDU) as error: 136 | misc.open_app_from_dashboard(backend, "first app") 137 | self.assertEqual(error.exception.status, error_status) 138 | # specific behavior: user refuses 139 | backend.exchange.side_effect = ExceptionRAPDU(misc.ERROR_DENIED_BY_USER) 140 | with self.assertRaises(ValueError) as error: 141 | misc.open_app_from_dashboard(backend, "second app") 142 | # specific behavior: app not installed 143 | backend.exchange.side_effect = ExceptionRAPDU(misc.ERROR_APP_NOT_FOUND) 144 | with self.assertRaises(ValueError) as error: 145 | misc.open_app_from_dashboard(backend, "third app") 146 | -------------------------------------------------------------------------------- /tests/functional/backend/test_speculos.py: -------------------------------------------------------------------------------- 1 | from ledgered.devices import Devices, DeviceType 2 | from pathlib import Path 3 | from typing import Optional 4 | from unittest import TestCase 5 | from unittest.mock import patch 6 | 7 | from ragger.backend import SpeculosBackend, RaisePolicy 8 | from ragger.error import ExceptionRAPDU 9 | from ragger.utils import RAPDU 10 | 11 | from tests.stubs import SpeculosServerStub, EndPoint, APDUStatus 12 | 13 | ROOT_SCREENSHOT_PATH = Path(__file__).parent.parent.resolve() 14 | 15 | 16 | class TestbackendSpeculos(TestCase): 17 | """ 18 | Test patterns explained: 19 | 20 | ``` 21 | def test_something(self): 22 | # patches 'subprocess' so that Speculos Client won't actually launch an app inside Speculos 23 | with patch("speculos.client.subprocess"): 24 | # starts the Speculos server stub, which will answer the SpeculosClient requests 25 | with SpeculosServerStub(): 26 | # starts the backend: starts the underlying SpeculosClient so that exchanges can be 27 | # performed 28 | with self.backend: 29 | ``` 30 | 31 | Sent APDUs: 32 | 33 | - starting with '00' means the response has a APDUStatus.SUCCESS status (0x9000) 34 | - else means the response has a APDUStatus.ERROR status (arbitrarily set to 0x8000) 35 | """ 36 | 37 | def check_rapdu(self, rapdu: RAPDU, expected: Optional[bytes] = None, status: int = 0x9000): 38 | self.assertEqual(rapdu.status, status) 39 | if expected is None: 40 | return 41 | self.assertEqual(rapdu.data, expected) 42 | 43 | def setUp(self): 44 | self.device = Devices.get_by_type(DeviceType.NANOS) 45 | self.backend = SpeculosBackend("some app", self.device) 46 | 47 | def test_exchange_raw(self): 48 | with patch("speculos.client.subprocess"): 49 | with SpeculosServerStub(): 50 | with self.backend: 51 | rapdu = self.backend.exchange_raw(bytes.fromhex("00000000")) 52 | self.check_rapdu(rapdu, expected=bytes.fromhex(EndPoint.APDU)) 53 | 54 | def test_exchange_raw_error(self): 55 | with patch("speculos.client.subprocess"): 56 | with SpeculosServerStub(): 57 | with self.backend: 58 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING 59 | rapdu = self.backend.exchange_raw(bytes.fromhex("01000000")) 60 | self.check_rapdu(rapdu, 61 | expected=bytes.fromhex(EndPoint.APDU), 62 | status=APDUStatus.ERROR) 63 | 64 | def test_exchange_raw_raises(self): 65 | with patch("speculos.client.subprocess"): 66 | with SpeculosServerStub(): 67 | with self.backend: 68 | with self.assertRaises(ExceptionRAPDU) as error: 69 | self.backend.exchange_raw(bytes.fromhex("01000000")) 70 | self.assertEqual(error.exception.status, APDUStatus.ERROR) 71 | 72 | def test_exchange_raw_raise_valid(self): 73 | with patch("speculos.client.subprocess"): 74 | with SpeculosServerStub(): 75 | with self.backend: 76 | self.backend.raise_policy = RaisePolicy.RAISE_ALL 77 | with self.assertRaises(ExceptionRAPDU) as error: 78 | self.backend.exchange_raw(bytes.fromhex("00000000")) 79 | self.assertEqual(error.exception.status, APDUStatus.SUCCESS) 80 | 81 | def test_send_raw(self): 82 | with patch("speculos.client.subprocess"): 83 | with SpeculosServerStub(): 84 | with self.backend: 85 | self.assertIsNone(self.backend._pending) 86 | self.backend.send_raw(bytes.fromhex("00000000")) 87 | self.assertIsNotNone(self.backend._pending) 88 | 89 | def test_receive_error(self): 90 | with self.assertRaises(AssertionError): 91 | self.backend.receive() 92 | 93 | def test_receive_ok(self): 94 | with patch("speculos.client.subprocess"): 95 | with SpeculosServerStub(): 96 | with self.backend: 97 | self.backend.send_raw(bytes.fromhex("00000000")) 98 | rapdu = self.backend.receive() 99 | self.check_rapdu(rapdu, expected=bytes.fromhex(EndPoint.APDU)) 100 | 101 | def test_exchange_async_raw_ok(self): 102 | with patch("speculos.client.subprocess"): 103 | with SpeculosServerStub(): 104 | with self.backend: 105 | with self.backend.exchange_async_raw(bytes.fromhex("00000000")): 106 | self.assertIsNone(self.backend.last_async_response) 107 | rapdu = self.backend.last_async_response 108 | self.assertIsNotNone(rapdu) 109 | self.check_rapdu(rapdu, expected=bytes.fromhex(EndPoint.APDU)) 110 | 111 | def test_exchange_async_raw_error(self): 112 | with patch("speculos.client.subprocess"): 113 | with SpeculosServerStub(): 114 | with self.backend: 115 | self.backend.raise_policy = RaisePolicy.RAISE_NOTHING 116 | with self.backend.exchange_async_raw(bytes.fromhex("01000000")): 117 | self.assertIsNone(self.backend.last_async_response) 118 | rapdu = self.backend.last_async_response 119 | self.assertIsNotNone(rapdu) 120 | self.check_rapdu(rapdu, 121 | expected=bytes.fromhex(EndPoint.APDU), 122 | status=APDUStatus.ERROR) 123 | 124 | def test_exchange_async_raw_raises(self): 125 | with patch("speculos.client.subprocess"): 126 | with SpeculosServerStub(): 127 | with self.backend: 128 | with self.assertRaises(ExceptionRAPDU) as error: 129 | with self.backend.exchange_async_raw(bytes.fromhex("01000000")): 130 | pass 131 | self.assertEqual(error.exception.status, APDUStatus.ERROR) 132 | 133 | def test_exchange_async_raw_raise_valid(self): 134 | with patch("speculos.client.subprocess"): 135 | with SpeculosServerStub(): 136 | with self.backend: 137 | self.backend.raise_policy = RaisePolicy.RAISE_ALL 138 | with self.assertRaises(ExceptionRAPDU) as error: 139 | with self.backend.exchange_async_raw(bytes.fromhex("00000000")): 140 | pass 141 | self.assertEqual(error.exception.status, APDUStatus.SUCCESS) 142 | 143 | def test_clicks(self): 144 | with patch("speculos.client.subprocess"): 145 | with SpeculosServerStub(): 146 | with self.backend: 147 | self.backend.right_click() 148 | self.backend.left_click() 149 | self.backend.both_click() 150 | -------------------------------------------------------------------------------- /tests/unit/conftests/test_base_conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from ledgered.devices import DeviceType, Devices 4 | from pathlib import Path 5 | from typing import Tuple 6 | from unittest import TestCase 7 | from unittest.mock import patch 8 | 9 | from ragger.conftest import base_conftest as bc 10 | 11 | from ..helpers import temporary_directory 12 | 13 | 14 | def prepare_base_dir(directory: Path) -> Tuple[Path, Path]: 15 | (directory / ".git").mkdir() 16 | (directory / "build" / "stax" / "bin").mkdir(parents=True, exist_ok=True) 17 | (directory / "deps" / "dep" / "build" / "stax" / "bin").mkdir(parents=True, exist_ok=True) 18 | dep_path = (directory / "deps" / "dep" / "build" / "stax" / "bin" / "app.elf") 19 | dep_path.touch() 20 | token_file_path = (directory / "deps" / ".ethereum_application_build_goes_there") 21 | token_file_path.touch() 22 | app_path = (directory / "build" / "stax" / "bin" / "app.elf") 23 | app_path.touch() 24 | return app_path, dep_path 25 | 26 | 27 | @dataclass 28 | class AppMock: 29 | build_directory: str 30 | sdk: str 31 | 32 | 33 | @dataclass 34 | class ManifestMock: 35 | app: AppMock 36 | 37 | @staticmethod 38 | def from_path(*args): 39 | return ManifestMock(AppMock(".", "c")) 40 | 41 | 42 | class TestBaseConftest(TestCase): 43 | 44 | def setUp(self): 45 | self.seed = "some seed" 46 | self.stax = Devices.get_by_type(DeviceType.STAX) 47 | 48 | def test_prepare_speculos_args_simplest(self): 49 | with temporary_directory() as temp_dir: 50 | app_path, _ = prepare_base_dir(temp_dir) 51 | with patch("ragger.conftest.base_conftest.Manifest", ManifestMock): 52 | result_app, result_args = bc.prepare_speculos_args(temp_dir, self.stax, False, 53 | False, self.seed, []) 54 | self.assertEqual(result_app, app_path) 55 | self.assertEqual(result_args, {"args": ["--seed", self.seed]}) 56 | 57 | def test_prepare_speculos_args_with_prod_pki(self): 58 | with temporary_directory() as temp_dir: 59 | app_path, _ = prepare_base_dir(temp_dir) 60 | with patch("ragger.conftest.base_conftest.Manifest", ManifestMock): 61 | result_app, result_args = bc.prepare_speculos_args(temp_dir, self.stax, False, True, 62 | self.seed, []) 63 | self.assertEqual(result_app, app_path) 64 | self.assertEqual(result_args, {"args": ["-p", "--seed", self.seed]}) 65 | 66 | def test_prepare_speculos_args_with_custom_args(self): 67 | arg = "whatever" 68 | with temporary_directory() as temp_dir: 69 | app_path, _ = prepare_base_dir(temp_dir) 70 | with patch("ragger.conftest.base_conftest.Manifest", ManifestMock): 71 | result_app, result_args = bc.prepare_speculos_args(temp_dir, self.stax, False, 72 | False, self.seed, [arg]) 73 | self.assertEqual(result_app, app_path) 74 | self.assertEqual(result_args, {"args": [arg, "--seed", self.seed]}) 75 | 76 | def test_prepare_speculos_args_simple_with_gui(self): 77 | with temporary_directory() as temp_dir: 78 | app_path, _ = prepare_base_dir(temp_dir) 79 | with patch("ragger.conftest.base_conftest.Manifest", ManifestMock): 80 | result_app, result_args = bc.prepare_speculos_args(temp_dir, self.stax, True, False, 81 | self.seed, []) 82 | self.assertEqual(result_app, app_path) 83 | self.assertEqual(result_args, {"args": ["--display", "qt", "--seed", self.seed]}) 84 | 85 | def test_prepare_speculos_args_main_as_library(self): 86 | with temporary_directory() as temp_dir: 87 | app_path, dep_path = prepare_base_dir(temp_dir) 88 | with patch("ragger.conftest.base_conftest.conf.OPTIONAL.MAIN_APP_DIR", "./deps"): 89 | with patch("ragger.conftest.base_conftest.Manifest", ManifestMock) as manifest: 90 | result_app, result_args = bc.prepare_speculos_args( 91 | temp_dir, self.stax, False, False, self.seed, []) 92 | self.assertEqual(result_app, dep_path) 93 | self.assertEqual(result_args, {"args": [f"-l{app_path}", "--seed", self.seed]}) 94 | 95 | def test_prepare_speculos_args_sideloaded_apps_ok(self): 96 | with temporary_directory() as temp_dir: 97 | app_path, _ = prepare_base_dir(temp_dir) 98 | sideloaded_apps_dir = temp_dir / "here" 99 | lib1_path = sideloaded_apps_dir / "lib1/build/stax/bin/" 100 | lib2_path = sideloaded_apps_dir / "lib2/build/stax/bin/" 101 | os.makedirs(lib1_path) 102 | os.makedirs(lib2_path) 103 | lib1_exe = lib1_path / "app.elf" 104 | lib2_exe = lib2_path / "app.elf" 105 | lib1_exe.touch() 106 | lib2_exe.touch() 107 | 108 | with patch("ragger.conftest.base_conftest.conf.OPTIONAL.SIDELOADED_APPS_DIR", 109 | sideloaded_apps_dir): 110 | 111 | with patch("ragger.conftest.base_conftest.Manifest", ManifestMock): 112 | result_app, result_args = bc.prepare_speculos_args( 113 | temp_dir, self.stax, False, False, self.seed, []) 114 | self.assertEqual(result_app, app_path) 115 | self.assertEqual(result_args, 116 | {"args": [f"-l{lib1_exe}", f"-l{lib2_exe}", "--seed", self.seed]}) 117 | 118 | def test_create_backend_nok(self): 119 | with self.assertRaises(ValueError): 120 | bc.create_backend(None, "does not exist", None, None, None, None, None, []) 121 | 122 | def test_create_backend_speculos(self): 123 | with patch("ragger.conftest.base_conftest.SpeculosBackend") as backend: 124 | with temporary_directory() as temp_dir: 125 | prepare_base_dir(temp_dir) 126 | with patch("ragger.conftest.base_conftest.Manifest", ManifestMock): 127 | result = bc.create_backend(temp_dir, "Speculos", self.stax, False, False, None, 128 | self.seed, []) 129 | self.assertEqual(result, backend()) 130 | 131 | def test_create_backend_ledgercomm(self): 132 | with patch("ragger.conftest.base_conftest.LedgerWalletBackend") as backend: 133 | result = bc.create_backend(None, "ledgerWALLET", self.stax, False, False, None, 134 | self.seed, []) 135 | self.assertEqual(result, backend()) 136 | 137 | def test_create_backend_ledgerwallet(self): 138 | with patch("ragger.conftest.base_conftest.LedgerCommBackend") as backend: 139 | result = bc.create_backend(None, "LedgerComm", self.stax, False, False, None, self.seed, 140 | []) 141 | self.assertEqual(result, backend()) 142 | -------------------------------------------------------------------------------- /doc/rationale.rst: -------------------------------------------------------------------------------- 1 | .. _Rationale: 2 | 3 | =========== 4 | Rationale 5 | =========== 6 | 7 | Testing, easily manipulating a Ledger :term:`application` is hard. Although 8 | :term:`Speculos` strongly eases it, it cannot always replace IRL tests on 9 | physical devices. 10 | 11 | There are libraries allowing to communicate with a physical device. However, 12 | it would be very convenient to be able to develop code which would be compatible 13 | wherever the application runs, either on an emulator or on a physical device. 14 | 15 | On top of this, all these libraries provides low-level functions like pressing a 16 | button or sending APDUs. Applications can be complex pieces of software and 17 | testing one require higher-level code. 18 | 19 | Abstracting the backends 20 | ======================== 21 | 22 | The original goal of ``Ragger`` is to make the manipulation of an 23 | :term:`application` oblivious of the underlying device. Most applications are 24 | tested against :term:`Speculos`, the Ledger device emulator, but it is not 25 | always enough, and testing on real physical devices is often required. 26 | 27 | ``Ragger`` provides an interface which wraps several libraries: 28 | 29 | - one emulator-only library: :term:`Speculos` (which uses itself as an emulator), 30 | - two agnostic libraries, which can communicate with either a physical device, 31 | or an emulated one: 32 | 33 | - :term:`LedgerComm` 34 | - :term:`LedgerWallet` 35 | 36 | In ``Ragger``, the classes embedding these libraries are called 37 | :term:`backends`. Any other backend can be added, given it respects the 38 | :py:class:`BackendInterface ` interface. 39 | 40 | .. thumbnail:: images/usage.svg 41 | :align: center 42 | :title: Software / communication layers between an application and its client 43 | :show_caption: true 44 | 45 | Application :term:`clients` using ``Ragger`` must comply with this 46 | interface to communicate with the application. Once it's done, the client 47 | can communicate with an application running either on top of an emulator or on 48 | a real, physical device with very little cost. 49 | 50 | Why not **no** cost? That's because the backend actually needs to talk to 51 | something. Speculos is conveniently able to start its emulator itself, however 52 | the other backends will need the application to be already started. Typically, 53 | the application will have to be installed on a physical device, started, and the 54 | device connected to the computer launching the client. 55 | 56 | How to exploit these capabilities to write test running on both emulated or 57 | physical device is documented in the :ref:`tutorial section`. 58 | 59 | 60 | Easing the development of application clients 61 | ============================================= 62 | 63 | On top of abstracting the backend, ``Ragger`` provides tools and mechanisms 64 | allowing to ease the development of application clients and write more thorough 65 | tests. 66 | 67 | .. _rationale_navigation: 68 | 69 | Navigation 70 | ---------- 71 | 72 | In particular, ``Ragger`` offers abstraction layers to declare application flows 73 | without repeating oneself. This is the role of the 74 | :py:class:`Navigator ` interface. 75 | 76 | This class allows to declare a set of 77 | :py:class:`navigation instructions `) which, bound with 78 | callbacks, allows to abstract the expected behavior of an application. 79 | 80 | Once the instructions are declared, it is possible to declare feature flows as 81 | a list of instructions. 82 | 83 | .. thumbnail:: images/navigate.svg 84 | :align: center 85 | :title: Software / communication layers between an application and its client 86 | :show_caption: true 87 | 88 | The ``Navigator`` class also offers (with Speculos) screenshot checking 89 | capabilities: while the instructions are performed, ``Ragger`` takes screenshots 90 | of the application's screen, and is able to save them or compare them with 91 | :term:`golden snapshots ` to check if the application behaves 92 | as expected. 93 | 94 | This does not sound like much, but as soon as an application get a bit complex, 95 | it helps a lot to write code which on the first hand manipulate high-level 96 | concept as validating a transaction, and on the other hand deal with low-level 97 | details such as crafting an :term:`APDU` and click on a button at the right time. 98 | 99 | Touch screen management 100 | ----------------------- 101 | 102 | Dealing with UI and user interaction is never simple. Nano devices has only two 103 | user physical inputs, through the two buttons, which already allows some 104 | elaborate combinations that could be challenging to test automatically. 105 | 106 | With the touchable screens devices, the number of possibilities 107 | drastically increases. 108 | 109 | ``Ragger`` embeds tools allowing to ease the development and the maintenance of 110 | UI clients. this tools mainly consist of 3 components: 111 | 112 | - the :py:class:`layout classes `, representing 113 | the layouts proposed in the NBGL section of the C SDK, 114 | - the :py:class:`use cases classes `, 115 | representing the use cases proposed in the NBGL section of the C SDK, 116 | - the :py:mod:`screen module `, allowing to nest 117 | the previous components in a single, centralized object. 118 | 119 | .. note:: 120 | 121 | If you are familiar with the :term:`NBGL` library, you will notice that 122 | ``Ragger`` does not implement a :term:`Page` representation. It will be 123 | integrated eventually. 124 | 125 | 126 | These components bring multiple benefits: 127 | 128 | - these abstractions prevent to directly use ``(X, Y)`` coordinates to interact 129 | with the screen and propose higher-level methods (for instance, when using the 130 | :py:class:`UseCaseHome ` use case, 131 | going to the settings is triggered with the method ``UseCaseHome.settings()`` 132 | instead of touching the screen at ``(342, 55)``). The client's code is 133 | meaningful. 134 | - ``Ragger`` internally keeps track of these positions on **every** :term:`SDK` 135 | version. If a new SDK version moves a button to other coordinates, the 136 | code written with the ``Ragger`` components will stay valid and functional. 137 | - the :term:`layouts ` and :term:`use cases ` mimic the 138 | :term:`NBGL` capabilities, so that the ``Ragger`` client screen architecture 139 | is close to the application one. 140 | - the :py:class:`FullScreen ` class 141 | embeds every existing :py:class:`layout ` and 142 | :py:class:`use case ` in a single class, 143 | providing a fast way of testing an interface without any other configuration. 144 | - the :py:class:`MetaScreen ` metaclass 145 | allows to build custom screen classes nesting the 146 | :py:class:`layouts ` and the 147 | :py:class:`use cases ` of your choosing, 148 | creating a convenient and meaningful screen object where all UI interactions 149 | are centralized. 150 | 151 | 152 | You can find example of these components in :ref:`this tutorial `. 153 | --------------------------------------------------------------------------------