Skip to content

Commit 6705f95

Browse files
authored
Add support for signing messages using LedgerHQ wallet on Ethereum (#51)
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.
1 parent e9e434f commit 6705f95

11 files changed

+175
-6
lines changed

README.md

+28
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,31 @@ or
4343
```shell
4444
$ python setup.py develop
4545
```
46+
47+
## Usage with LedgerHQ hardware
48+
49+
The SDK supports signatures using [app-ethereum](https://github.com/LedgerHQ/app-ethereum),
50+
the Ethereum app for the Ledger hardware wallets.
51+
52+
This has been tested successfully on Linux (amd64).
53+
Let us know if it works for you on other operating systems.
54+
55+
Using a Ledger device on Linux requires root access or the setup of udev rules.
56+
57+
Unlocking the device is required before using the relevant SDK functions.
58+
59+
### Debian / Ubuntu
60+
61+
Install [ledger-wallets-udev](https://packages.debian.org/bookworm/ledger-wallets-udev).
62+
63+
`sudo apt-get install ledger-wallets-udev`
64+
65+
### On NixOS
66+
67+
Configure `hardware.ledger.enable = true`.
68+
69+
### Other Linux systems
70+
71+
See https://github.com/LedgerHQ/udev-rules
72+
73+

docker/python-3.10.dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ USER root
2929
RUN chown -R user:user /opt/aleph-sdk-python
3030

3131
RUN git config --global --add safe.directory /opt/aleph-sdk-python
32-
RUN pip install -e .[testing,ethereum,solana,tezos]
32+
RUN pip install -e .[testing,ethereum,solana,tezos,ledger]
3333

3434
RUN mkdir /data
3535
RUN chown user:user /data

docker/python-3.11.dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ USER root
2929
RUN chown -R user:user /opt/aleph-sdk-python
3030

3131
RUN git config --global --add safe.directory /opt/aleph-sdk-python
32-
RUN pip install -e .[testing,ethereum,solana,tezos]
32+
RUN pip install -e .[testing,ethereum,solana,tezos,ledger]
3333

3434
RUN mkdir /data
3535
RUN chown user:user /data

docker/python-3.9.dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ USER root
2929
RUN chown -R user:user /opt/aleph-sdk-python
3030

3131
RUN git config --global --add safe.directory /opt/aleph-sdk-python
32-
RUN pip install -e .[testing,ethereum,solana,tezos]
32+
RUN pip install -e .[testing,ethereum,solana,tezos,ledger]
3333

3434
RUN mkdir /data
3535
RUN chown user:user /data

docker/ubuntu-20.04.dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ USER root
3434
RUN chown -R user:user /opt/aleph-sdk-python
3535

3636
RUN git config --global --add safe.directory /opt/aleph-sdk-python
37-
RUN pip install -e .[testing,ethereum,solana,tezos]
37+
RUN pip install -e .[testing,ethereum,solana,tezos,ledger]
3838

3939
RUN mkdir /data
4040
RUN chown user:user /data

docker/ubuntu-22.04.dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ USER root
3434
RUN chown -R user:user /opt/aleph-sdk-python
3535

3636
RUN git config --global --add safe.directory /opt/aleph-sdk-python
37-
RUN pip install -e .[testing,ethereum,solana,tezos]
37+
RUN pip install -e .[testing,ethereum,solana,tezos,ledger]
3838

3939
RUN mkdir /data
4040
RUN chown user:user /data

setup.cfg

+5-1
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ solana =
9999
tezos =
100100
pynacl
101101
aleph-pytezos==0.1.1
102+
ledger =
103+
ledgereth==0.9.0
102104
docs =
103105
sphinxcontrib-plantuml
104106

@@ -124,12 +126,14 @@ extras = True
124126
addopts =
125127
--cov aleph.sdk --cov-report term-missing
126128
--verbose
129+
-m "not ledger_hardware"
127130
norecursedirs =
128131
dist
129132
build
130133
.tox
131134
testpaths = tests
132-
135+
markers =
136+
"ledger_hardware: marks tests as requiring ledger hardware"
133137
[aliases]
134138
dists = bdist_wheel
135139

src/aleph/sdk/wallets/__init__.py

Whitespace-only changes.
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .ethereum import LedgerETHAccount
2+
3+
__all__ = ["LedgerETHAccount"]
+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from __future__ import annotations
2+
3+
from typing import Dict, List, Optional
4+
5+
from eth_typing import HexStr
6+
from ledgerblue.Dongle import Dongle
7+
from ledgereth import find_account, get_account_by_path, get_accounts
8+
from ledgereth.comms import init_dongle
9+
from ledgereth.messages import sign_message
10+
from ledgereth.objects import LedgerAccount, SignedMessage
11+
12+
from ...chains.common import BaseAccount, get_verification_buffer
13+
14+
15+
class LedgerETHAccount(BaseAccount):
16+
"""Account using the Ethereum app on Ledger hardware wallets."""
17+
18+
CHAIN = "ETH"
19+
CURVE = "secp256k1"
20+
_account: LedgerAccount
21+
_device: Dongle
22+
23+
def __init__(self, account: LedgerAccount, device: Dongle):
24+
"""Initialize an aleph.im account instance that relies on a LedgerHQ
25+
device and the Ethereum Ledger application for signatures.
26+
27+
See the static methods `self.from_address(...)` and `self.from_path(...)`
28+
for an easier method of instantiation.
29+
"""
30+
self._account = account
31+
self._device = device
32+
33+
@staticmethod
34+
def from_address(
35+
address: str, device: Optional[Dongle] = None
36+
) -> Optional[LedgerETHAccount]:
37+
"""Initialize an aleph.im account from a LedgerHQ device from
38+
a known wallet address.
39+
"""
40+
device = device or init_dongle()
41+
account = find_account(address=address, dongle=device, count=5)
42+
return LedgerETHAccount(
43+
account=account,
44+
device=device,
45+
)
46+
47+
@staticmethod
48+
def from_path(path: str, device: Optional[Dongle] = None) -> LedgerETHAccount:
49+
"""Initialize an aleph.im account from a LedgerHQ device from
50+
a known wallet account path."""
51+
device = device or init_dongle()
52+
account = get_account_by_path(path_string=path, dongle=device)
53+
return LedgerETHAccount(
54+
account=account,
55+
device=device,
56+
)
57+
58+
async def sign_message(self, message: Dict) -> Dict:
59+
"""Sign a message inplace."""
60+
message: Dict = self._setup_sender(message)
61+
62+
# TODO: Check why the code without a wallet uses `encode_defunct`.
63+
msghash: bytes = get_verification_buffer(message)
64+
sig: SignedMessage = sign_message(msghash, dongle=self._device)
65+
66+
signature: HexStr = sig.signature
67+
68+
message["signature"] = signature
69+
return message
70+
71+
def get_address(self) -> str:
72+
return self._account.address
73+
74+
def get_public_key(self) -> str:
75+
"""Obtaining the public key is not supported by the ledgereth library
76+
we use, and may not be supported by LedgerHQ devices at all.
77+
"""
78+
raise NotImplementedError()
79+
80+
81+
def get_fallback_account() -> LedgerETHAccount:
82+
"""Returns the first account available on the device first device found."""
83+
device: Dongle = init_dongle()
84+
accounts: List[LedgerAccount] = get_accounts(dongle=device, count=1)
85+
if not accounts:
86+
raise ValueError("No account found on device")
87+
account = accounts[0]
88+
return LedgerETHAccount(account=account, device=device)

tests/unit/test_wallet_ethereum.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from dataclasses import asdict, dataclass
2+
3+
import pytest
4+
5+
from aleph.sdk.chains.common import get_verification_buffer
6+
from aleph.sdk.chains.ethereum import verify_signature
7+
from aleph.sdk.exceptions import BadSignatureError
8+
from aleph.sdk.wallets.ledger.ethereum import LedgerETHAccount, get_fallback_account
9+
10+
11+
@dataclass
12+
class Message:
13+
chain: str
14+
sender: str
15+
type: str
16+
item_hash: str
17+
18+
19+
@pytest.mark.ledger_hardware
20+
@pytest.mark.asyncio
21+
async def test_ledger_eth_account():
22+
account: LedgerETHAccount = get_fallback_account()
23+
24+
address = account.get_address()
25+
assert address
26+
assert type(address) is str
27+
assert len(address) == 42
28+
29+
message = Message("ETH", account.get_address(), "SomeType", "ItemHash")
30+
signed = await account.sign_message(asdict(message))
31+
assert signed["signature"]
32+
assert len(signed["signature"]) == 132
33+
34+
verify_signature(
35+
signed["signature"], signed["sender"], get_verification_buffer(signed)
36+
)
37+
38+
with pytest.raises(BadSignatureError):
39+
signed["signature"] = signed["signature"][:-8] + "cafecafe"
40+
41+
verify_signature(
42+
signed["signature"], signed["sender"], get_verification_buffer(signed)
43+
)
44+
45+
with pytest.raises(NotImplementedError):
46+
account.get_public_key()

0 commit comments

Comments
 (0)