Skip to content

Commit 733f0dc

Browse files
authored
Trezor device authentication (#56)
* Add trezor library dependency * Resolve dependency conflict between trezorlib and check-wheel-contents * Add Trezor device authenticator from autonity-cli * Resolve pyright errors * Add environment variables for Trezor authenticator parameters * Allow using "'" character instead of "h" in derivation path * Document authentication methods
1 parent 58ee37a commit 733f0dc

File tree

8 files changed

+371
-20
lines changed

8 files changed

+371
-20
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ pip install afp-sdk
1818
See [afp.autonity.org](https://afp.autonity.org/) for the Autonomous Futures Protocol
1919
documentation, including the Python SDK reference.
2020

21+
## Authentication
22+
23+
The SDK supports 3 methods for authenticating with AutEx and signing blockchain
24+
transactions.
25+
26+
- **Private key:** Use an Autonity account's private key as a hex-string with `0x`
27+
prefix.
28+
- **Key file:** Use a [Geth / Clef](https://geth.ethereum.org/docs/fundamentals/account-management)
29+
key file.
30+
- **Trezor device:** Use a [Trezor](https://trezor.io/) hardware wallet. The derivation
31+
path of an Ethereum account needs to be specified, which can be found in the Account
32+
Settings in the Trezor Suite.
33+
2134
## Overview
2235

2336
The `afp` package consists of the following:

afp/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from . import bindings, enums, exceptions, schemas
44
from .afp import AFP
5-
from .auth import Authenticator, KeyfileAuthenticator, PrivateKeyAuthenticator
5+
from .auth import (
6+
Authenticator,
7+
KeyfileAuthenticator,
8+
PrivateKeyAuthenticator,
9+
TrezorAuthenticator,
10+
)
611
from .exceptions import AFPException
712

813
__all__ = (
@@ -15,4 +20,5 @@
1520
"Authenticator",
1621
"KeyfileAuthenticator",
1722
"PrivateKeyAuthenticator",
23+
"TrezorAuthenticator",
1824
)

afp/afp.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from .auth import Authenticator, KeyfileAuthenticator, PrivateKeyAuthenticator
1+
from .auth import (
2+
Authenticator,
3+
KeyfileAuthenticator,
4+
PrivateKeyAuthenticator,
5+
TrezorAuthenticator,
6+
)
27
from .config import Config
38
from .api.admin import Admin
49
from .api.margin_account import MarginAccount
@@ -25,9 +30,10 @@ class AFP:
2530
----------
2631
authenticator : afp.Authenticator, optional
2732
The default authenticator for signing transactions & messages. Can also be set
28-
with environment variables; use `AFP_PRIVATE_KEY` for private key
29-
authentication, `AFP_KEYFILE` and `AFP_KEYFILE_PASSWORD` for keyfile
30-
authentication.
33+
with environment variables: use `AFP_PRIVATE_KEY` for private key
34+
authentication; `AFP_KEYFILE` and `AFP_KEYFILE_PASSWORD` for keyfile
35+
authentication; `AFP_TREZOR_PATH_OR_INDEX` and `AFP_TREZOR_PASSPHRASE` for
36+
Trezor device authentication.
3137
rpc_url : str, optional
3238
The URL of an Autonity RPC provider. Can also be set with the `AFP_RPC_URL`
3339
environment variable.
@@ -217,13 +223,25 @@ def Trading(
217223

218224

219225
def _default_authenticator() -> Authenticator | None:
220-
if defaults.PRIVATE_KEY is not None and defaults.KEYFILE is not None:
226+
auth_variable_count = sum(
227+
[
228+
int(bool(defaults.PRIVATE_KEY)),
229+
int(bool(defaults.KEYFILE)),
230+
int(bool(defaults.TREZOR_PATH_OR_INDEX)),
231+
]
232+
)
233+
if auth_variable_count > 1:
221234
raise ConfigurationError(
222-
"Only one of AFP_PRIVATE_KEY and AFP_KEYFILE environment "
223-
"variables should be specified"
235+
"Only one of AFP_PRIVATE_KEY, AFP_KEYFILE and AFP_TREZOR_PATH_OR_INDEX "
236+
"environment variables should be specified"
224237
)
225-
if defaults.PRIVATE_KEY is not None:
238+
239+
if defaults.PRIVATE_KEY:
226240
return PrivateKeyAuthenticator(defaults.PRIVATE_KEY)
227-
if defaults.KEYFILE is not None:
241+
if defaults.KEYFILE:
228242
return KeyfileAuthenticator(defaults.KEYFILE, defaults.KEYFILE_PASSWORD)
243+
if defaults.TREZOR_PATH_OR_INDEX:
244+
return TrezorAuthenticator(
245+
defaults.TREZOR_PATH_OR_INDEX, defaults.TREZOR_PASSPHRASE
246+
)
229247
return None

afp/auth.py

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
1+
import atexit
12
import json
23
import os
4+
import sys
35
from typing import Protocol, cast
46

7+
import trezorlib.ethereum as trezor_eth
58
from eth_account.account import Account
69
from eth_account.datastructures import SignedTransaction
710
from eth_account.messages import encode_defunct
811
from eth_account.signers.local import LocalAccount
912
from eth_account.types import TransactionDictType
13+
from eth_account._utils.legacy_transactions import (
14+
encode_transaction,
15+
serializable_unsigned_transaction_from_dict,
16+
)
1017
from eth_typing.evm import ChecksumAddress
18+
from eth_utils.conversions import to_int
19+
from eth_utils.crypto import keccak
1120
from hexbytes import HexBytes
21+
from trezorlib.client import TrezorClient
22+
from trezorlib.client import get_default_client # type: ignore
23+
from trezorlib.ui import TrezorClientUI
24+
from trezorlib.tools import parse_path
25+
from trezorlib.transport import DeviceIsBusy
26+
from web3 import Web3
1227
from web3.constants import CHECKSUM_ADDRESSS_ZERO
1328
from web3.types import TxParams
1429

30+
from .constants import TREZOR_DEFAULT_PREFIX
31+
from .exceptions import DeviceError
32+
1533

1634
class Authenticator(Protocol):
1735
address: ChecksumAddress
@@ -69,12 +87,154 @@ class KeyfileAuthenticator(PrivateKeyAuthenticator):
6987
key_file : str
7088
The path to the keyfile.
7189
password : str
72-
The password for decrypting the keyfile.
90+
The password for decrypting the keyfile. Defaults to no password.
7391
"""
7492

75-
def __init__(self, key_file: str, password: str) -> None:
93+
def __init__(self, key_file: str, password: str = "") -> None:
7694
with open(os.path.expanduser(key_file), encoding="utf8") as f:
7795
key_data = json.load(f)
7896

7997
private_key = Account.decrypt(key_data, password=password)
8098
super().__init__(private_key.to_0x_hex())
99+
100+
101+
class TrezorAuthenticator(Authenticator):
102+
"""Authenticates with a Trezor device.
103+
104+
Parameters
105+
----------
106+
path_or_index: str or int
107+
The full derivation path of the account, e.g. `m/44'/60'/0'/0/123`; or the
108+
index of the account at the default Trezor derivation prefix for Ethereum
109+
accounts `m/44'/60'/0'/0`, e.g. `123`.
110+
passphrase: str
111+
The passphrase for the Trezor device. Defaults to no passphrase.
112+
"""
113+
114+
client: TrezorClient[TrezorClientUI]
115+
116+
def __init__(self, path_or_index: str | int, passphrase: str = ""):
117+
if isinstance(path_or_index, int) or path_or_index.isdigit():
118+
path_str = f"{TREZOR_DEFAULT_PREFIX}/{int(path_or_index)}"
119+
else:
120+
path_str = path_or_index.replace("'", "h")
121+
try:
122+
self.path = parse_path(path_str)
123+
except ValueError as exc:
124+
raise DeviceError(
125+
f"Invalid Trezor BIP32 derivation path '{path_str}'"
126+
) from exc
127+
self.client = self._get_client(passphrase)
128+
atexit.register(self.client.end_session)
129+
130+
address_str = trezor_eth.get_address( # type: ignore
131+
self.client, self.path
132+
)
133+
self.address = Web3.to_checksum_address(address_str)
134+
135+
def sign_transaction(self, params: TxParams) -> SignedTransaction:
136+
assert "chainId" in params
137+
assert "gas" in params
138+
assert "nonce" in params
139+
assert "to" in params
140+
assert "value" in params
141+
data_bytes = HexBytes(params["data"] if "data" in params else b"")
142+
143+
print("[Confirm on Trezor device]", file=sys.stderr)
144+
145+
if "gasPrice" in params and params["gasPrice"]:
146+
v_int, r_bytes, s_bytes = trezor_eth.sign_tx( # type: ignore
147+
self.client,
148+
self.path,
149+
nonce=cast(int, params["nonce"]),
150+
gas_price=cast(int, params["gasPrice"]),
151+
gas_limit=params["gas"],
152+
to=cast(str, params["to"]),
153+
value=cast(int, params["value"]),
154+
data=data_bytes,
155+
chain_id=params["chainId"],
156+
)
157+
else:
158+
assert "maxFeePerGas" in params
159+
assert "maxPriorityFeePerGas" in params
160+
v_int, r_bytes, s_bytes = trezor_eth.sign_tx_eip1559( # type: ignore
161+
self.client,
162+
self.path,
163+
nonce=cast(int, params["nonce"]),
164+
gas_limit=params["gas"],
165+
to=cast(str, params["to"]),
166+
value=cast(int, params["value"]),
167+
data=data_bytes,
168+
chain_id=params["chainId"],
169+
max_gas_fee=int(params["maxFeePerGas"]),
170+
max_priority_fee=int(params["maxPriorityFeePerGas"]),
171+
)
172+
173+
r_int = to_int(r_bytes)
174+
s_int = to_int(s_bytes)
175+
filtered_tx = dict((k, v) for (k, v) in params.items() if k not in ("from"))
176+
# In a LegacyTransaction, "type" is not a valid field. See EIP-2718.
177+
if "type" in filtered_tx and filtered_tx["type"] == "0x0":
178+
filtered_tx.pop("type")
179+
tx_unsigned = serializable_unsigned_transaction_from_dict(
180+
cast(TransactionDictType, filtered_tx)
181+
)
182+
tx_encoded = encode_transaction(tx_unsigned, vrs=(v_int, r_int, s_int))
183+
txhash = keccak(tx_encoded)
184+
return SignedTransaction(
185+
raw_transaction=HexBytes(tx_encoded),
186+
hash=HexBytes(txhash),
187+
r=r_int,
188+
s=s_int,
189+
v=v_int,
190+
)
191+
192+
def sign_message(self, message: bytes) -> HexBytes:
193+
print("[Confirm on Trezor device]", file=sys.stderr)
194+
195+
sigdata = trezor_eth.sign_message( # type: ignore
196+
self.client,
197+
self.path,
198+
message.decode("utf-8"),
199+
)
200+
return HexBytes(sigdata.signature)
201+
202+
@staticmethod
203+
def _get_client(
204+
passphrase: str,
205+
) -> TrezorClient[TrezorClientUI]:
206+
ui = _NonInteractiveTrezorUI(passphrase)
207+
try:
208+
return cast(
209+
TrezorClient[TrezorClientUI],
210+
get_default_client(ui=ui),
211+
)
212+
except DeviceIsBusy as exc:
213+
raise DeviceError("Device in use by another process") from exc
214+
except Exception as exc:
215+
raise DeviceError(
216+
"No Trezor device found; "
217+
"check device is connected, unlocked, and detected by OS"
218+
) from exc
219+
220+
221+
class _NonInteractiveTrezorUI(TrezorClientUI):
222+
"""Replacement for the default TrezorClientUI of the Trezor library.
223+
224+
Bringing up an interactive passphrase prompt is unwanted in the SDK;
225+
this implementation receives the passphrase as constructor argument.
226+
"""
227+
228+
_passphrase: str
229+
230+
def __init__(self, passphrase: str) -> None:
231+
self._passphrase = passphrase
232+
233+
def button_request(self, br: object) -> None:
234+
pass
235+
236+
def get_pin(self, code: object) -> str:
237+
raise DeviceError("PIN entry on host is not supported")
238+
239+
def get_passphrase(self, available_on_device: bool) -> str:
240+
return self._passphrase

afp/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ def _int_or_none(value: str | None) -> int | None:
4141
"Z": 12,
4242
}
4343

44+
# Trezor device constants
45+
TREZOR_DEFAULT_PREFIX = "m/44h/60h/0h/0"
46+
4447
schema_cids = SimpleNamespace(
4548
# afp-product-schemas v0.2.0
4649
ORACLE_CONFIG_V020="bafyreifcec2km7hxwq6oqzjlspni2mgipetjb7pqtaewh2efislzoctboi",
@@ -86,6 +89,8 @@ def _int_or_none(value: str | None) -> int | None:
8689
KEYFILE=os.getenv("AFP_KEYFILE", None),
8790
KEYFILE_PASSWORD=os.getenv("AFP_KEYFILE_PASSWORD", ""),
8891
PRIVATE_KEY=os.getenv("AFP_PRIVATE_KEY", None),
92+
TREZOR_PATH_OR_INDEX=os.getenv("AFP_TERZOR_PATH_OR_INDEX", None),
93+
TREZOR_PASSPHRASE=os.getenv("AFP_TREZOR_PASSPHRASE", ""),
8994
# Venue parameters
9095
EXCHANGE_URL=os.getenv("AFP_EXCHANGE_URL", _current_env.EXCHANGE_URL),
9196
# IPFS client parameters

afp/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class ExchangeError(AFPException):
2121
pass
2222

2323

24+
class DeviceError(AFPException):
25+
pass
26+
27+
2428
# Exchange error sub-types
2529

2630

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"requests>=2.32.0",
3030
"rfc8785>=0.1.4",
3131
"siwe>=4.4.0",
32+
"trezor[ethereum]>=0.13.10",
3233
"web3>=7.6.0",
3334
]
3435
classifiers = [
@@ -47,7 +48,7 @@ Changes = "https://github.com/autonity/afp-sdk/blob/master/CHANGELOG.md"
4748

4849
[dependency-groups]
4950
dev = [
50-
"check-wheel-contents>=0.6.3",
51+
"check-wheel-contents>=0.6.1,<0.6.2",
5152
"griffe2md>=1.2.1",
5253
"poethepoet>=0.33.1",
5354
"pydistcheck>=0.10.0",

0 commit comments

Comments
 (0)