Skip to content

Add support for signing messages using LedgerHQ wallet on Ethereum #51

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()