Skip to content
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

Support Ledger signer #1402

Merged
merged 95 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from 71 commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
e971af6
Move `KeyPair` to separate file
franciszekjob Jul 17, 2024
754e181
wip: Add `LedgerSigner`
franciszekjob Jul 17, 2024
deec6cf
Add parsing derivation path; Add tests for invalid derivation path
franciszekjob Jul 17, 2024
999472b
Change test case values
franciszekjob Jul 17, 2024
88ac105
Refactor `LedgerSigner.__init__()`
franciszekjob Jul 17, 2024
55808e5
Implement `LedgerStarknetApp.get_public_key()`
franciszekjob Jul 17, 2024
1592155
Remove unneeced import
franciszekjob Jul 17, 2024
6edfb7e
Add `LedgerStarknetApp.sign_hash()`; Implement `sign_transaction()` a…
franciszekjob Jul 18, 2024
c0a4045
Format
franciszekjob Jul 18, 2024
ccf1d28
Update ledger signer init tests
franciszekjob Jul 18, 2024
c88e654
Format
franciszekjob Jul 18, 2024
40f05cd
Move helper functions below classes
franciszekjob Jul 18, 2024
d638cd9
Rename test for init `LedgerSigner` with invalid derivation path
franciszekjob Jul 18, 2024
4eff5c1
Update docstrings of `LedgerSigner` methods
franciszekjob Jul 18, 2024
7fe2d68
Add `LedgerStarknetApp.version`
franciszekjob Jul 18, 2024
8b98452
Format
franciszekjob Jul 18, 2024
fc47fee
Add docs for `LedgerSigner`
franciszekjob Jul 18, 2024
5f8e565
Add transaction sign test for `LedgerSigner`
franciszekjob Jul 18, 2024
1a6ea14
Merge branch 'development' of https://github.com/software-mansion/sta…
franciszekjob Jul 18, 2024
a2fb8d7
Add `ledgerwallet` to `pyproject.toml`
franciszekjob Jul 18, 2024
d0ec4eb
Move `KeyPair` back to `stark_curve_signer.py`
franciszekjob Jul 18, 2024
e181bd6
Add `bip-utils` to `pyproject.toml`
franciszekjob Jul 18, 2024
b55b84a
Fix passing `device_confirmation` param
franciszekjob Jul 18, 2024
6557449
Add caret to `ledgerwallet` and `bip-utils` dependencies
franciszekjob Jul 18, 2024
ebc9340
Add variable annotations
franciszekjob Jul 18, 2024
b6b7607
Format
franciszekjob Jul 18, 2024
a1771dc
wip: Add test form eth transfer
franciszekjob Jul 19, 2024
a8852a5
Add `LedgerSigner` usage example in signing docs
franciszekjob Jul 22, 2024
fdc3520
Add missing return sections in `LedgerSigner` docstrings
franciszekjob Jul 22, 2024
88a78fe
Minor refactor of `_parse_derivation_path_str()`
franciszekjob Jul 22, 2024
7434645
Format
franciszekjob Jul 22, 2024
71adfee
Skip `test_sign_transaction`; Temporarily remove `test_transfer`
franciszekjob Jul 22, 2024
7e18509
Remove unused `get_account_balance_eth()`
franciszekjob Jul 22, 2024
b093696
Remove sign hash command 1
franciszekjob Jul 22, 2024
0e92b1c
Update test skip-message
franciszekjob Jul 22, 2024
2b598ba
Merge branch 'development' of https://github.com/software-mansion/sta…
franciszekjob Jul 22, 2024
e080fa3
Format
franciszekjob Jul 22, 2024
794129f
Restore sign hash command 1
franciszekjob Jul 22, 2024
8e57d0b
Update `poetry.lock`
franciszekjob Jul 22, 2024
d49a7ba
Disable `test_init_with_invalid_derivation_path`; Change test skip-me…
franciszekjob Jul 23, 2024
eb31ab0
Remove unused imports
franciszekjob Jul 23, 2024
da60471
Add `test_deploy_account_and_transfer`; Add speculos automation json
franciszekjob Jul 26, 2024
019fb96
Add leder-related env variables to CI
franciszekjob Jul 26, 2024
ccbc174
Add ledger setup to CI
franciszekjob Jul 26, 2024
86d182f
Remove check for sender address balance in ledger transfer test
franciszekjob Jul 26, 2024
e578987
Update starknet_py/net/signer/test_ledger_signer.py
franciszekjob Jul 26, 2024
bad4a5a
Update starknet_py/net/signer/test_ledger_signer.py
franciszekjob Jul 26, 2024
e717873
Update starknet_py/net/signer/test_ledger_signer.py
franciszekjob Jul 26, 2024
fd1817d
Add test request for speculos on ci
franciszekjob Jul 26, 2024
439f222
Remove unused variable
franciszekjob Jul 26, 2024
1b54d25
Refactor addresses variables
franciszekjob Jul 26, 2024
b250365
Get `LedgerSigner` example code from tests
franciszekjob Jul 29, 2024
067af30
Rename constants for ledger responses length
franciszekjob Jul 29, 2024
819ff6f
Refactor ledger code snippets
franciszekjob Jul 29, 2024
934b0b4
Update ledger code snippet comment
franciszekjob Jul 29, 2024
c25243e
Update ledger code snippet - remove prefund-related code
franciszekjob Jul 29, 2024
7e0cc0c
Fix title underline
franciszekjob Jul 29, 2024
795a440
Replace `automation` file path with json content
franciszekjob Jul 29, 2024
d51cdfc
Temporarily skip `test_create_account_with_ledger_signer`
franciszekjob Jul 29, 2024
6bb089e
Reduce waiting time for speculos start
franciszekjob Jul 29, 2024
50a23d9
Temporarily skip `test_sign_transaction`
franciszekjob Jul 29, 2024
7aae00e
Change chain id to `SEPOLIA` in `test_sign_transaction`
franciszekjob Jul 29, 2024
0067626
Temporarily skip `test_init_with_invalid_derivation_path`
franciszekjob Jul 29, 2024
0307f23
Fix regex in ledger automation json; Unskip `test_init_with_invalid_d…
franciszekjob Jul 30, 2024
82e375f
Move automation json file
franciszekjob Jul 30, 2024
569c723
Cleanup CI ledger setup
franciszekjob Jul 30, 2024
2c1f232
Add temp `ls` on CI
franciszekjob Jul 30, 2024
e537037
Remove backslash from curl command
franciszekjob Jul 30, 2024
22333c9
Put `Start Speculos emulator container` command into one line
franciszekjob Jul 30, 2024
fc4cff3
Change temp command `ps` to `ls`
franciszekjob Jul 30, 2024
f6ec67a
Restore `Wait for Speculos to start` job
franciszekjob Jul 30, 2024
e70791f
Remove prints from `test_deploy_account_and_transfer`
franciszekjob Jul 30, 2024
95fc39e
Resolve conflicts with `development`
franciszekjob Aug 1, 2024
5e8d483
Fix contract variable names
franciszekjob Aug 1, 2024
b939050
Fix linting
franciszekjob Aug 2, 2024
f477dcc
Move signer tests to `tests/unit/signer`
franciszekjob Aug 2, 2024
d7eea6f
Change check for signature length in `test_sign_transaction`
franciszekjob Aug 2, 2024
f494b82
Add ETH contract address in constants
franciszekjob Aug 2, 2024
8f1bf12
Format
franciszekjob Aug 2, 2024
b335781
Update starknet_py/net/signer/ledger_signer.py
franciszekjob Aug 2, 2024
da99ffe
Merge branch 'franciszekjob/1386-support-ledger-signer' of https://gi…
franciszekjob Aug 2, 2024
c41f47c
Remove unnecessary command usage in `LedgerSigner.sign_hash()`
franciszekjob Aug 2, 2024
97a27ab
Remove unused parameter `derivation_path`
franciszekjob Aug 2, 2024
392db46
Use declare v3 in ledger `test_sign_transaction`
franciszekjob Aug 5, 2024
064def7
Format
franciszekjob Aug 5, 2024
d6da62d
Update `working-directory` in `Update automation rules` job
franciszekjob Aug 5, 2024
2c5736f
Update code snippets paths in `signing.rst`
franciszekjob Aug 5, 2024
e935f78
Setup ledger speculos on windows CI
franciszekjob Aug 6, 2024
c55fd17
Enable windows tests
kkawula Aug 6, 2024
bbe557a
Revert "Enable windows tests"
kkawula Aug 6, 2024
3fc921c
Revert "Setup ledger speculos on windows CI"
franciszekjob Aug 6, 2024
795edbd
Skip Ledger tests on Windows
franciszekjob Aug 6, 2024
6ac0065
Update todo comment
franciszekjob Aug 6, 2024
6cb93c6
Use v3 instead of v1 in `test_deploy_account_and_transfer`
franciszekjob Aug 6, 2024
f81a010
Update example title in `signing.rst`
franciszekjob Aug 6, 2024
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
45 changes: 45 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ jobs:
fail-fast: false
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
env:
LEDGER_PROXY_ADDRESS: 127.0.0.1
LEDGER_PROXY_PORT: 9999
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -197,6 +200,48 @@ jobs:
- name: Install devnet
run: ./starknet_py/tests/install_devnet.sh

# ====================== SETUP LEDGER SPECULOS ====================== #

- name: Pull speculos image
run: docker pull ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools


- name: Clone LedgerHQ Starknet app repository
run: git clone https://github.com/LedgerHQ/app-starknet.git

- name: Build the app inside Docker container
uses: addnab/docker-run-action@v3
ddoktorski marked this conversation as resolved.
Show resolved Hide resolved
with:
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools
options: --rm -v ${{ github.workspace }}:/apps
run: |
cd /apps/app-starknet
cargo clean
cargo ledger build nanox

- name: Start Speculos emulator container
uses: addnab/docker-run-action@v3
with:
image: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-dev-tools
options: --rm -d --name speculos-emulator -v ${{ github.workspace }}:/apps --publish 5000:5000 --publish 9999:9999
run: |
speculos \
-m nanox \
--apdu-port 9999 \
--api-port 5000 \
--display headless \
/apps/app-starknet/target/nanox/release/starknet

- name: Wait for Speculos to start
run: sleep 5
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved

- name: Update automation rules
working-directory: starknet_py/net/signer
run: |
curl -X POST http://127.0.0.1:5000/automation \
-H "Content-Type: application/json" \
-d @speculos_automation.json

# ====================== RUN TESTS ====================== #

- name: Check circular imports
Expand Down
9 changes: 9 additions & 0 deletions docs/api/signer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@ KeyPair
:undoc-members:
:member-order: groupwise

------------
LedgerSigner
------------

.. py:module:: starknet_py.net.signer.ledger_signer

.. autoclass:: LedgerSigner
:members:
:member-order: groupwise
15 changes: 15 additions & 0 deletions docs/guide/signing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ signing algorithm, it is possible to create ``Account`` with custom
:language: python
:dedent: 4

Signing with Ledger
-------------------
:ref:`LedgerSigner` allows you to sign transactions using a Ledger device. The device must be unlocked and Starknet app needs to be open.

.. codesnippet:: ../../starknet_py/net/signer/test_ledger_signer.py
:language: python
:dedent: 4

Deploying account and transferring ETH
--------------------------------------
.. codesnippet:: ../../starknet_py/net/signer/test_ledger_signer.py
:language: python
:dedent: 4
:start-after: docs-deploy-account-and-transfer: start
:end-before: docs-deploy-account-and-transfer: end

Signing off-chain messages
-------------------------------
Expand Down
698 changes: 691 additions & 7 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ furo = { version = "^2024.5.6", optional = true }
pycryptodome = "^3.17"
crypto-cpp-py = "1.4.4"
eth-keyfile = "^0.8.1"
ledgerwallet = "^0.5.0"
bip-utils = "^2.9.3"
ddoktorski marked this conversation as resolved.
Show resolved Hide resolved

[tool.poetry.extras]
docs = ["sphinx", "enum-tools", "furo"]
Expand Down
8 changes: 8 additions & 0 deletions starknet_py/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@
QUERY_VERSION_BASE = 2**128

ROOT_PATH = Path(__file__).parent

# Ledger constants
STARKNET_CLA = 0x5A
EIP_2645_PURPOSE = 0x80000A55
EIP_2645_PATH_LENGTH = 6
PUBLIC_KEY_RESPONSE_LENGTH = 65
SIGNATURE_RESPONSE_LENGTH = 65
VERSION_RESPONSE_LENGTH = 3
172 changes: 172 additions & 0 deletions starknet_py/net/signer/ledger_signer.py
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
from typing import List

from bip_utils import Bip32KeyIndex, Bip32Path, Bip32Utils
from ledgerwallet.client import LedgerClient

from starknet_py.constants import (
EIP_2645_PATH_LENGTH,
EIP_2645_PURPOSE,
PUBLIC_KEY_RESPONSE_LENGTH,
SIGNATURE_RESPONSE_LENGTH,
STARKNET_CLA,
VERSION_RESPONSE_LENGTH,
)
from starknet_py.net.models import AccountTransaction
from starknet_py.net.models.chains import ChainId
from starknet_py.net.signer import BaseSigner
from starknet_py.utils.typed_data import TypedData


class LedgerStarknetApp:
def __init__(self):
self.client: LedgerClient = LedgerClient(cla=STARKNET_CLA)

@property
def version(self) -> str:
"""
Get the Ledger app version.

:return: Version string.
"""
response = self.client.apdu_exchange(ins=0)
if len(response) != VERSION_RESPONSE_LENGTH:
raise ValueError(
f"Unexpected response length (expected: {VERSION_RESPONSE_LENGTH}, actual: {len(response)}"
)
major, minor, patch = list(response)
return f"{major}.{minor}.{patch}"

def get_public_key(
self, derivation_path: Bip32Path, device_confirmation: bool = False
) -> int:
"""
Get public key for the given derivation path.

:param derivation_path: Derivation path of the account.
:param device_confirmation: Whether to display confirmation on the device for extra security.
:return: Public key.
"""

data = _derivation_path_to_bytes(derivation_path)
response = self.client.apdu_exchange(
ins=1,
data=data,
p1=int(device_confirmation),
p2=0,
)

if len(response) != PUBLIC_KEY_RESPONSE_LENGTH:
raise ValueError(
f"Unexpected response length (expected: {PUBLIC_KEY_RESPONSE_LENGTH}, actual: {len(response)}"
)

public_key = int.from_bytes(response[1:33], byteorder="big")
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved
return public_key

def sign_hash(self, derivation_path: Bip32Path, hash_val: int) -> List[int]:
"""
Request a signature for a raw hash with the given derivation path.
Currently, the Ledger app only supports blind signing raw hashes.

:param derivation_path: Derivation path of the account.
:param hash_val: Hash to sign.
:return: Signature as a list of two integers.
"""

data = _derivation_path_to_bytes(derivation_path)
self.client.apdu_exchange(
ins=0,
data=data,
p1=0,
p2=0,
)
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved

# for some reason the Ledger app expects the data to be left shifted by 4 bits
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved
shifted_int = hash_val << 4
shifted_bytes = shifted_int.to_bytes(32, byteorder="big")

response = self.client.apdu_exchange(
ins=0x02,
data=shifted_bytes,
p1=0x01,
p2=0x00,
)

if (
len(response) != SIGNATURE_RESPONSE_LENGTH + 1
or response[0] != SIGNATURE_RESPONSE_LENGTH
):
raise ValueError(
f"Unexpected response length (expected: {SIGNATURE_RESPONSE_LENGTH}, actual: {len(response)}"
)

r, s = int.from_bytes(response[1:33], byteorder="big"), int.from_bytes(
response[33:65], byteorder="big"
)
return [r, s]


class LedgerSigner(BaseSigner):
def __init__(self, derivation_path_str: str, chain_id: ChainId):
"""
:param derivation_path_str: Derivation path string of the account.
:param chain_id: ChainId of the chain.
"""

self.app: LedgerStarknetApp = LedgerStarknetApp()
self.derivation_path: Bip32Path = _parse_derivation_path_str(
derivation_path_str
)
self.chain_id: ChainId = chain_id

@property
def public_key(self) -> int:
return self.app.get_public_key(derivation_path=self.derivation_path)

def sign_transaction(self, transaction: AccountTransaction) -> List[int]:
tx_hash = transaction.calculate_hash(self.chain_id)
return self.app.sign_hash(
derivation_path=self.derivation_path, hash_val=tx_hash
)

def sign_message(self, typed_data: TypedData, account_address: int) -> List[int]:
msg_hash = typed_data.message_hash(account_address)
return self.app.sign_hash(
derivation_path=self.derivation_path, hash_val=msg_hash
)


def _parse_derivation_path_str(derivation_path_str) -> Bip32Path:
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved
"""
Parse a derivation path string to a Bip32Path object.

:param derivation_path_str: Derivation path string.
:return: Bip32Path object.
"""
if not derivation_path_str:
raise ValueError("Empty derivation path")

path_parts = derivation_path_str.lstrip("m/").split("/")
path_elements = [
Bip32KeyIndex(
Bip32Utils.HardenIndex(int(part[:-1])) if part.endswith("'") else int(part)
)
for part in path_parts
]

if len(path_elements) != EIP_2645_PATH_LENGTH:
raise ValueError(f"Derivation path is not {EIP_2645_PATH_LENGTH}-level long")
if path_elements[0] != EIP_2645_PURPOSE:
raise ValueError("Derivation path is not prefixed with m/2645.")

return Bip32Path(path_elements)


def _derivation_path_to_bytes(derivation_path: Bip32Path) -> bytes:
"""
Convert a derivation path to a bytes object.

:param derivation_path: Derivation path.
:return: Bytes object.
"""
return b"".join(index.ToBytes() for index in derivation_path)
39 changes: 39 additions & 0 deletions starknet_py/net/signer/speculos_automation.json
franciszekjob marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"version": 1,
"rules": [
{
"text": "Confirm Hash to sign",
"conditions": [],
"actions": [
["button", 2, true],
["button", 2, false]
]
},
{
"regexp": ".*Hash \\(.*\\)",
"conditions": [],
"actions": [
["button", 2, true],
["button", 2, false]
]
},
{
"text": "Reject",
"conditions": [],
"actions": [
["button", 2, true],
["button", 2, false]
]
},
{
"text": "Approve",
"conditions": [],
"actions": [
["button", 1, true],
["button", 2, true],
["button", 1, false],
["button", 2, false]
]
}
]
}
Loading
Loading