Skip to content

Commit b7ddead

Browse files
Support Ledger signer (#1402)
1 parent 5126491 commit b7ddead

File tree

12 files changed

+1176
-8
lines changed

12 files changed

+1176
-8
lines changed

.github/workflows/checks.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ jobs:
161161
matrix:
162162
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
163163
env:
164+
LEDGER_PROXY_ADDRESS: 127.0.0.1
165+
LEDGER_PROXY_PORT: 9999
164166
SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }}
165167
steps:
166168
- uses: actions/checkout@v4
@@ -199,6 +201,48 @@ jobs:
199201
- name: Install devnet
200202
run: ./starknet_py/tests/install_devnet.sh
201203

204+
# ====================== SETUP LEDGER SPECULOS ====================== #
205+
206+
- name: Pull speculos image
207+
run: docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools
208+
209+
210+
- name: Clone LedgerHQ Starknet app repository
211+
run: git clone https://github.com/LedgerHQ/app-starknet.git
212+
213+
- name: Build the app inside Docker container
214+
uses: addnab/docker-run-action@v3
215+
with:
216+
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools
217+
options: --rm -v ${{ github.workspace }}:/apps
218+
run: |
219+
cd /apps/app-starknet
220+
cargo clean
221+
cargo ledger build nanox
222+
223+
- name: Start Speculos emulator container
224+
uses: addnab/docker-run-action@v3
225+
with:
226+
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools
227+
options: --rm -d --name speculos-emulator -v ${{ github.workspace }}:/apps --publish 5000:5000 --publish 9999:9999
228+
run: |
229+
speculos \
230+
-m nanox \
231+
--apdu-port 9999 \
232+
--api-port 5000 \
233+
--display headless \
234+
/apps/app-starknet/target/nanox/release/starknet
235+
236+
- name: Wait for Speculos to start
237+
run: sleep 5
238+
239+
- name: Update automation rules
240+
working-directory: starknet_py/tests/unit/signer
241+
run: |
242+
curl -X POST http://127.0.0.1:5000/automation \
243+
-H "Content-Type: application/json" \
244+
-d @speculos_automation.json
245+
202246
# ====================== RUN TESTS ====================== #
203247

204248
- name: Check circular imports

docs/api/signer.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,12 @@ KeyPair
3232
:undoc-members:
3333
:member-order: groupwise
3434

35+
------------
36+
LedgerSigner
37+
------------
38+
39+
.. py:module:: starknet_py.net.signer.ledger_signer
40+
41+
.. autoclass:: LedgerSigner
42+
:members:
43+
:member-order: groupwise

docs/guide/signing.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ signing algorithm, it is possible to create ``Account`` with custom
1212
:language: python
1313
:dedent: 4
1414

15+
Signing with Ledger
16+
-------------------
17+
:ref:`LedgerSigner` allows you to sign transactions using a Ledger device. The device must be unlocked and Starknet app needs to be open.
18+
19+
.. codesnippet:: ../../starknet_py/tests/unit/signer/test_ledger_signer.py
20+
:language: python
21+
:dedent: 4
22+
23+
Deploying account and transferring STRK
24+
---------------------------------------
25+
.. codesnippet:: ../../starknet_py/tests/unit/signer/test_ledger_signer.py
26+
:language: python
27+
:dedent: 4
28+
:start-after: docs-deploy-account-and-transfer: start
29+
:end-before: docs-deploy-account-and-transfer: end
1530

1631
Signing off-chain messages
1732
-------------------------------

poetry.lock

Lines changed: 691 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ furo = { version = "^2024.5.6", optional = true }
2929
pycryptodome = "^3.17"
3030
crypto-cpp-py = "1.4.4"
3131
eth-keyfile = "^0.8.1"
32+
ledgerwallet = "^0.5.0"
33+
bip-utils = "^2.9.3"
3234

3335
[tool.poetry.extras]
3436
docs = ["sphinx", "enum-tools", "furo"]

starknet_py/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,11 @@
3737
QUERY_VERSION_BASE = 2**128
3838

3939
ROOT_PATH = Path(__file__).parent
40+
41+
# Ledger constants
42+
STARKNET_CLA = 0x5A
43+
EIP_2645_PURPOSE = 0x80000A55
44+
EIP_2645_PATH_LENGTH = 6
45+
PUBLIC_KEY_RESPONSE_LENGTH = 65
46+
SIGNATURE_RESPONSE_LENGTH = 65
47+
VERSION_RESPONSE_LENGTH = 3
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from typing import List
2+
3+
from bip_utils import Bip32KeyIndex, Bip32Path, Bip32Utils
4+
from ledgerwallet.client import LedgerClient
5+
6+
from starknet_py.constants import (
7+
EIP_2645_PATH_LENGTH,
8+
EIP_2645_PURPOSE,
9+
PUBLIC_KEY_RESPONSE_LENGTH,
10+
SIGNATURE_RESPONSE_LENGTH,
11+
STARKNET_CLA,
12+
VERSION_RESPONSE_LENGTH,
13+
)
14+
from starknet_py.net.models import AccountTransaction
15+
from starknet_py.net.models.chains import ChainId
16+
from starknet_py.net.signer import BaseSigner
17+
from starknet_py.utils.typed_data import TypedData
18+
19+
20+
class LedgerStarknetApp:
21+
def __init__(self):
22+
self.client: LedgerClient = LedgerClient(cla=STARKNET_CLA)
23+
24+
@property
25+
def version(self) -> str:
26+
"""
27+
Get the Ledger app version.
28+
29+
:return: Version string.
30+
"""
31+
response = self.client.apdu_exchange(ins=0)
32+
if len(response) != VERSION_RESPONSE_LENGTH:
33+
raise ValueError(
34+
f"Unexpected response length (expected: {VERSION_RESPONSE_LENGTH}, actual: {len(response)}"
35+
)
36+
major, minor, patch = list(response)
37+
return f"{major}.{minor}.{patch}"
38+
39+
def get_public_key(
40+
self, derivation_path: Bip32Path, device_confirmation: bool = False
41+
) -> int:
42+
"""
43+
Get public key for the given derivation path.
44+
45+
:param derivation_path: Derivation path of the account.
46+
:param device_confirmation: Whether to display confirmation on the device for extra security.
47+
:return: Public key.
48+
"""
49+
50+
data = _derivation_path_to_bytes(derivation_path)
51+
response = self.client.apdu_exchange(
52+
ins=1,
53+
data=data,
54+
p1=int(device_confirmation),
55+
p2=0,
56+
)
57+
58+
if len(response) != PUBLIC_KEY_RESPONSE_LENGTH:
59+
raise ValueError(
60+
f"Unexpected response length (expected: {PUBLIC_KEY_RESPONSE_LENGTH}, actual: {len(response)}"
61+
)
62+
63+
public_key = int.from_bytes(response[1:33], byteorder="big")
64+
return public_key
65+
66+
def sign_hash(self, hash_val: int) -> List[int]:
67+
"""
68+
Request a signature for a raw hash with the given derivation path.
69+
Currently, the Ledger app only supports blind signing raw hashes.
70+
71+
:param hash_val: Hash to sign.
72+
:return: Signature as a list of two integers.
73+
"""
74+
75+
# for some reason the Ledger app expects the data to be left shifted by 4 bits
76+
shifted_int = hash_val << 4
77+
shifted_bytes = shifted_int.to_bytes(32, byteorder="big")
78+
79+
response = self.client.apdu_exchange(
80+
ins=0x02,
81+
data=shifted_bytes,
82+
p1=0x01,
83+
p2=0x00,
84+
)
85+
86+
if (
87+
len(response) != SIGNATURE_RESPONSE_LENGTH + 1
88+
or response[0] != SIGNATURE_RESPONSE_LENGTH
89+
):
90+
raise ValueError(
91+
f"Unexpected response length (expected: {SIGNATURE_RESPONSE_LENGTH}, actual: {len(response)}"
92+
)
93+
94+
r, s = int.from_bytes(response[1:33], byteorder="big"), int.from_bytes(
95+
response[33:65], byteorder="big"
96+
)
97+
return [r, s]
98+
99+
100+
class LedgerSigner(BaseSigner):
101+
def __init__(self, derivation_path_str: str, chain_id: ChainId):
102+
"""
103+
:param derivation_path_str: Derivation path string of the account.
104+
:param chain_id: ChainId of the chain.
105+
"""
106+
107+
self.app: LedgerStarknetApp = LedgerStarknetApp()
108+
self.derivation_path: Bip32Path = _parse_derivation_path_str(
109+
derivation_path_str
110+
)
111+
self.chain_id: ChainId = chain_id
112+
113+
@property
114+
def public_key(self) -> int:
115+
return self.app.get_public_key(derivation_path=self.derivation_path)
116+
117+
def sign_transaction(self, transaction: AccountTransaction) -> List[int]:
118+
tx_hash = transaction.calculate_hash(self.chain_id)
119+
return self.app.sign_hash(hash_val=tx_hash)
120+
121+
def sign_message(self, typed_data: TypedData, account_address: int) -> List[int]:
122+
msg_hash = typed_data.message_hash(account_address)
123+
return self.app.sign_hash(hash_val=msg_hash)
124+
125+
126+
def _parse_derivation_path_str(derivation_path_str: str) -> Bip32Path:
127+
"""
128+
Parse a derivation path string to a Bip32Path object.
129+
130+
:param derivation_path_str: Derivation path string.
131+
:return: Bip32Path object.
132+
"""
133+
if not derivation_path_str:
134+
raise ValueError("Empty derivation path")
135+
136+
path_parts = derivation_path_str.lstrip("m/").split("/")
137+
path_elements = [
138+
Bip32KeyIndex(
139+
Bip32Utils.HardenIndex(int(part[:-1])) if part.endswith("'") else int(part)
140+
)
141+
for part in path_parts
142+
]
143+
144+
if len(path_elements) != EIP_2645_PATH_LENGTH:
145+
raise ValueError(f"Derivation path is not {EIP_2645_PATH_LENGTH}-level long")
146+
if path_elements[0] != EIP_2645_PURPOSE:
147+
raise ValueError("Derivation path is not prefixed with m/2645.")
148+
149+
return Bip32Path(path_elements)
150+
151+
152+
def _derivation_path_to_bytes(derivation_path: Bip32Path) -> bytes:
153+
"""
154+
Convert a derivation path to a bytes object.
155+
156+
:param derivation_path: Derivation path.
157+
:return: Bytes object.
158+
"""
159+
return b"".join(index.ToBytes() for index in derivation_path)

starknet_py/tests/unit/__init__.py

Whitespace-only changes.

starknet_py/tests/unit/signer/__init__.py

Whitespace-only changes.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"version": 1,
3+
"rules": [
4+
{
5+
"text": "Confirm Hash to sign",
6+
"conditions": [],
7+
"actions": [
8+
["button", 2, true],
9+
["button", 2, false]
10+
]
11+
},
12+
{
13+
"regexp": ".*Hash \\(.*\\)",
14+
"conditions": [],
15+
"actions": [
16+
["button", 2, true],
17+
["button", 2, false]
18+
]
19+
},
20+
{
21+
"text": "Reject",
22+
"conditions": [],
23+
"actions": [
24+
["button", 2, true],
25+
["button", 2, false]
26+
]
27+
},
28+
{
29+
"text": "Approve",
30+
"conditions": [],
31+
"actions": [
32+
["button", 1, true],
33+
["button", 2, true],
34+
["button", 1, false],
35+
["button", 2, false]
36+
]
37+
}
38+
]
39+
}

0 commit comments

Comments
 (0)