python-ledgercomm/0000755000175000017500000000000015013713254016454 5ustar manuelguerramanuelguerrapython-ledgercomm/ledgercomm/0000755000175000017500000000000015013676324020600 5ustar manuelguerramanuelguerrapython-ledgercomm/ledgercomm/transport.py0000744000175000017500000001572514550232424023213 0ustar manuelguerramanuelguerra"""ledgercomm.transport module.""" import enum import logging import struct from typing import Union, Tuple, Optional, Literal, cast from ledgercomm.interfaces.tcp_client import TCPClient from ledgercomm.interfaces.hid_device import HID from ledgercomm.log import LOG class TransportType(enum.Enum): """Type of interface available.""" HID = 1 TCP = 2 class Transport: """Transport class to send APDUs. Allow to communicate using HID device such as Nano S/X or through TCP socket with the Speculos emulator. Parameters ---------- interface : str Either "hid" or "tcp" for the underlying communication interface. server : str IP adress of the TCP server if interface is "tcp". port : int Port of the TCP server if interface is "tcp". debug : bool Whether you want debug logs or not. Attributes ---------- interface : TransportType Either TransportType.HID or TransportType.TCP. com : Union[TCPClient, HID] Communication interface to send/receive APDUs. """ def __init__(self, interface: Literal["hid", "tcp"] = "tcp", server: str = "127.0.0.1", port: int = 9999, debug: bool = False) -> None: """Init constructor of Transport.""" if debug: LOG.setLevel(logging.DEBUG) # create console handler and set level to debug ch = logging.StreamHandler() ch.setLevel(logging.DEBUG) # create formatter formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s') # add formatter to ch ch.setFormatter(formatter) # add ch to logger LOG.addHandler(ch) self.interface: TransportType try: self.interface = TransportType[interface.upper()] except KeyError as exc: raise KeyError(f"Unknown interface '{interface}'!") from exc self.com: Union[TCPClient, HID] = (TCPClient(server=server, port=port) if self.interface == TransportType.TCP else HID()) self.com.open() @staticmethod def apdu_header(cla: int, ins: Union[int, enum.IntEnum], p1: int = 0, p2: int = 0, opt: Optional[int] = None, lc: int = 0) -> bytes: """Pack the APDU header as bytes. Parameters ---------- cla : int Instruction class: CLA (1 byte) ins : Union[int, IntEnum] Instruction code: INS (1 byte) p1 : int Instruction parameter: P1 (1 byte). p2 : int Instruction parameter: P2 (1 byte). opt : Optional[int] Optional parameter: Opt (1 byte). lc : int Number of bytes in the payload: Lc (1 byte). Returns ------- bytes APDU header packed with parameters. """ ins = cast(int, ins.value) if isinstance(ins, enum.IntEnum) else cast(int, ins) if opt: return struct.pack( "BBBBBB", cla, ins, p1, p2, 1 + lc, # add option to length opt) return struct.pack("BBBBB", cla, ins, p1, p2, lc) def send(self, cla: int, ins: Union[int, enum.IntEnum], p1: int = 0, p2: int = 0, option: Optional[int] = None, cdata: bytes = b"") -> int: """Send structured APDUs through `self.com`. Parameters ---------- cla : int Instruction class: CLA (1 byte) ins : Union[int, IntEnum] Instruction code: INS (1 byte) p1 : int Instruction parameter: P1 (1 byte). p2 : int Instruction parameter: P2 (1 byte). option : Optional[int] Optional parameter: Opt (1 byte). cdata : bytes Command data (variable length). Returns ------- int Total lenght of the APDU sent. """ header: bytes = Transport.apdu_header(cla, ins, p1, p2, option, len(cdata)) return self.com.send(header + cdata) def send_raw(self, apdu: Union[str, bytes]) -> int: """Send raw bytes `apdu` through `self.com`. Parameters ---------- apdu : Union[str, bytes] Hexstring or bytes within APDU to be sent through `self.com`. Returns ------- Optional[int] Total lenght of APDU sent if any. """ if isinstance(apdu, str): apdu = bytes.fromhex(apdu) return self.com.send(apdu) def recv(self) -> Tuple[int, bytes]: """Receive data from `self.com`. Blocking IO. Returns ------- Tuple[int, bytes] A pair (sw, rdata) for the status word (2 bytes represented as int) and the reponse data (variable lenght). """ return self.com.recv() def exchange(self, cla: int, ins: Union[int, enum.IntEnum], p1: int = 0, p2: int = 0, option: Optional[int] = None, cdata: bytes = b"") -> Tuple[int, bytes]: """Send structured APDUs and wait to receive datas from `self.com`. Parameters ---------- cla : int Instruction class: CLA (1 byte) ins : Union[int, IntEnum] Instruction code: INS (1 byte) p1 : int Instruction parameter: P1 (1 byte). p2 : int Instruction parameter: P2 (1 byte). option : Optional[int] Optional parameter: Opt (1 byte). cdata : bytes Command data (variable length). Returns ------- Tuple[int, bytes] A pair (sw, rdata) for the status word (2 bytes represented as int) and the reponse data (bytes of variable lenght). """ header: bytes = Transport.apdu_header(cla, ins, p1, p2, option, len(cdata)) return self.com.exchange(header + cdata) def exchange_raw(self, apdu: Union[str, bytes]) -> Tuple[int, bytes]: """Send raw bytes `apdu` and wait to receive datas from `self.com`. Parameters ---------- apdu : Union[str, bytes] Hexstring or bytes within APDU to send through `self.com`. Returns ------- Tuple[int, bytes] A pair (sw, rdata) for the status word (2 bytes represented as int) and the reponse (bytes of variable lenght). """ if isinstance(apdu, str): apdu = bytes.fromhex(apdu) return self.com.exchange(apdu) def close(self) -> None: """Close `self.com` interface. Returns ------- None """ self.com.close() python-ledgercomm/ledgercomm/cli/0000755000175000017500000000000015013676324021347 5ustar manuelguerramanuelguerrapython-ledgercomm/ledgercomm/cli/send.py0000744000175000017500000000562414550232424022654 0ustar manuelguerramanuelguerra"""ledgercomm.cli.send module.""" import argparse from pathlib import Path import re from typing import Iterator, Optional from ledgercomm import Transport, __version__ def parse_file(filepath: Path, condition: Optional[str]) -> Iterator[str]: """Filter with `condition` and yield line of `filepath`.""" with open(filepath, "r", encoding="utf-8") as f: for line in f: if condition and line.startswith(condition): yield line.replace(condition, "").strip() else: yield line.strip() def main(): """Entrypoint of ledgercomm-parse binary.""" parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(help="sub-command help", dest="command") # file subparser parser_file = subparsers.add_parser("file", help="send APDUs from file") parser_file.add_argument("filepath", help="path of the file within APDUs") # stdin subparser _ = subparsers.add_parser("stdin", help="send APDUs from stdin") # stdin subparser parser_log = subparsers.add_parser("log", help="send APDUs from Ledger Live log file") parser_log.add_argument("filepath", help="path of the Ledger Live log file within APDUs") # args for main parser parser.add_argument("--hid", help="Use HID instead of TCP client", action="store_true") parser.add_argument("--server", help="IP server of the TCP client (default: 127.0.0.1)", default="127.0.0.1") parser.add_argument("--port", help="Port of the TCP client (default: 9999)", default=9999) parser.add_argument("--startswith", help="Only send APDUs which starts with STARTSWITH (default: None)", default=None) parser.add_argument("--version", "-v", help="Print LedgerComm package current version", default=False, action="store_true") args = parser.parse_args() if args.version: print(__version__) return 0 transport = (Transport(interface="hid", debug=True) if args.hid else Transport( interface="tcp", server=args.server, port=args.port, debug=True)) if args.command == "file": filepath: Path = Path(args.filepath) for apdu in parse_file(filepath, args.startswith): # type: str if apdu: transport.exchange_raw(re.sub(r"[^a-fA-F0-9]", "", apdu)) if args.command == "stdin": apdu: str = input() if args.startswith: apdu = apdu.replace(args.startswith, "").strip() if apdu: transport.exchange_raw(re.sub(r"[^a-fA-F0-9]", "", apdu)) if args.command == "log": # TODO: implement Ledger Live log parser raise NotImplementedError("Ledger Live log parser is not yet available!") transport.close() return 0 if __name__ == "__main__": main() python-ledgercomm/ledgercomm/cli/__init__.py0000744000175000017500000000003614550232424023452 0ustar manuelguerramanuelguerra"""ledegercomm.cli module.""" python-ledgercomm/ledgercomm/log.py0000744000175000017500000000012414550232424021723 0ustar manuelguerramanuelguerra"""ledgercomm.log module.""" import logging LOG = logging.getLogger("ledgercomm") python-ledgercomm/ledgercomm/interfaces/0000755000175000017500000000000015013676324022723 5ustar manuelguerramanuelguerrapython-ledgercomm/ledgercomm/interfaces/hid_device.py0000744000175000017500000001572514550232424025365 0ustar manuelguerramanuelguerra"""ledgercomm.interfaces.hid_device module.""" from typing import Any, List, Mapping, Optional, Tuple try: import hid except ImportError: hid = None from ledgercomm.interfaces.comm import Comm from ledgercomm.log import LOG DEFAULT_VENDOR_ID = 0x2C97 MACOS_USAGE_PAGE = 0xffa0 class HIDAPINotInstalledError(ImportError): """Custom error to be raised when hidapi is not installed.""" def __init__(self): """Init constructor of HIDAPINotInstalledError.""" super().__init__("hidapi is not installed, try: 'pip install ledgercomm[hid]'") class CannotFindDeviceError(Exception): """Custom error to be raised when the device can't be found.""" def __init__(self, vendor_id: int): """Init constructor of CannotFindDeviceError.""" super().__init__(f"Can't find Ledger device with vendor_id {hex(vendor_id)}") class HID(Comm): """HID class. Mainly used to communicate with Nano S/X through USB. Parameters ---------- vendor_id: int Vendor ID of the device. Default to Ledger Vendor ID 0x2C97. Attributes ---------- device : hid.device HID device connection. path : Optional[bytes] Path of the HID device. __opened : bool Whether the connection to the HID device is opened or not. """ def __init__(self, vendor_id: int = DEFAULT_VENDOR_ID) -> None: """Init constructor of HID.""" if hid is None: raise HIDAPINotInstalledError() self.device = hid.device() self.path: Optional[bytes] = None self.__opened: bool = False self.vendor_id: int = vendor_id def open(self) -> None: """Open connection to the HID device. Returns ------- None """ if not self.__opened: if not self.path: self.path = HID._decide_device_path(self.vendor_id) self.device.open_path(self.path) self.device.set_nonblocking(True) self.__opened = True @staticmethod def _decide_device_path(vendor_id: int = DEFAULT_VENDOR_ID) -> bytes: if hid is None: raise HIDAPINotInstalledError() devices: List[Mapping[str, Any]] = list(hid.enumerate(vendor_id, 0)) LOG.debug("hid.enumerate(), devices: %s", devices) devices = [device for device in devices if device.get("interface_number") == 0] if len(devices) == 1: # No ambiguity, pick the only device return devices[0]["path"] # On MacOS, "interface_number" is not a reliable filtering criteria, so we # also filter by "usage_page". mac_filtered_devices = [ device for device in devices if device.get("usage_page") == MACOS_USAGE_PAGE ] if len(mac_filtered_devices) != 0: # If we are on a MAC device, we keep the new filtered list devices = mac_filtered_devices if len(devices) == 1: # No ambiguity, pick the only device return devices[0]["path"] if len(devices) > 1: LOG.warning("More than one Ledger device with vendor_id %s, will pick the first one", hex(vendor_id)) # First, we sort by "path", so that the order is deterministic devices = sorted(devices, key=lambda device: device["path"]) return devices[0]["path"] raise CannotFindDeviceError(vendor_id) @staticmethod def enumerate_devices(vendor_id: int = DEFAULT_VENDOR_ID) -> List[bytes]: """Enumerate HID devices to find Nano S/X. Parameters ---------- vendor_id: int Vendor ID of the device. Default to Ledger Vendor ID 0x2C97. Returns ------- List[bytes] List of paths to HID devices which should be Nano S or Nano X. """ if hid is None: raise HIDAPINotInstalledError() devices: List[bytes] = [] for hid_device in hid.enumerate(vendor_id, 0): if (hid_device.get("interface_number") == 0 or # MacOS specific hid_device.get("usage_page") == MACOS_USAGE_PAGE): devices.append(hid_device["path"]) assert len(devices) != 0, f"Can't find Ledger device with vendor_id {hex(vendor_id)}" return devices def send(self, data: bytes) -> int: """Send `data` through HID device `self.device`. Parameters ---------- data : bytes Bytes of data to send. Returns ------- int Total length of data sent to the device. """ if not data: raise ValueError("Can't send empty data!") LOG.debug("=> %s", data.hex()) data = int.to_bytes(len(data), 2, byteorder="big") + data offset: int = 0 seq_idx: int = 0 length: int = 0 while offset < len(data): # Header: channel (0x0101), tag (0x05), sequence index header: bytes = b"\x01\x01\x05" + seq_idx.to_bytes(2, byteorder="big") data_chunk: bytes = header + data[offset:offset + 64 - len(header)] self.device.write(b"\x00" + data_chunk) length += len(data_chunk) + 1 offset += 64 - len(header) seq_idx += 1 return length def recv(self) -> Tuple[int, bytes]: """Receive data through HID device `self.device`. Blocking IO. Returns ------- Tuple[int, bytes] A pair (sw, rdata) containing the status word and response data. """ seq_idx: int = 0 self.device.set_nonblocking(False) data_chunk: bytes = bytes(self.device.read(64 + 1)) self.device.set_nonblocking(True) assert data_chunk[:2] == b"\x01\x01" assert data_chunk[2] == 5 assert data_chunk[3:5] == seq_idx.to_bytes(2, byteorder="big") data_len: int = int.from_bytes(data_chunk[5:7], byteorder="big") data: bytes = data_chunk[7:] while len(data) < data_len: read_bytes = bytes(self.device.read(64 + 1, timeout_ms=1000)) data += read_bytes[5:] sw: int = int.from_bytes(data[data_len - 2:data_len], byteorder="big") rdata: bytes = data[:data_len - 2] LOG.debug("<= %s %s", rdata.hex(), hex(sw)[2:]) return sw, rdata def exchange(self, data: bytes) -> Tuple[int, bytes]: """Exchange (send + receive) with `self.device`. Parameters ---------- data : bytes Bytes with `data` to send. Returns ------- Tuple[int, bytes] A pair (sw, rdata) containing the status word and reponse data. """ self.send(data) return self.recv() # blocking IO def close(self) -> None: """Close connection to HID device `self.device`. Returns ------- None """ if self.__opened: self.device.close() self.__opened = False python-ledgercomm/ledgercomm/interfaces/tcp_client.py0000744000175000017500000000572414550232424025424 0ustar manuelguerramanuelguerra"""ledgercomm.interfaces.tcp_client module.""" import socket from typing import Tuple from ledgercomm.interfaces.comm import Comm from ledgercomm.log import LOG class TCPClient(Comm): """TCPClient class. Mainly used to connect to the TCP server of the Speculos emulator. Parameters ---------- server : str IP address of the TCP server. port : int Port of the TCP server. Attributes ---------- server : str IP address of the TCP server. port : int Port of the TCP server. socket : socket.socket TCP socket to communicate with the server. __opened : bool Whether the TCP socket is opened or not. """ def __init__(self, server: str, port: int) -> None: """Init constructor of TCPClient.""" self.server: str = server self.port: int = port self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.__opened: bool = False def open(self) -> None: """Open connection to TCP socket with `self.server` and `self.port`. Returns ------- None """ if not self.__opened: self.socket.connect((self.server, self.port)) self.__opened = True def send(self, data: bytes) -> int: """Send `data` through TCP socket `self.socket`. Parameters ---------- data : bytes Bytes of data to send. Returns ------- int Total lenght of data sent through TCP socket. """ if not data: raise ValueError("Can't send empty data!") LOG.debug("=> %s", data.hex()) data_len: bytes = int.to_bytes(len(data), 4, byteorder="big") return self.socket.send(data_len + data) def recv(self) -> Tuple[int, bytes]: """Receive data through TCP socket `self.socket`. Blocking IO. Returns ------- Tuple[int, bytes] A pair (sw, rdata) containing the status word and response data. """ length: int = int.from_bytes(self.socket.recv(4), byteorder="big") rdata: bytes = self.socket.recv(length) sw: int = int.from_bytes(self.socket.recv(2), byteorder="big") LOG.debug("<= %s %s", rdata.hex(), hex(sw)[2:]) return sw, rdata def exchange(self, data: bytes) -> Tuple[int, bytes]: """Exchange (send + receive) with `self.socket`. Parameters ---------- data : bytes Bytes with `data` to send. Returns ------- Tuple[int, bytes] A pair (sw, rdata) containing the status word and response data. """ self.send(data) return self.recv() # blocking IO def close(self) -> None: """Close connection to TCP socket `self.socket`. Returns ------- None """ if self.__opened: self.socket.close() self.__opened = False python-ledgercomm/ledgercomm/interfaces/__init__.py0000744000175000017500000000004414550232424025025 0ustar manuelguerramanuelguerra"""ledgercomm.interfaces module.""" python-ledgercomm/ledgercomm/interfaces/comm.py0000744000175000017500000000163214550232424024225 0ustar manuelguerramanuelguerra"""ledgercomm.comm module.""" from abc import ABCMeta, abstractmethod from typing import Tuple class Comm(metaclass=ABCMeta): """Abstract class for communication interface.""" @abstractmethod def open(self) -> None: """Just open the interface.""" raise NotImplementedError @abstractmethod def send(self, data: bytes) -> int: """Allow to send raw bytes from the interface.""" raise NotImplementedError @abstractmethod def recv(self) -> Tuple[int, bytes]: """Allow to receive raw bytes from the interface.""" raise NotImplementedError @abstractmethod def exchange(self, data: bytes) -> Tuple[int, bytes]: """Allow to send and receive raw bytes from the interface.""" raise NotImplementedError @abstractmethod def close(self) -> None: """Just close the interface.""" raise NotImplementedError python-ledgercomm/ledgercomm/__init__.py0000744000175000017500000000033714550232424022707 0ustar manuelguerramanuelguerra"""ledgercomm module.""" from ledgercomm.transport import Transport try: from ledgercomm.__version__ import __version__ # noqa except ImportError: __version__ = "unknown version" # noqa __all__ = ["Transport"] python-ledgercomm/setup.cfg0000744000175000017500000000174014550232424020300 0ustar manuelguerramanuelguerra[metadata] name = ledgercomm author = Ledger author_email = hello@ledger.fr description = Library to communicate with Ledger Nano S/X and Speculos long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/LedgerHQ/ledgercomm project_urls = Bug Tracker = https://github.com/LedgerHQ/ledgercomm/issues classifiers = Development Status :: 5 - Production/Stable Environment :: Console License :: OSI Approved :: MIT License Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Operating System :: POSIX [options] packages = find: python_requires = >=3.8 zip_safe = True [options.extras_require] hid= hidapi>=0.9.0.post3 [options.entry_points] console_scripts= ledgercomm-send = ledgercomm.cli.send:main [pylint] extension-pkg-whitelist=hid disable = C0103, # invalid-name R0801, # duplicate-code R0913 # too-many-arguments [pycodestyle] max-line-length = 100 python-ledgercomm/pyproject.toml0000744000175000017500000000067214550232424021376 0ustar manuelguerramanuelguerra[build-system] requires = [ "setuptools>=45", "setuptools_scm[toml]>=6.2", "wheel" ] build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "ledgercomm/__version__.py" local_scheme = "no-local-version" [tool.mypy] ignore_missing_imports = true [tool.yapf] based_on_style = "pep8" column_limit = 100 [tool.coverage.report] show_missing = true exclude_lines = [ "@abstractmethod", "pragma: no cover" ] python-ledgercomm/requirements-opt.txt0000744000175000017500000000002414550232424022535 0ustar manuelguerramanuelguerrahidapi>=0.9.0.post3 python-ledgercomm/LICENCE0000744000175000017500000000204714550232424017445 0ustar manuelguerramanuelguerraMIT License Copyright (c) 2020 Ledger Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-ledgercomm/CHANGELOG.md0000744000175000017500000000235114550232424020267 0ustar manuelguerramanuelguerra# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.2.1] - 2023-06-14 ### Fixed - Fixed Ledger device enumeration on MAC devices ## [1.2.0] - 2023-05-29 ### Changed - package: Version is not longer hardcoded in sources, but inferred from tag then bundled into the package thanks to `setuptools_scm` ## [1.1.2] - 2022-11-21 ### Changed - Fix issue preventing APDU dumps to be displayed in console ## [1.1.1] - 2022-11-10 ### Changed - Fix issue preventing connection to multiple devices via `hid`. ## [1.1.0] - 2020-11-26 ### Added - Specific logger for logging instead of basic one ### Changed - Keyword parameter for `Transport.send()`: `payload` -> `cdata` ### Fixed - AttributeError when using CLI `ledgercomm-send` ### Removed - CLI command `ledgercomm-repl` (for now...) ## [1.0.2] - 2020-10-26 ### Changed - CLI command `ledgercomm-parser` renamed to `ledgercomm-send` ## [1.0.1] - 2020-10-23 ### Changed - Package metadata for PyPi ## [1.0.0] - 2020-10-23 ### Added - First release of ledgercomm on PyPi python-ledgercomm/requirements-dev.txt0000744000175000017500000000007714550232424022521 0ustar manuelguerramanuelguerrapycodestyle>=2.6.0 pydocstyle>=5.1.1 pylint>=2.6.0 mypy>=0.790 python-ledgercomm/.github/0000755000175000017500000000000015013676323020021 5ustar manuelguerramanuelguerrapython-ledgercomm/.github/workflows/0000755000175000017500000000000015013676323022056 5ustar manuelguerramanuelguerrapython-ledgercomm/.github/workflows/python-fast-checks.yml0000744000175000017500000000671714550232424026322 0ustar manuelguerramanuelguerraname: Python checks and deployment on: workflow_dispatch: push: tags: - '*' branches: - master pull_request: branches: - master jobs: lint: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: [3.8, 3.9, '3.10'] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python3 -m pip install pip setuptools --upgrade if [ -f requirements-dev.txt ]; then python3 -m pip install -r requirements-dev.txt; fi - name: Pycodestyle (PEP 8 - 257) run: | python3 -m pycodestyle ledgercomm/ - name: Pylint run: | python3 -m pylint --disable=W0511 --rcfile=setup.cfg ledgercomm/ # disable TODO (fixme): W0511 yapf: name: Python formatting runs-on: ubuntu-latest steps: - name: Clone uses: actions/checkout@v3 - name: Install dependencies run: pip install yapf - name: Yapf source formatting run: yapf ledgercomm --recursive -d mypy: name: Python type checking runs-on: ubuntu-latest steps: - name: Clone uses: actions/checkout@v3 - name: Install dependencies run: pip install mypy types-setuptools types-requests - name: Mypy type checking run: mypy ledgercomm bandit: name: Security checking runs-on: ubuntu-latest steps: - name: Clone uses: actions/checkout@v3 - name: Install dependencies run: pip install bandit - name: Bandit security checking run: bandit -r ledgercomm -ll deploy: name: Build the Python package and deploy if needed needs: [lint, yapf, mypy, bandit] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Install dependencies run: | python -m pip install pip --upgrade pip install build twine - name: Build Python package run: | python -m build twine check dist/* echo "TAG_VERSION=$(python -c 'from ledgercomm import __version__; print(__version__)')" >> "$GITHUB_ENV" - name: Check version against CHANGELOG if: startsWith(github.ref, 'refs/tags/') run: | CHANGELOG_VERSION=$(grep -Po '(?<=## \[)(\d+\.)+[^\]]' CHANGELOG.md | head -n 1) if [ "${{ env.TAG_VERSION }}" == "${CHANGELOG_VERSION}" ]; \ then \ exit 0; \ else \ echo "Tag '${{ env.TAG_VERSION }}' and CHANGELOG '${CHANGELOG_VERSION}' versions mismatch!"; \ exit 1; \ fi - name: Publish Python package if: success() && github.event_name == 'push' run: python -m twine upload dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PUBLIC_API_TOKEN }} TWINE_NON_INTERACTIVE: 1 - name: Publish a release on the repo if: success() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') uses: "marvinpinto/action-automatic-releases@latest" with: automatic_release_tag: "v${{ env.TAG_VERSION }}" repo_token: "${{ secrets.GITHUB_TOKEN }}" prerelease: true files: | LICENSE dist/ python-ledgercomm/.gitignore0000744000175000017500000001015214550232424020444 0ustar manuelguerramanuelguerra### Code ### .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json ### Linux ### *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### macOS ### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf .idea # Generated files .idea/**/contentModel.xml # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml # Gradle .idea/**/gradle.xml .idea/**/libraries # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. # .idea/modules.xml # .idea/*.iml # .idea/modules # *.iml # *.ipr # CMake cmake-build-*/ # Mongo Explorer plugin .idea/**/mongoSettings.xml # File-based project format *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # Editor-based Rest Client .idea/httpRequests # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser ### PyCharm Patch ### # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 # *.iml # modules.xml # .idea/misc.xml # *.ipr # Sonarlint plugin .idea/**/sonarlint/ # SonarQube Plugin .idea/**/sonarIssues.xml # Markdown Navigator plugin .idea/**/markdown-navigator.xml .idea/**/markdown-navigator/ ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST __version__.py # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # Mr Developer .mr.developer.cfg .project .pydevproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ python-ledgercomm/README.md0000744000175000017500000000602614550232424017740 0ustar manuelguerramanuelguerra# LedgerCOMM ## Overview Python library to send and receive [APDU](https://en.wikipedia.org/wiki/Smart_card_application_protocol_data_unit) through HID or TCP socket. It can be used with a Ledger Nano S/X or with the [Speculos](https://github.com/LedgerHQ/speculos) emulator. ## Install If you just want to communicate through TCP socket, there is no dependency ```bash $ pip install ledgercomm ``` otherwise, [hidapi](https://github.com/trezor/cython-hidapi) must be installed as an extra dependency like this ```bash $ pip install ledgercomm[hid] ``` ## Getting started ### Library ```python from ledgercomm import Transport # Nano S/X using HID interface transport = Transport(interface="hid", debug=True) # or Speculos through TCP socket transport = Transport(interface="tcp", server="127.0.0.1", port=9999, debug=True) # # send/recv APDUs # # send method for structured APDUs transport.send(cla=0xe0, ins=0x03, p1=0, p2=0, cdata=b"") # send b"\xe0\x03\x00\x00\x00" # or send_raw method for hexadecimal string transport.send_raw("E003000000") # send b"\xe0\x03\x00\x00\x00" # or with bytes type transport.send_raw(b"\xe0\x03\x00\x00\x00") # Waiting for a response (blocking IO) sw, response = transport.recv() # type: int, bytes # # exchange APDUs (one time send/recv) # # exchange method for structured APDUs sw, response = transport.exchange(cla=0xe0, ins=0x03, p1=0, p2=0, cdata=b"") # send b"\xe0\x03\x00\x00\x00" # or exchange_raw method for hexadecimal string sw, reponse = transport.exchange_raw("E003000000") # send b"\xe0\x03\x00\x00\x00" # or with bytes type sw, response = transport.exchange_raw(b"\xe0\x03\x00\x00\x00") ``` ### CLI #### Usage When installed, `ledgercomm` provides a CLI tool named `ledgercomm-send` ```bash $ ledgercomm-send --help usage: ledgercomm-send [-h] [--hid] [--server SERVER] [--port PORT] [--startswith STARTSWITH] {file,stdin,log} ... positional arguments: {file,stdin,log} sub-command help file send APDUs from file stdin send APDUs from stdin log send APDUs from Ledger Live log file optional arguments: -h, --help show this help message and exit --hid Use HID instead of TCP client --server SERVER IP server of the TCP client (default: 127.0.0.1) --port PORT Port of the TCP client (default: 9999) --startswith STARTSWITH Only send APDUs starting with STARTSWITH (default: None) ``` #### Example If Speculos is launched with default parameters or your Nano S/X is plugged with correct udev rules, you can send APDUs from stdin ```bash $ echo "E003000000" | ledgercomm-send stdin # Speculos $ echo "E003000000" | ledgercomm-send --hid stdin # Nano S/X ``` Or you can replay APDUs using the following text file named `apdus.txt` with some condition ```text # this line won't be send if you've the right STARTSWITH condition => E003000000 # another APDU to send => E004000000 ``` then ```bash $ ledgercomm-send --startswith "=>" file apdus.txt ```