-
Notifications
You must be signed in to change notification settings - Fork 156
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
Ledger support #26
Ledger support #26
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some preliminary feedback.
eth_account/ledger.py
Outdated
if len(element) == 1: | ||
result = result + struct.pack(">I", int(element[0])) | ||
else: | ||
result = result + struct.pack(">I", 0x80000000 | int(element[0])) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any chance we can get an explanation of what this code is doing and why with some inline comments?
eth_account/ledger.py
Outdated
offset = 1 + result[0] | ||
address = result[offset + 1 : offset + 1 + result[offset]] | ||
|
||
return f'0x{address.decode()}' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should use eth_utils.to_normalized_address
(assuming address
is a bytes value.
eth_account/ledger.py
Outdated
result = result + struct.pack(">I", 0x80000000 | int(element[0])) | ||
return result | ||
|
||
def get_addresses(self, offset): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The name of this function suggests that it will return multiple addresses, but it seems to only return a single one.
eth_account/ledger.py
Outdated
""" | ||
offset = 0 | ||
while address != self.get_addresses(offset): | ||
offset += 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of this mutation pattern, how about:
import itertools
for offset in itertools.count():
...
Also, lets set an upper bound on this to some reasonable value (like 20?).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, lets set an upper bound on this to some reasonable value (like 20?).
I'm not sure we can define what maximum number of addresses should be allowed against a wallet. I guess we can start with something like: how many addresses can we check in one second? At the least, we would need an escape hatch for people who might have tons of addresses, like:
def get_offset(self, address, maximum_search_length=100):
(but with a better name)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good plan, like the kwarg. how about search_limit
? We should probably also allow for specifying the starting offset value.
Thanks for the feedback, I updated accordingly ! I will probably change the offset name to something more explicit like account_id. Next is signTransaction() then encrypt() and signHash() |
@carver probably something for us to do separately, but this feels like the time to create an abstract base class for accounts so the public required API is explicitly defined. |
eth_account/ledger.py
Outdated
class LedgerAccount: | ||
""" | ||
''' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have a look at the PEP docstring format https://www.python.org/dev/peps/pep-0257/, I think it allows the docstring to get parsed correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nevermind, I just noticed all the docstrings in the project use triple single quotes.
eth_account/ledger.py
Outdated
|
||
reply = self.device.exchange(apdu, timeout=60) | ||
except: | ||
raise Exception('Something went wrong when communicating with device') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Im not too keen on masking the underlying exception in all cases. Are there specific exceptions raised by the exchange
method that could be made more friendly?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I will remove the try catch
After a rebase on master, you can subclass |
@carver signHash(), as currently defined, is not doable with the ledger: what is sent to the device is not the hash of the message, it is the whole message. The message is then hashed directly in the ledger and vrs signature are then returned. Is it possible to have the message passed to signHash() in BaseAccount ? |
@pipermerriam @carver I consider dev done, could you review ? |
Yeah, signing is ugly right now. The process for hashing messages is not well standardized. It's baked into the hardware keys, so we can't have a single So to support ledger signing, it should probably just be a custom method. You can raise a
Also, passing CI tests :) |
Because ledgerblue depends on pycrypto, and some of the other dependencies (eth-keyfile) have replaced it with pycryptodome, we are getting errors on this branch, like the following:
There is an open issue on ledgerblue: LedgerHQ/blue-loader-python#44 |
Hm, maybe we should use Or maybe just upgrade ledger software to use pycryptodome :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I understand correctly, test_ledger.py
won't be runnable in CI. If so, can you move it to a new tests/integration/ledger/...
directory. In addition, will you add a README
to that directory which explains how to run the tests and what the per-requisits are.
Right now the tests are failing for fixable reasons, so I want to see those get resolved before we hide them in a test that's skipped by CI. |
eth_account/signers/ledger.py
Outdated
|
||
''' | ||
|
||
def __init__(self, account_id=0, address='', path_prefix=ETH_DERIVATION_PATH_PREFIX): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer None
as the default value for address
here.
eth_account/signers/ledger.py
Outdated
|
||
for account_id in itertools.count(start=search_account_id): | ||
if account_id > search_limit: | ||
raise Exception(f'Address {address} not found' + |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer use of ValueError
or even a custom exception type here rather than a bare Exception
eth_account/signers/ledger.py
Outdated
raise Exception(f'Address {address} not found' + | ||
f'(search_limit={search_limit}, ' + | ||
f'search_account_id={search_account_id})') | ||
if address == self.get_address(account_id): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably use eth_utils.is_same_address
eth_account/signers/ledger.py
Outdated
''' | ||
List Ethereum HD wallet adrress of the ledger device | ||
''' | ||
return list(map(lambda account_id: self.get_address(account_id), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the lambda isn't needed here, you should be able to just pass in self.get_address
.
Alternate way to do this which I think is more readable
return [self.get_address(account_id) for account_id in range(...)]
eth_account/signers/ledger.py
Outdated
if address == self.get_address(account_id): | ||
return account_id | ||
|
||
def list(self, limit=5, page=0): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternate name suggestion get_addresses
eth_account/signers/ledger.py
Outdated
''' | ||
Not available with a Ledger | ||
''' | ||
raise Exception('signHash() is not available within ledger devices') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you use NotImplementedError
here.
r = int.from_bytes(result[1:1 + 32], 'big') | ||
s = int.from_bytes(result[1 + 32: 1 + 32 + 32], 'big') | ||
|
||
rlp_encoded = encode_transaction(unsigned_transaction, vrs=(v, r, s)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious if we should have a bit of validation here to sanity check that the returned signature does indeed belong to the expected signing address?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The sanity test has been added
setup.py
Outdated
@@ -6,6 +6,9 @@ | |||
) | |||
|
|||
extras_require={ | |||
'ledger': [ | |||
"ledgerblue==0.1.17", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we change this to be a version range, maybe just pinned within the minor
version? ledgerblue>=0.1.17,<0.2.0
I will now try to:
What's your thought on the third point ? |
Sorry, I don't know much about the internals here of the dongle interface. Can you show how this proposal would change the eth-account API? |
@carver I see it as copying and adapting HIDDongleHIDAPI class from https://github.com/LedgerHQ/blue-loader-python/blob/master/ledgerblue/comm.py#L56 and inserting it in ledger.py directly. This HIDDongleHIDAPI class only has a dependency on 'hidapi>=0.7.99' and so we can get rid of ledgerbue requirement, but off course I need to make a small POC ... |
This would be great in theory. Will have to see how it looks in practice. |
I don't know why but I can't find a way to make the defunctSignMessage() work with my ledger and I don't think it is a requirement for supporting ledger in |
I will try to simplify USBDevice class to be cleaner. Maybe we can do like geth where they have implemented a usbwallet.driver so it can be used by other hardware device: https://github.com/ethereum/go-ethereum/blob/master/accounts/usbwallet/ledger.go |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- The two
wrap
andunwrap
APDU
methods are quite long and don't read well. Do you think you could add some comments to explain what exactly is happening in them? - All of the method and variable names should be updated to use
snake_casing
instead ofcamelCasing
- All
Exception
andBaseException
usage should be updated to use either an appropriate standard library exception or a custom exception class.
eth_account/signers/ledger.py
Outdated
time.sleep(0.0001) | ||
return bytearray(data) | ||
|
||
def wrapCommandAPDU(self, channel, command, packetSize): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method doesn't appear to use self
. What do you think about extracting it to a utility function so that it can have some basic tests written for it (which will also allow it to be easily tested in isolation)
eth_account/signers/ledger.py
Outdated
def waitFirstResponse(self, timeout): | ||
start = time.time() | ||
data = "" | ||
while len(data) == 0: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a cleaner way to do this
start_at = time.time()
while True:
data = self.device.read(65)
if data:
break
else:
# check timeout, sleep, etc
eth_account/signers/ledger.py
Outdated
result += b"\x00" | ||
return bytearray(result) | ||
|
||
def unwrapResponseAPDU(self, channel, data, packetSize): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same with this method. No self
usage so it should be able to be extracted into a utility method and tested in isolation.
eth_account/signers/ledger.py
Outdated
possibleCause = "Condition of use not satisfied (denied by the user?)" | ||
if sw == 0x6a84 or sw == 0x6a85: | ||
possibleCause = "Not enough space?" | ||
if sw == 0x6484: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These should probably all be elif
clauses.
eth_account/signers/ledger.py
Outdated
if self.debug: | ||
print("HID <= %s %.2x" % (to_hex(response), sw)) | ||
if sw != 0x9000: | ||
possibleCause = "Unknown reason" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you move this to a final else
clause.
eth_account/signers/ledger.py
Outdated
result.extend(bytearray(self.device.read(65))) | ||
sw = (result[swOffset] << 8) + result[swOffset + 1] | ||
response = result[dataStart : dataLength + dataStart] | ||
if self.debug: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you drop the debug
flag and instead use the standard python logging facilities. We don't ship with print
statements.
class LedgerUsbDevice:
logger = logging.getLogger('eth_account.signers.ledger.LedgerUsbDevices')
def some_method(self):
self.logging.debug('the message')
I think the use of the hidapi is the way to go, so I spent some time cleanly implementing it with comments. Hope it looks like it could be possibly merged this way ? :) I still have to move |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly optional nitpicky stuff. The only big question is it doesnt look like there is handling for transactions larger than 255 bytes.
eth_account/signers/ledger.py
Outdated
packet = header + commands[packet_id] | ||
|
||
# Add padding to the packet to make it exactly PACKET_SIZE long | ||
packet += bytes([0x0] * (PACKET_SIZE - len(packet))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could this be simplified with ljust
?
packet.ljust(PACKET_SIZE, bytes([0x0]))
eth_account/signers/ledger.py
Outdated
command = struct.pack('>H', len(command)) + command | ||
|
||
# Split command into at max PACKET_FREE sized chunks | ||
commands = [command[i:i + PACKET_FREE] for i in range(0, len(command), PACKET_FREE)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The var name commands
was confusing the first couple times reading through. Could it be something like command_chunks
, command_packets
, or simply chunks
?
class LedgerUsbDevice: | ||
''' | ||
References: | ||
- https://github.com/LedgerHQ/blue-loader-python/blob/master/ledgerblue/comm.py#L56 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the comm
reference be taken out now that it is no longer used, or is it an important example for how the ledger works?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would rather let it as is because part of the comm module are still present (device detection for ex.)
eth_account/signers/ledger.py
Outdated
for hidDevice in hid.enumerate(0, 0): | ||
if hidDevice['vendor_id'] == 0x2c97: | ||
if ('interface_number' in hidDevice and hidDevice['interface_number'] == 0) \ | ||
or ('usage_page' in hidDevice and hidDevice['usage_page'] == 0xffa0): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could these hex values be put in global constants? LEDGER_VENDOR_ID
and LEDGER_USAGE_PAGE_ID
?
eth_account/signers/ledger.py
Outdated
if not channel: | ||
if reply_start + timeout < time.time(): | ||
raise LedgerUsbException(f'Timeout waiting for a device' + | ||
f'response (timeout={timeout}s)') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL about f-strings!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, they're pretty cool. I can't wait to use them more broadly! Unfortunately f-strings weren't added until 3.6, but Web3.py (and therefore eth-account) has to support py3.5. So this will have to be switched out for %
or .format
style instead. :(
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That explain why circleCI lint was failing ! Too bad we are not on 3.6 :)
I will update to remove all f-strings.
eth_account/signers/ledger.py
Outdated
# Status is stored at then end of the reply | ||
(status,) = struct.unpack('>H', reply[-2:]) | ||
|
||
if status == 0x9000: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does 0x9000
mean? A constant var could explain this.
eth_account/signers/ledger.py
Outdated
rlp_encoded_tx = rlp.encode(unsigned_transaction) | ||
|
||
# https://github.com/LedgerHQ/blue-app-eth/blob/master/doc/ethapp.asc#sign-eth-transaction | ||
apdu = bytes.fromhex('e0040000') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'e0040000' could be saved to a constant, with a descriptive name like: ADPU_SIGN_TX_FIRST_DATA_BLOCK
eth_account/signers/ledger.py
Outdated
apdu = bytes.fromhex('e0040000') | ||
apdu += bytes([len(bip32_path) + len(rlp_encoded_tx)]) | ||
apdu += bip32_path | ||
apdu += rlp_encoded_tx |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if the rlp_encoded_tx is greater than the 255 byte limit? Should this be getting chunked and sent with the next data block adpu ('e0048000')?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The chunk is done by the exchange() function, but I will add a test to see if it is working as expected.
@dylanjw you were right ! Transaction larger than 255 would not be signed. Now fixed ! |
c81fcec
to
457dd86
Compare
I finally found a way to make defunctSignMessage() work and sign message on the ledger ! I think the PR is now worth reviewing @pipermerriam @carver @dylanjw but do you want me to squash history before or after review ? This PR now use hidapi python module and do not rely anymore on ledgerblue. |
Squashing can happen after. I should be able to do another review tomorrow. |
... and by tomorrow I mean next week. Sorry for the delay. I've been pretty heads-down on getting the next trinity release out the door. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a full review, but at least something to get started on.
) | ||
|
||
# Not a fixture because it does not support multiple LedgerAccount | ||
ledger = LedgerAccount() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does it mean that "it does not support multiple LedgerAccount"? Maybe it should be a session fixture, like:
@pytest.fixture(scope="session")
def ledger:
return LedgerAccount()
|
||
@pytest.fixture | ||
def acct(request): | ||
return Account |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this could just be Account
down below, and drop the acct
fixture altogether.
assert type(signed.r) is str | ||
assert type(signed.s) is str | ||
|
||
assert signed.hash == tx_hash |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This approach looks problematic. The tx_hash
will be different for every ledger, because it will be a different account signing it. So if I ran it on my own ledger, the test would fail. Am I missing something?
I guess one way to make it work would be to include a test setup with a (burned) mnemonic that everyone can use to initialize their ledger to the same account before the test.
assert len(accounts_4p1) == 4 | ||
assert accounts_4p0 == accounts_10[:4] | ||
assert accounts_4p1 == accounts_10[4:8] | ||
assert accounts_10 == (accounts_4p0 + accounts_4p1 + accounts_4p2[:2]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe just do accounts_12
instead of accounts_10
above, then all three of these assertions can be dropped for: assert accounts_12 == accounts_4p0 + accounts_4p1 + accounts_4p2
.
accounts_4p0 = ledger.get_addresses(limit=4, page=0) | ||
accounts_4p1 = ledger.get_addresses(limit=4, page=1) | ||
accounts_4p2 = ledger.get_addresses(limit=4, page=2) | ||
assert len(accounts_4p1) == 4 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might as well check the length of all three
|
||
assert signed.hash == tx_hash | ||
|
||
assert acct.recoverTransaction(signed.rawTransaction).lower() == expected_sender |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ledger.address
should return the address in checksum format. Then this doesn't need to be lower()
d in the assertion.
|
||
assert type(signed.v) is int | ||
assert type(signed.r) is str | ||
assert type(signed.s) is str |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
r
and s
should also be returned from signTransaciton()
as int
, like http://eth-account.readthedocs.io/en/latest/eth_account.html#eth_account.account.Account.signTransaction
Assuming r and s are hex strings, you can convert with eth_utils.to_int(hexstr=r)
assert signed.messageHash == msghash | ||
assert type(signed.v) is int | ||
assert type(signed.r) is str | ||
assert type(signed.s) is str |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All three should be int
, like http://eth-account.readthedocs.io/en/latest/eth_account.html#eth_account.account.Account.signHash
@@ -0,0 +1,116 @@ | |||
# Require a connected Ledger ... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe even better to point to the readme, like:
# This test requires a connected Ledger
# For setup instructions see tests/integration/ledger/README.md
pip install -e .[ledger] | ||
``` | ||
|
||
Run the tests with: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably need to set up the ledger somehow first right? From the other comments, maybe we need a shared seed for testing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You were right, I added a seed in the README and used it in the tests.
@carver I updated following your review. I tested using the seed and it worked on both a nanos and a ledger blue using he hidapi approach ! |
@carver seems my PR missed the 0.3.0 release :) |
@carver @pipermerriam do you think this PR still needs some work ? |
The function defunctSignMessage() is not working but is added for documentation purpose.
- remove ledgerblue dependency - pack all device comm in LedgerUsbDevice class
- use checksumed address - return r and s as int - Add instruction for ledger init using a well defined seed - add ledger as a sessions fixture - add tx_signed and addresses values related to the new seed - update tests to use new defined seed
@bargst sorry for our silence, 👀 |
@bargst thanks so much for this. Merged in #34 This pr, and the new HDAccount one both fall into a category where we'll need to do an in-depth review and security audit before we can include in master and release for public use. Our current workload suggests it'll be later this year after Devcon before we can do that so we've merged into a local feature branch until that time. Thank you for your contribution. It's going to be a big improvement. |
Thanks @pipermerriam for merging ! I will close this PR, feel free to ping me during review/audit :) |
…ve-pypy-markdown-support switch to native pypy markdown support
What was wrong?
Issue #25
How was it fixed?
Create a class LedgerAccount
Cute Animal Picture