From dc182fd79ddac72656892b62fbf33146cede4be4 Mon Sep 17 00:00:00 2001 From: Alexis Grojean Date: Wed, 11 Oct 2023 10:52:52 +0200 Subject: [PATCH 1/6] Add 'dump' option to ledgerctl CLI install command. --- ledgerwallet/client.py | 67 ++++-------------------------- ledgerwallet/crypto/scp.py | 15 +++++++ ledgerwallet/ledgerctl.py | 35 +++++++++++++--- ledgerwallet/manifest.py | 4 ++ ledgerwallet/transport/__init__.py | 5 +++ ledgerwallet/transport/file.py | 41 ++++++++++++++++++ ledgerwallet/utils.py | 67 +++++++++++++++++++++++++++++- 7 files changed, 168 insertions(+), 66 deletions(-) create mode 100644 ledgerwallet/transport/file.py diff --git a/ledgerwallet/client.py b/ledgerwallet/client.py index 4106844..5c2b0f3 100644 --- a/ledgerwallet/client.py +++ b/ledgerwallet/client.py @@ -1,4 +1,3 @@ -import enum import logging import struct from typing import Union @@ -6,13 +5,10 @@ from construct import ( Bytes, Const, - FlagsEnum, GreedyRange, Hex, Int8ub, Int32ub, - Int32ul, - Optional, PascalString, Rebuild, Struct, @@ -22,49 +18,15 @@ from intelhex import IntelHex from ledgerwallet.crypto.ecc import PrivateKey -from ledgerwallet.crypto.scp import SCP +from ledgerwallet.crypto.scp import SCP, FakeSCP from ledgerwallet.hsmscript import HsmScript from ledgerwallet.hsmserver import HsmServer from ledgerwallet.ledgerserver import LedgerServer from ledgerwallet.manifest import AppManifest from ledgerwallet.proto.listApps_pb2 import AppList from ledgerwallet.simpleserver import SimpleServer -from ledgerwallet.transport import enumerate_devices -from ledgerwallet.utils import serialize - - -class LedgerIns(enum.IntEnum): - SECUINS = 0 - GET_VERSION = 1 - VALIDATE_TARGET_ID = 4 - INITIALIZE_AUTHENTICATION = 0x50 - VALIDATE_CERTIFICATE = 0x51 - GET_CERTIFICATE = 0x52 - MUTUAL_AUTHENTICATE = 0x53 - ONBOARD = 0xD0 - RUN_APP = 0xD8 - # Commands for custom endorsement - ENDORSE_SET_START = 0xC0 - ENDORSE_SET_COMMIT = 0xC2 - - -class LedgerSecureIns(enum.IntEnum): - SET_LOAD_OFFSET = 5 - LOAD = 6 - FLUSH = 7 - CRC = 8 - COMMIT = 9 - CREATE_APP = 11 - DELETE_APP = 12 - LIST_APPS = 14 - LIST_APPS_CONTINUE = 15 - GET_VERSION = 16 - GET_MEMORY_INFORMATION = 17 - SETUP_CUSTOM_CERTIFICATE = 18 - RESET_CUSTOM_CERTIFICATE = 19 - DELETE_APP_BY_HASH = 21 - MCU_BOOTLOADER = 0xB0 - +from ledgerwallet.transport import FileDevice, enumerate_devices +from ledgerwallet.utils import LedgerIns, LedgerSecureIns, VersionInfo, serialize LOAD_SEGMENT_CHUNK_HEADER_LENGTH = 3 MIN_PADDING_LENGTH = 1 @@ -93,24 +55,6 @@ class LedgerSecureIns(enum.IntEnum): ), ) -VersionInfo = Struct( - target_id=Hex(Int32ub), - se_version=PascalString(Int8ub, "utf-8"), - _flags_len=Const(b"\x04"), - flags=FlagsEnum( - Int32ul, - recovery_mode=1, - signed_mcu=2, - is_onboarded=4, - trust_issuer=8, - trust_custom_ca=16, - hsm_initialized=32, - pin_validated=128, - ), - mcu_version=PascalString(Int8ub, "utf-8"), - mcu_hash=Optional(Bytes(32)), -) - class AppInfo(object): def __init__( @@ -164,15 +108,18 @@ class NoLedgerDeviceException(Exception): class LedgerClient(object): def __init__(self, device=None, cla=0xE0, private_key=None): + self.scp = None if device is None: devices = enumerate_devices() if len(devices) == 0: raise NoLedgerDeviceException("No Ledger device has been found.") device = devices[0] + elif type(device) == FileDevice: + self.scp = FakeSCP() + self.device = device self.cla = cla self._target_id = None - self.scp = None if private_key is None: self.private_key = PrivateKey() else: diff --git a/ledgerwallet/crypto/scp.py b/ledgerwallet/crypto/scp.py index 692138a..b73f52b 100644 --- a/ledgerwallet/crypto/scp.py +++ b/ledgerwallet/crypto/scp.py @@ -112,3 +112,18 @@ def unwrap(self, data: bytes) -> bytes: raise Exception("Invalid SCP MAC") data = self._decrypt_data(encrypted_data) return iso9797_unpad(data) + + +class FakeSCP: + def __init__(self): + pass + + @staticmethod + def identity_wrap(data: bytes) -> bytes: + return data + + def wrap(self, data): + return self.identity_wrap(data) + + def unwrap(self, data): + return self.identity_wrap(data) diff --git a/ledgerwallet/ledgerctl.py b/ledgerwallet/ledgerctl.py index 1ec50eb..0478ec5 100644 --- a/ledgerwallet/ledgerctl.py +++ b/ledgerwallet/ledgerctl.py @@ -25,6 +25,7 @@ from ledgerwallet.manifest import AppManifest from ledgerwallet.manifest_json import AppManifestJson from ledgerwallet.manifest_toml import AppManifestToml +from ledgerwallet.transport import FileDevice class ManifestFormatError(Exception): @@ -87,6 +88,14 @@ def get_private_key() -> bytes: return private_key +def get_file_device(target_id, output_file): + try: + return LedgerClient(FileDevice(target_id, out=output_file)) + except NoLedgerDeviceException as exception: + click.echo(exception) + sys.exit(0) + + @click.group() @click.option("-v", "--verbose", is_flag=True, help="Display exchanged APDU.") @click.pass_context @@ -162,9 +171,15 @@ def list_apps(get_client, remote, url, key): help="Delete using application hash instead of application name", is_flag=True, ) +@click.option( + "-d", + "--dump", + help="Dump APDU installation file.", + is_flag=False, + flag_value="out.apdu", +) @click.pass_obj -def install_app(get_client, manifest: AppManifest, force): - client = get_client() +def install_app(get_client, manifest: AppManifest, force, dump): try: app_manifest: AppManifest = AppManifestToml(manifest) except TOMLDecodeError as toml_error: @@ -177,10 +192,20 @@ def install_app(get_client, manifest: AppManifest, force): raise ManifestFormatError(toml_error, json_error) try: - if force: - client.delete_app(app_manifest.app_name) - client.close() + if dump: + try: + dump_file = open(dump, "w") + except OSError: + click.echo("Unable to open file {} for dump.".format(dump)) + sys.exit(1) + click.echo("Dumping APDU installation file to {}".format(dump)) + client = get_file_device(app_manifest.target_id, dump_file) + else: client = get_client() + if force: + client.delete_app(app_manifest.app_name) + client.close() + client = get_client() client.install_app(app_manifest) except CommException as e: if e.sw == 0x6985: diff --git a/ledgerwallet/manifest.py b/ledgerwallet/manifest.py index b38dada..18b2f68 100644 --- a/ledgerwallet/manifest.py +++ b/ledgerwallet/manifest.py @@ -105,6 +105,10 @@ class AppManifest(ABC): def app_name(self) -> str: return self.dic.get("name", "") + @property + def target_id(self) -> str: + return self.dic.get("targetId", "") + @abstractmethod def data_size(self, device: str) -> int: pass diff --git a/ledgerwallet/transport/__init__.py b/ledgerwallet/transport/__init__.py index 452cdf2..a7a5b0f 100644 --- a/ledgerwallet/transport/__init__.py +++ b/ledgerwallet/transport/__init__.py @@ -1,11 +1,16 @@ from contextlib import contextmanager from .device import Device +from .file import FileDevice from .hid import HidDevice from .tcp import TcpDevice DEVICE_CLASSES = [TcpDevice, HidDevice] +__all__ = [ + "FileDevice", +] + def enumerate_devices(): devices = [] diff --git a/ledgerwallet/transport/file.py b/ledgerwallet/transport/file.py new file mode 100644 index 0000000..87e5adf --- /dev/null +++ b/ledgerwallet/transport/file.py @@ -0,0 +1,41 @@ +import sys + +from ..utils import LedgerIns, VersionInfo +from .device import Device + + +class FileDevice(Device): + def __init__(self, target_id, out=None): + if out is None: + out = sys.stdout + t_id = int(target_id, 16) + self.version_info = VersionInfo.build( + dict(target_id=t_id, se_version="0", flags=0, mcu_version="0") + ) + self.buffer = None + self.out = out + + @classmethod + def enumerate_devices(cls): + return None + + def open(self): + pass + + def write(self, data: bytes): + self.buffer = data + if not self.buffer[1] == LedgerIns.GET_VERSION: + print(data.hex(), file=self.out) + + def read(self, timeout: int = 0) -> bytes: + if self.buffer[1] == LedgerIns.GET_VERSION: + return self.version_info + b"\x90\x00" + return b"\x00\x00\x00\x02\x90\x00" + + def exchange(self, data: bytes, timeout: int = 0) -> bytes: + self.write(data) + return self.read() + + def close(self): + if self.out: + self.out.close() diff --git a/ledgerwallet/utils.py b/ledgerwallet/utils.py index d44556b..59898a0 100644 --- a/ledgerwallet/utils.py +++ b/ledgerwallet/utils.py @@ -1,5 +1,18 @@ import logging -from enum import Enum +from enum import Enum, IntEnum + +from construct import ( + Bytes, + Const, + FlagsEnum, + Hex, + Int8ub, + Int32ub, + Int32ul, + Optional, + PascalString, + Struct, +) class DeviceNames(Enum): @@ -9,6 +22,58 @@ class DeviceNames(Enum): LEDGER_BLUE = "Ledger Blue" +class LedgerIns(IntEnum): + SECUINS = 0 + GET_VERSION = 1 + VALIDATE_TARGET_ID = 4 + INITIALIZE_AUTHENTICATION = 0x50 + VALIDATE_CERTIFICATE = 0x51 + GET_CERTIFICATE = 0x52 + MUTUAL_AUTHENTICATE = 0x53 + ONBOARD = 0xD0 + RUN_APP = 0xD8 + # Commands for custom endorsement + ENDORSE_SET_START = 0xC0 + ENDORSE_SET_COMMIT = 0xC2 + + +class LedgerSecureIns(IntEnum): + SET_LOAD_OFFSET = 5 + LOAD = 6 + FLUSH = 7 + CRC = 8 + COMMIT = 9 + CREATE_APP = 11 + DELETE_APP = 12 + LIST_APPS = 14 + LIST_APPS_CONTINUE = 15 + GET_VERSION = 16 + GET_MEMORY_INFORMATION = 17 + SETUP_CUSTOM_CERTIFICATE = 18 + RESET_CUSTOM_CERTIFICATE = 19 + DELETE_APP_BY_HASH = 21 + MCU_BOOTLOADER = 0xB0 + + +VersionInfo = Struct( + target_id=Hex(Int32ub), + se_version=PascalString(Int8ub, "utf-8"), + _flags_len=Const(b"\x04"), + flags=FlagsEnum( + Int32ul, + recovery_mode=1, + signed_mcu=2, + is_onboarded=4, + trust_issuer=8, + trust_custom_ca=16, + hsm_initialized=32, + pin_validated=128, + ), + mcu_version=PascalString(Int8ub, "utf-8"), + mcu_hash=Optional(Bytes(32)), +) + + def enable_apdu_log(): logger = logging.getLogger("ledgerwallet") logger.setLevel(logging.DEBUG) From ad8dd1fc1fdada0f48e823b1de7e7d1848f30982 Mon Sep 17 00:00:00 2001 From: Alexis Grojean Date: Thu, 12 Oct 2023 17:52:17 +0200 Subject: [PATCH 2/6] Add 'dump' option to ledgerctl CLI delete command. --- ledgerwallet/ledgerctl.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/ledgerwallet/ledgerctl.py b/ledgerwallet/ledgerctl.py index 0478ec5..adbc9a9 100644 --- a/ledgerwallet/ledgerctl.py +++ b/ledgerwallet/ledgerctl.py @@ -88,7 +88,7 @@ def get_private_key() -> bytes: return private_key -def get_file_device(target_id, output_file): +def get_file_device(output_file, target_id="0x33000004"): try: return LedgerClient(FileDevice(target_id, out=output_file)) except NoLedgerDeviceException as exception: @@ -199,7 +199,7 @@ def install_app(get_client, manifest: AppManifest, force, dump): click.echo("Unable to open file {} for dump.".format(dump)) sys.exit(1) click.echo("Dumping APDU installation file to {}".format(dump)) - client = get_file_device(app_manifest.target_id, dump_file) + client = get_file_device(dump_file, app_manifest.target_id) else: client = get_client() if force: @@ -235,14 +235,32 @@ def install_remote_app(get_client, app_path, key_path, url, key): help="Delete using application hash instead of application name", is_flag=True, ) +@click.option( + "-d", + "--dump", + help="Dump APDU delete command file.", + is_flag=False, + flag_value="out_delete.apdu", +) @click.pass_obj -def delete_app(get_client, app, by_hash): +def delete_app(get_client, app, by_hash, dump): if by_hash: data = bytes.fromhex(app) else: data = app + + if dump: + try: + dump_file = open(dump, "w") + except OSError: + click.echo("Unable to open file {} for dump.".format(dump)) + sys.exit(1) + click.echo("Dumping APDU delete command file to {}".format(dump)) + client = get_file_device(dump_file) + else: + client = get_client() try: - get_client().delete_app(data) + client.delete_app(data) except CommException as e: if e.sw == 0x6985: click.echo("Operation has been canceled by the user.") From 5182c6e0a6f2dd2d5cdadd967c7d6e8e6b4a141a Mon Sep 17 00:00:00 2001 From: Alexis Grojean Date: Wed, 6 Dec 2023 10:49:09 +0100 Subject: [PATCH 3/6] PR review. --- ledgerwallet/ledgerctl.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/ledgerwallet/ledgerctl.py b/ledgerwallet/ledgerctl.py index adbc9a9..cec5a55 100644 --- a/ledgerwallet/ledgerctl.py +++ b/ledgerwallet/ledgerctl.py @@ -172,14 +172,14 @@ def list_apps(get_client, remote, url, key): is_flag=True, ) @click.option( - "-d", - "--dump", - help="Dump APDU installation file.", + "-o", + "--offline", + help="Dump APDU installation file, do not attempt to connect to a physical device.", is_flag=False, flag_value="out.apdu", ) @click.pass_obj -def install_app(get_client, manifest: AppManifest, force, dump): +def install_app(get_client, manifest: AppManifest, force, offline): try: app_manifest: AppManifest = AppManifestToml(manifest) except TOMLDecodeError as toml_error: @@ -192,13 +192,13 @@ def install_app(get_client, manifest: AppManifest, force, dump): raise ManifestFormatError(toml_error, json_error) try: - if dump: + if offline: try: - dump_file = open(dump, "w") + dump_file = open(offline, "w") except OSError: - click.echo("Unable to open file {} for dump.".format(dump)) + click.echo("Unable to open file {} for dump.".format(offline)) sys.exit(1) - click.echo("Dumping APDU installation file to {}".format(dump)) + click.echo("Dumping APDU installation file to {}".format(offline)) client = get_file_device(dump_file, app_manifest.target_id) else: client = get_client() @@ -236,26 +236,28 @@ def install_remote_app(get_client, app_path, key_path, url, key): is_flag=True, ) @click.option( - "-d", - "--dump", - help="Dump APDU delete command file.", + "-o", + "--offline", + help=( + "Dump APDU delete command file, do not attempt to connect to a physical device." + ), is_flag=False, flag_value="out_delete.apdu", ) @click.pass_obj -def delete_app(get_client, app, by_hash, dump): +def delete_app(get_client, app, by_hash, offline): if by_hash: data = bytes.fromhex(app) else: data = app - if dump: + if offline: try: - dump_file = open(dump, "w") + dump_file = open(offline, "w") except OSError: - click.echo("Unable to open file {} for dump.".format(dump)) + click.echo("Unable to open file {} for dump.".format(offline)) sys.exit(1) - click.echo("Dumping APDU delete command file to {}".format(dump)) + click.echo("Dumping APDU delete command file to {}".format(offline)) client = get_file_device(dump_file) else: client = get_client() From bf08f747336f63bdf66050dfdc8658c47807b8af Mon Sep 17 00:00:00 2001 From: Alexis Grojean Date: Wed, 6 Dec 2023 10:55:36 +0100 Subject: [PATCH 4/6] Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b989a..e9a0219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## [0.4.0] - 2023-12-06 + +### Add + +- offline mode : Add an option to allow dumping the APDU installation / delete file instead of trying to send it to a device. + ## [0.3.0] - 2023-05-29 ### Changed From 6081cd99243a5e87ccd9635e5f4187cf07be9744 Mon Sep 17 00:00:00 2001 From: Alexis Grojean Date: Wed, 6 Dec 2023 14:04:59 +0100 Subject: [PATCH 5/6] Fix help field for 'force' option of the 'install' command. --- ledgerwallet/ledgerctl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ledgerwallet/ledgerctl.py b/ledgerwallet/ledgerctl.py index cec5a55..a054784 100644 --- a/ledgerwallet/ledgerctl.py +++ b/ledgerwallet/ledgerctl.py @@ -168,7 +168,7 @@ def list_apps(get_client, remote, url, key): @click.option( "-f", "--force", - help="Delete using application hash instead of application name", + help="Delete the app with the same name before loading the provided one.", is_flag=True, ) @click.option( From 33d6c6d72b925b28aa88b8219611065560c1c9ad Mon Sep 17 00:00:00 2001 From: Alexis Grojean Date: Wed, 6 Dec 2023 14:05:47 +0100 Subject: [PATCH 6/6] Allow 'force' option with 'offline' option. --- ledgerwallet/ledgerctl.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ledgerwallet/ledgerctl.py b/ledgerwallet/ledgerctl.py index a054784..4d9bc88 100644 --- a/ledgerwallet/ledgerctl.py +++ b/ledgerwallet/ledgerctl.py @@ -200,6 +200,8 @@ def install_app(get_client, manifest: AppManifest, force, offline): sys.exit(1) click.echo("Dumping APDU installation file to {}".format(offline)) client = get_file_device(dump_file, app_manifest.target_id) + if force: + client.delete_app(app_manifest.app_name) else: client = get_client() if force: