Skip to content

Commit

Permalink
Merge pull request #1 from LedgerHQ/dirty
Browse files Browse the repository at this point in the history
Adding context manager on exchange to allow pre-response interactions
  • Loading branch information
lpascal-ledger authored May 12, 2022
2 parents 6a3ff26 + 4796f9e commit 1ac2f6b
Show file tree
Hide file tree
Showing 8 changed files with 218 additions and 51 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build_and_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
uses: dawidd6/action-download-artifact@v2
with:
repo: LedgerHQ/app-boilerplate
name: boilerplate-app-nanoS-debug
name: boilerplate-app-nanoS
workflow: ci-workflow.yml
path: tests/elfs/
- name: Check the downloaded files
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: Documentation generation & update

on:
push:
branches:
- develop
- master
pull_request:
branches:
- develop
Expand All @@ -28,7 +31,7 @@ jobs:
name: Deploy the documentation on Github pages
runs-on: ubuntu-latest
needs: generate
# if: github.ref == 'refs/heads/master'
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- name: Download documentation bundle
uses: actions/download-artifact@v2
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ following fixtures:

```python
import pytest
from ragger.backends import SpeculosBackend, LedgerCommBackend
from ragger.backend import SpeculosBackend, LedgerCommBackend

# adding an pytest CLI option "--live"
def pytest_addoption(parser):
Expand All @@ -35,7 +35,7 @@ def live(pytestconfig):

# Depending on the "--live" option value, a different backend is instantiated,
# and the tests will either run on Speculos or on a physical device
@pytest.fixture(scope="session")
@pytest.fixture
def client(live):
if live:
backend = LedgerCommBackend(interface="hid", raises=True)
Expand Down
128 changes: 109 additions & 19 deletions src/ragger/backend/interface.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import dataclass
from types import TracebackType
from typing import Optional, Type
from typing import Optional, Type, Iterable, Generator

from ragger.utils import pack_APDU

Expand All @@ -12,30 +13,50 @@ class RAPDU:
data: bytes

def __str__(self):
return f'[0x{self.status:02x}] {self.data}'
return f'[0x{self.status:02x}] {self.data.hex()}'


class BackendInterface(ABC):

def __init__(self, host: str, port: int, raises: bool = False):
def __init__(self,
host: str,
port: int,
raises: bool = False,
valid_statuses: Iterable[int] = (0x9000, )):
"""Initializes the Backend
:param host: The host where to reach the backend.
:type host: str
:param port: The port where to reach the backend
:type port: int
:param raises: Weither the instance should raises on non-0x9000 response
:param raises: Weither the instance should raises on non-valid response
statuses, or not.
:type raises: bool
:param valid_statuses: a list of RAPDU statuses considered successfull
(default: [0x9000])
:type valid_statuses: any iterable
"""
self._host = host
self._port = port
self._raises = raises
self._valid_statuses = valid_statuses
self._last_async_response: Optional[RAPDU] = None

@property
def last_async_response(self) -> Optional[RAPDU]:
"""
:return: The last RAPDU received after a call to `exchange_async` or
`exchange_async_raw`. `None` if no called was made, or if it
resulted into an exception throw.
:rtype: RAPDU
"""
return self._last_async_response

@property
def raises(self) -> bool:
"""
:return: Weither the instance raises on non-0x9000 response statuses or not.
:return: Weither the instance raises on non-0x9000 response statuses or
not.
:rtype: bool
"""
return self._raises
Expand All @@ -48,6 +69,16 @@ def url(self) -> str:
"""
return f"{self._host}:{self._port}"

def is_valid(self, status: int) -> bool:
"""
:param status: A status to check
:type status: int
:return: If the given status is considered valid or not
:rtype: bool
"""
return status in self._valid_statuses

@abstractmethod
def __enter__(self) -> "BackendInterface":
raise NotImplementedError
Expand All @@ -67,8 +98,6 @@ def send(self,
"""
Formats then sends an APDU to the backend.
The length is automaticaly added to the APDU message.
:param cla: The application ID
:type cla: int
:param ins: The command ID
Expand All @@ -80,8 +109,8 @@ def send(self,
:param data: Command data
:type data: bytes
:return: Nothing
:rtype: None
:return: None
:rtype: NoneType
"""
return self.send_raw(pack_APDU(cla, ins, p1, p2, data))

Expand All @@ -96,8 +125,8 @@ def send_raw(self, data: bytes = b"") -> None:
:param data: The APDU message
:type data: bytes
:return: Nothing
:rtype: None
:return: None
:rtype: NoneType
"""
raise NotImplementedError

Expand All @@ -111,7 +140,7 @@ def receive(self) -> RAPDU:
:raises ApduException: If the `raises` attribute is True, this method
will raise if the backend returns a status code
different from 0x9000
not registered a a `valid_statuses`
:return: The APDU response
:rtype: RAPDU
Expand Down Expand Up @@ -162,13 +191,74 @@ def exchange_raw(self, data: bytes = b"") -> RAPDU:
:raises ApduException: If the `raises` attribute is True, this method
will raise if the backend returns a status code
different from 0x9000
not registered a a `valid_statuses`
:return: The APDU response
:rtype: RAPDU
"""
raise NotImplementedError

@contextmanager
def exchange_async(self,
cla: int,
ins: int,
p1: int = 0,
p2: int = 0,
data: bytes = b"") -> Generator[None, None, None]:
"""
Formats and sends an APDU to the backend, then gives the control back to
the caller.
This can be useful to still control a device when it has not responded
yet, for instance when a UI validation is requested.
The context manager does not yield anything. Interaction through the
backend instance is still possible.
:param cla: The application ID
:type cla: int
:param ins: The command ID
:type ins: int
:param p1: First instruction parameter, defaults to 0
:type p1: int
:param p1: Second instruction parameter, defaults to 0
:type p1: int
:param data: Command data
:type data: bytes
:raises ApduException: If the `raises` attribute is True, this method
will raise if the backend returns a status code
not registered a a `valid_statuses`
:return: None
:rtype: NoneType
"""
with self.exchange_async_raw(pack_APDU(cla, ins, p1, p2, data)):
yield

@contextmanager
@abstractmethod
def exchange_async_raw(self,
data: bytes = b"") -> Generator[None, None, None]:
"""
Sends the given APDU to the backend, then gives the control back to the
caller.
Every part of the APDU (including length) are the caller's
responsibility
:param data: The APDU message
:type data: bytes
:raises ApduException: If the `raises` attribute is True, this method
will raise if the backend returns a status code
not registered a a `valid_statuses`
:return: None
:rtype: NoneType
"""
raise NotImplementedError

@abstractmethod
def right_click(self) -> None:
"""
Expand All @@ -181,8 +271,8 @@ def right_click(self) -> None:
get stuck (on further call to `receive` for instance) until the expected
action is performed on the device.
:return: Nothing
:rtype: None
:return: None
:rtype: NoneType
"""
raise NotImplementedError

Expand All @@ -198,8 +288,8 @@ def left_click(self) -> None:
get stuck (on further call to `receive` for instance) until the expected
action is performed on the device.
:return: Nothing
:rtype: None
:return: None
:rtype: NoneType
"""
raise NotImplementedError

Expand All @@ -215,7 +305,7 @@ def both_click(self) -> None:
get stuck (on further call to `receive` for instance) until the expected
action is performed on the device.
:return: Nothing
:rtype: None
:return: None
:rtype: NoneType
"""
raise NotImplementedError
23 changes: 17 additions & 6 deletions src/ragger/backend/ledgercomm.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Optional
from contextlib import contextmanager
from typing import Optional, Iterable, Generator

from ledgercomm import Transport
from speculos.client import ApduException
Expand All @@ -9,11 +10,10 @@

def manage_error(function):

def decoration(*args, **kwargs) -> RAPDU:
self: LedgerCommBackend = args[0]
rapdu: RAPDU = function(*args, **kwargs)
def decoration(self: 'LedgerCommBackend', *args, **kwargs) -> RAPDU:
rapdu: RAPDU = function(self, *args, **kwargs)
logger.debug("Receiving '%s'", rapdu)
if rapdu.status == 0x9000 or not self.raises:
if not self.raises or self.is_valid(rapdu.status):
return rapdu
# else should raise
raise ApduException(rapdu.status, rapdu.data)
Expand All @@ -28,9 +28,13 @@ def __init__(self,
port: int = 9999,
raises: bool = False,
interface: str = 'hid',
valid_statuses: Iterable[int] = (0x9000, ),
*args,
**kwargs):
super().__init__(host, port, raises=raises)
super().__init__(host,
port,
raises=raises,
valid_statuses=valid_statuses)
self._client: Optional[Transport] = None
kwargs['interface'] = interface
self._args = (args, kwargs)
Expand Down Expand Up @@ -67,6 +71,13 @@ def exchange_raw(self, data: bytes = b"") -> RAPDU:
logger.debug("Exchange: receiving < '%s'", result)
return result

@contextmanager
def exchange_async_raw(self,
data: bytes = b"") -> Generator[None, None, None]:
self.send_raw(data)
yield
self._last_async_response = self.receive()

def right_click(self) -> None:
pass

Expand Down
32 changes: 26 additions & 6 deletions src/ragger/backend/speculos.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pathlib import Path
from typing import Optional
from contextlib import contextmanager
from typing import Optional, Iterable, Generator

from speculos.client import SpeculosClient, ApduResponse, ApduException

Expand All @@ -9,12 +10,11 @@

def manage_error(function):

def decoration(*args, **kwargs) -> RAPDU:
self: SpeculosBackend = args[0]
def decoration(self: 'SpeculosBackend', *args, **kwargs) -> RAPDU:
try:
rapdu = function(*args, **kwargs)
rapdu = function(self, *args, **kwargs)
except ApduException as error:
if self.raises:
if self.raises and not self.is_valid(error.sw):
raise error
rapdu = RAPDU(error.sw, error.data)
logger.debug("Receiving '%s'", rapdu)
Expand All @@ -30,8 +30,12 @@ def __init__(self,
host: str = "127.0.0.1",
port: int = 5000,
raises: bool = False,
valid_statuses: Iterable[int] = (0x9000, ),
**kwargs):
super().__init__(host, port, raises=raises)
super().__init__(host,
port,
raises=raises,
valid_statuses=valid_statuses)
self._client: SpeculosClient = SpeculosClient(app=str(application),
api_url=self.url,
**kwargs)
Expand Down Expand Up @@ -65,6 +69,22 @@ def exchange_raw(self, data: bytes = b"") -> RAPDU:
logger.debug("Sending '%s'", data)
return RAPDU(0x9000, self._client._apdu_exchange(data))

@contextmanager
def exchange_async_raw(self,
data: bytes = b"") -> Generator[None, None, None]:
with self._client.apdu_exchange_nowait(cla=data[0],
ins=data[1],
p1=data[2],
p2=data[3],
data=data[5:]) as response:
yield
try:
self._last_async_response = response.receive()
except ApduException as error:
if self.raises and not self.is_valid(error.sw):
raise error
self._last_async_response = RAPDU(error.sw, error.data)

def right_click(self) -> None:
self._client.press_and_release("right")

Expand Down
Loading

0 comments on commit 1ac2f6b

Please sign in to comment.