diff --git a/README.md b/README.md index 172cc281..d56a33a3 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,31 @@ or ```shell $ python setup.py develop ``` + +## Usage with LedgerHQ hardware + +The SDK supports signatures using [app-ethereum](https://github.com/LedgerHQ/app-ethereum), +the Ethereum app for the Ledger hardware wallets. + +This has been tested successfully on Linux (amd64). +Let us know if it works for you on other operating systems. + +Using a Ledger device on Linux requires root access or the setup of udev rules. + +Unlocking the device is required before using the relevant SDK functions. + +### Debian / Ubuntu + +Install [ledger-wallets-udev](https://packages.debian.org/bookworm/ledger-wallets-udev). + +`sudo apt-get install ledger-wallets-udev` + +### On NixOS + +Configure `hardware.ledger.enable = true`. + +### Other Linux systems + +See https://github.com/LedgerHQ/udev-rules + + diff --git a/docker/python-3.10.dockerfile b/docker/python-3.10.dockerfile index 6edbd57f..3af183ca 100644 --- a/docker/python-3.10.dockerfile +++ b/docker/python-3.10.dockerfile @@ -29,7 +29,7 @@ USER root RUN chown -R user:user /opt/aleph-sdk-python RUN git config --global --add safe.directory /opt/aleph-sdk-python -RUN pip install -e .[testing,ethereum,solana,tezos] +RUN pip install -e .[testing,ethereum,solana,tezos,ledger] RUN mkdir /data RUN chown user:user /data diff --git a/docker/python-3.11.dockerfile b/docker/python-3.11.dockerfile index 25a58823..644195a7 100644 --- a/docker/python-3.11.dockerfile +++ b/docker/python-3.11.dockerfile @@ -29,7 +29,7 @@ USER root RUN chown -R user:user /opt/aleph-sdk-python RUN git config --global --add safe.directory /opt/aleph-sdk-python -RUN pip install -e .[testing,ethereum,solana,tezos] +RUN pip install -e .[testing,ethereum,solana,tezos,ledger] RUN mkdir /data RUN chown user:user /data diff --git a/docker/python-3.9.dockerfile b/docker/python-3.9.dockerfile index b1714077..ff6d3c41 100644 --- a/docker/python-3.9.dockerfile +++ b/docker/python-3.9.dockerfile @@ -29,7 +29,7 @@ USER root RUN chown -R user:user /opt/aleph-sdk-python RUN git config --global --add safe.directory /opt/aleph-sdk-python -RUN pip install -e .[testing,ethereum,solana,tezos] +RUN pip install -e .[testing,ethereum,solana,tezos,ledger] RUN mkdir /data RUN chown user:user /data diff --git a/docker/ubuntu-20.04.dockerfile b/docker/ubuntu-20.04.dockerfile index a548d506..cb0d7c7e 100644 --- a/docker/ubuntu-20.04.dockerfile +++ b/docker/ubuntu-20.04.dockerfile @@ -34,7 +34,7 @@ USER root RUN chown -R user:user /opt/aleph-sdk-python RUN git config --global --add safe.directory /opt/aleph-sdk-python -RUN pip install -e .[testing,ethereum,solana,tezos] +RUN pip install -e .[testing,ethereum,solana,tezos,ledger] RUN mkdir /data RUN chown user:user /data diff --git a/docker/ubuntu-22.04.dockerfile b/docker/ubuntu-22.04.dockerfile index 2ee0ab98..8e44e482 100644 --- a/docker/ubuntu-22.04.dockerfile +++ b/docker/ubuntu-22.04.dockerfile @@ -34,7 +34,7 @@ USER root RUN chown -R user:user /opt/aleph-sdk-python RUN git config --global --add safe.directory /opt/aleph-sdk-python -RUN pip install -e .[testing,ethereum,solana,tezos] +RUN pip install -e .[testing,ethereum,solana,tezos,ledger] RUN mkdir /data RUN chown user:user /data 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..a8ba6899 --- /dev/null +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -0,0 +1,88 @@ +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: Dongle): + """Initialize an aleph.im account instance that relies on a LedgerHQ + device and the Ethereum Ledger application for signatures. + + See the static methods `self.from_address(...)` and `self.from_path(...)` + for an easier method of instantiation. + """ + self._account = account + self._device = device + + @staticmethod + def from_address( + address: str, device: Optional[Dongle] = None + ) -> Optional[LedgerETHAccount]: + """Initialize an aleph.im account from a LedgerHQ device from + a known wallet address. + """ + 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: + """Initialize an aleph.im account from a LedgerHQ device from + a known wallet account path.""" + 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: + """Obtaining the public key is not supported by the ledgereth library + we use, and may not be supported by LedgerHQ devices at all. + """ + 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..0f798c9d --- /dev/null +++ b/tests/unit/test_wallet_ethereum.py @@ -0,0 +1,46 @@ +from dataclasses import asdict, dataclass + +import pytest + +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_ledger_eth_account(): + account: LedgerETHAccount = get_fallback_account() + + address = account.get_address() + assert address + assert type(address) is 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) + ) + + with pytest.raises(NotImplementedError): + account.get_public_key()