From e6fb32ff58b4787fe791385aee4280a0a3ced06b Mon Sep 17 00:00:00 2001 From: Hugo Herter Date: Sat, 19 Aug 2023 16:12:01 +0200 Subject: [PATCH] Feature: Ledger wallets could not sign using ethereum Users could not use Ledger hardware wallets to sign messages using an Ethereum key. The command / response scheme used by Ledger to address the device is similar to the ISO/IEC 7816-4 smartcard protocol. Each command / response packet is called an APDU (application protocol data unit). Each APDU is specific to Ledger application, that adds the support for a chain or functionality. Solution: Use the Ledgereth library to send the Ethereum APDUs via ledgerblue. --- setup.cfg | 6 +- src/aleph/sdk/wallets/__init__.py | 0 src/aleph/sdk/wallets/ledger/__init__.py | 3 + src/aleph/sdk/wallets/ledger/ethereum.py | 74 ++++++++++++++++++++++++ tests/unit/test_wallet_ethereum.py | 50 ++++++++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/aleph/sdk/wallets/__init__.py create mode 100644 src/aleph/sdk/wallets/ledger/__init__.py create mode 100644 src/aleph/sdk/wallets/ledger/ethereum.py create mode 100644 tests/unit/test_wallet_ethereum.py diff --git a/setup.cfg b/setup.cfg index 85dac9bb..48eb7f9b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -99,6 +99,8 @@ solana = tezos = pynacl aleph-pytezos==0.1.1 +ledger = + ledgereth==0.9.0 docs = sphinxcontrib-plantuml @@ -124,12 +126,14 @@ extras = True addopts = --cov aleph.sdk --cov-report term-missing --verbose + -m "not ledger_hardware" norecursedirs = dist build .tox testpaths = tests - +markers = + "ledger_hardware: marks tests as requiring ledger hardware" [aliases] dists = bdist_wheel diff --git a/src/aleph/sdk/wallets/__init__.py b/src/aleph/sdk/wallets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aleph/sdk/wallets/ledger/__init__.py b/src/aleph/sdk/wallets/ledger/__init__.py new file mode 100644 index 00000000..dca6aa29 --- /dev/null +++ b/src/aleph/sdk/wallets/ledger/__init__.py @@ -0,0 +1,3 @@ +from .ethereum import LedgerETHAccount + +__all__ = ["LedgerETHAccount"] diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py new file mode 100644 index 00000000..cb372ea5 --- /dev/null +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from eth_typing import HexStr +from ledgerblue.Dongle import Dongle +from ledgereth import find_account, get_account_by_path, get_accounts +from ledgereth.comms import init_dongle +from ledgereth.messages import sign_message +from ledgereth.objects import LedgerAccount, SignedMessage + +from ...chains.common import BaseAccount, get_verification_buffer + + +class LedgerETHAccount(BaseAccount): + """Account using the Ethereum app on Ledger hardware wallets.""" + + CHAIN = "ETH" + CURVE = "secp256k1" + _account: LedgerAccount + _device: Dongle + + def __init__(self, account: LedgerAccount, device: Optional[Dongle]): + self._account = account + self._device = device or init_dongle() + + @staticmethod + def from_address( + address: str, device: Optional[Dongle] = None + ) -> Optional[LedgerETHAccount]: + device = device or init_dongle() + account = find_account(address=address, dongle=device, count=5) + return LedgerETHAccount( + account=account, + device=device, + ) + + @staticmethod + def from_path(path: str, device: Optional[Dongle] = None) -> LedgerETHAccount: + device = device or init_dongle() + account = get_account_by_path(path_string=path, dongle=device) + return LedgerETHAccount( + account=account, + device=device, + ) + + async def sign_message(self, message: Dict) -> Dict: + """Sign a message inplace.""" + message: Dict = self._setup_sender(message) + + # TODO: Check why the code without a wallet uses `encode_defunct`. + msghash: bytes = get_verification_buffer(message) + sig: SignedMessage = sign_message(msghash, dongle=self._device) + + signature: HexStr = sig.signature + + message["signature"] = signature + return message + + def get_address(self) -> str: + return self._account.address + + def get_public_key(self) -> str: + raise NotImplementedError() + + +def get_fallback_account() -> LedgerETHAccount: + """Returns the first account available on the device first device found.""" + device: Dongle = init_dongle() + accounts: List[LedgerAccount] = get_accounts(dongle=device, count=1) + if not accounts: + raise ValueError("No account found on device") + account = accounts[0] + return LedgerETHAccount(account=account, device=device) diff --git a/tests/unit/test_wallet_ethereum.py b/tests/unit/test_wallet_ethereum.py new file mode 100644 index 00000000..c7412b9c --- /dev/null +++ b/tests/unit/test_wallet_ethereum.py @@ -0,0 +1,50 @@ +from dataclasses import asdict, dataclass +from pathlib import Path +from tempfile import NamedTemporaryFile + +import pytest +from ledgereth.comms import init_dongle + +from aleph.sdk.chains.common import get_verification_buffer +from aleph.sdk.chains.ethereum import verify_signature +from aleph.sdk.exceptions import BadSignatureError +from aleph.sdk.wallets.ledger.ethereum import LedgerETHAccount, get_fallback_account + + +@dataclass +class Message: + chain: str + sender: str + type: str + item_hash: str + + +@pytest.mark.ledger_hardware +@pytest.mark.asyncio +async def test_LedgerETHAccount(): + account: LedgerETHAccount = get_fallback_account() + + address = account.get_address() + assert address + assert type(address) == str + assert len(address) == 42 + + message = Message("ETH", account.get_address(), "SomeType", "ItemHash") + signed = await account.sign_message(asdict(message)) + assert signed["signature"] + assert len(signed["signature"]) == 132 + + verify_signature( + signed["signature"], signed["sender"], get_verification_buffer(signed) + ) + + with pytest.raises(BadSignatureError): + signed["signature"] = signed["signature"][:-8] + "cafecafe" + + verify_signature( + signed["signature"], signed["sender"], get_verification_buffer(signed) + ) + + # Obtaining the public key is not supported (yet ?) on hardware wallets. + with pytest.raises(NotImplementedError): + account.get_public_key()