Skip to content

Commit

Permalink
Add support for signing messages using LedgerHQ wallet on Ethereum (#51)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hoh authored Aug 24, 2023
1 parent e9e434f commit 6705f95
Show file tree
Hide file tree
Showing 11 changed files with 175 additions and 6 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


2 changes: 1 addition & 1 deletion docker/python-3.10.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker/python-3.11.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker/python-3.9.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker/ubuntu-20.04.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker/ubuntu-22.04.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ solana =
tezos =
pynacl
aleph-pytezos==0.1.1
ledger =
ledgereth==0.9.0
docs =
sphinxcontrib-plantuml

Expand All @@ -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

Expand Down
Empty file.
3 changes: 3 additions & 0 deletions src/aleph/sdk/wallets/ledger/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .ethereum import LedgerETHAccount

__all__ = ["LedgerETHAccount"]
88 changes: 88 additions & 0 deletions src/aleph/sdk/wallets/ledger/ethereum.py
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 46 additions & 0 deletions tests/unit/test_wallet_ethereum.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 6705f95

Please sign in to comment.