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

Add support for BIP32 (Heirarchical Deterministic Wallets) #88

Merged
merged 17 commits into from
Feb 12, 2020
Merged
Show file tree
Hide file tree
Changes from 7 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
Empty file.
27 changes: 27 additions & 0 deletions eth_account/hdaccount/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import hashlib
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
import hmac

from eth_keys import (
keys,
)
from hexbytes import (
HexBytes,
)

SECP256K1_N = int("FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_BAAEDCE6_AF48A03B_BFD25E8C_D0364141", 16)


def hmac_sha512(chain_code: bytes, data: bytes) -> bytes:
"""
As specified by RFC4231 - https://tools.ietf.org/html/rfc4231
"""
return hmac.new(chain_code, data, hashlib.sha512).digest()


def ec_point(pkey: bytes) -> bytes:
"""
Compute `point(p)`, where `point` is ecdsa point multiplication
Note: Result is ecdsa public key serialized to compressed form
"""
return keys.PrivateKey(HexBytes(pkey)).public_key.to_compressed_bytes()
192 changes: 192 additions & 0 deletions eth_account/hdaccount/deterministic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""
Heirarchical Deterministic Wallet generator (HDWallet)

Partially implements the BIP-0032, BIP-0043, and BIP-0044 specifications:
BIP-0032: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
BIP-0043: https://github.com/bitcoin/bips/blob/master/bip-0043.mediawiki
BIP-0044: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki

Skips serialization and public key derivation as unnecssary for this library's purposes.

Notes
-----

* Integers are modulo the order of the curve (referred to as n).
* Addition (+) of two coordinate pair is defined as application of the EC group operation.
* Concatenation (||) is the operation of appending one byte sequence onto another.


Definitions
-----------

* point(p): returns the coordinate pair resulting from EC point multiplication
(repeated application of the EC group operation) of the secp256k1 base point
with the integer p.
* ser_32(i): serialize a 32-bit unsigned integer i as a 4-byte sequence,
most significant byte first.
* ser_256(p): serializes the integer p as a 32-byte sequence, most significant byte first.
* ser_P(P): serializes the coordinate pair P = (x,y) as a byte sequence using SEC1's compressed
form: (0x02 or 0x03) || ser_256(x), where the header byte depends on the parity of the
omitted y coordinate.
* parse_256(p): interprets a 32-byte sequence as a 256-bit number, most significant byte first.

"""
# Additional notes:
# - This algorithm only implements private parent key → private child key CKD function,
# as it is unnecessary to the HD key derivation functions used in this library to implement
# the other functions (as Ethereum uses an Account-based system)
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
# - Unlike other libraries, this library does not use Bitcoin key serialization, because it is
# not intended to be ultimately used for Bitcoin key derivations. This presents a simplified API.
from typing import (
Tuple,
Union,
)

from eth_utils import (
to_int,
)

from ._utils import (
SECP256K1_N,
ec_point,
hmac_sha512,
)


class Node(int):
TAG = "" # No tag
OFFSET = 0x0 # No offset
"""
Base node class
"""
def __new__(cls, index):
obj = int.__new__(cls, index + cls.OFFSET)
obj.index = index
return obj

def __repr__(self):
return f"{self.__class__.__name__}({self.index})"

def __add__(self, other: int):
return self.__class__(self.index + other)

def serialize(self) -> bytes:
assert 0 <= self < 2**32
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
return self.to_bytes(4, byteorder="big")

def encode(self) -> str:
return str(self.index) + self.TAG

@staticmethod
def decode(node: str) -> Union["SoftNode", "HardNode"]:
if len(node) < 1:
raise ValueError("Cannot use empty string")
if node[-1] in ("'", "H"):
return HardNode(int(node[:-1]))
else:
return SoftNode(int(node))


class SoftNode(Node):
"""
Soft node (unhardened), where value = index
"""
TAG = "" # No tag
OFFSET = 0x0 # No offset


class HardNode(Node):
"""
Hard node, where value = index + BIP32_HARDENED_CONSTANT
"""
TAG = "H" # "H" (or "'") means hard node (but use "H" for clarity)
OFFSET = 0x80000000 # 2**31, BIP32 "Hardening constant"


def derive_child_key(
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
parent_key: bytes,
parent_chain_code: bytes,
node: Node,
) -> Tuple[bytes, bytes]:
"""
From BIP32:

The function CKDpriv((k_par, c_par), i) → (k_i, c_i) computes a child extended
private key from the parent extended private key:

- Check whether i ≥ 2**31 (whether the child is a hardened key).
- If so (hardened child):
let I = HMAC-SHA512(Key = c_par, Data = 0x00 || ser_256(kpar) || ser_32(i)).
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
(Note: The 0x00 pads the private key to make it 33 bytes long.)
- If not (normal child):
let I = HMAC-SHA512(Key = c_par, Data = ser_P(point(k_par)) || ser_32(i)).
- Split I into two 32-byte sequences, I_L and I_R.
- The returned child key k_i is parse_256(I_L) + k_par (mod n).
- The returned chain code c_i is I_R.
- In case parse_256(I_L) ≥ n or k_i = 0, the resulting key is invalid,
and one should proceed with the next value for i.
(Note: this has probability lower than 1 in 2**127.)
"""
assert len(parent_chain_code) == 32
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(node, HardNode):
# NOTE Empty byte is added to align to SoftNode case
assert len(parent_key) == 32 # Should be guarenteed here in return statment
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
child = hmac_sha512(parent_chain_code, b"\x00" + parent_key + node.serialize())

else:
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
assert len(ec_point(parent_key)) == 33 # Should be guarenteed by Account class
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
child = hmac_sha512(parent_chain_code, ec_point(parent_key) + node.serialize())

assert len(child) == 64

if to_int(child[:32]) >= SECP256K1_N:
# Invalid key, compute using next node (< 2**-127 probability)
return derive_child_key(parent_key, parent_chain_code, node + 1)

child_key = (to_int(child[:32]) + to_int(parent_key)) % SECP256K1_N
if child_key == 0:
# Invalid key, compute using next node (< 2**-127 probability)
return derive_child_key(parent_key, parent_chain_code, node + 1)
return child_key.to_bytes(32, byteorder="big"), child[32:]
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved


class HDPath:
def __init__(self, path: str):
"""
Constructor for this class. Initializes an hd account generator and
if possible the encoded key and the derivation path. If no arguments are
specified, create a new account with createAccount(...) or initialize
an account given a mnemonic and an optional password with initAccount(...)
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
:param path : BIP32-compatible derivation path
:type path : str as "m/idx_0/.../idx_n" or "m/idx_0/.../idx_n"
where idx_* is either an integer value (soft node)
or an integer value followed by either the "'" char
or the "H" char (hardened node)
"""
nodes = path.split('/')
if not nodes[0] == 'm':
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError(f'Path is not valid: "{path}". Must start with "m"')
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
decoded_path = []
try:
for node in nodes[1:]: # We don't need the root node 'm'
decoded_path.append(Node.decode(node))
except ValueError as e:
raise ValueError(f'Path is not valid: "{path}". Issue with node "{node}": {e}')
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
self._path = decoded_path

def __repr__(self) -> str:
return f'{self.__class__.__name__}(path="{self.encode()}")'

def encode(self) -> str:
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
encoded_path = ['m']
for node in self._path:
encoded_path.append(node.encode())
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
return '/'.join(encoded_path)

def derive(self, seed: bytes) -> bytes:
master_node = hmac_sha512(b"Bitcoin seed", seed)
key = master_node[:32]
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
chain_code = master_node[32:]
for node in self._path:
key, chain_code = derive_child_key(key, chain_code, node)
return key
90 changes: 90 additions & 0 deletions tests/core/test_deterministic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import pytest

from eth_account.hdaccount.deterministic import (
HDPath,
)


# Test vectors from: https://en.bitcoin.it/wiki/BIP_0032_TestVectors
# Confirmed using https://github.com/richardkiss/pycoin
@pytest.mark.parametrize("seed,path,key", [
# --- BIP32 Testvector 1 ---
(
"000102030405060708090a0b0c0d0e0f", "m",
"e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35"
),
(
"000102030405060708090a0b0c0d0e0f", "m/0H",
"edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea"
),
(
"000102030405060708090a0b0c0d0e0f", "m/0H/1",
"3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368"
),
(
"000102030405060708090a0b0c0d0e0f", "m/0H/1/2H",
"cbce0d719ecf7431d88e6a89fa1483e02e35092af60c042b1df2ff59fa424dca"
),
(
"000102030405060708090a0b0c0d0e0f", "m/0H/1/2H/2",
"0f479245fb19a38a1954c5c7c0ebab2f9bdfd96a17563ef28a6a4b1a2a764ef4"
),
(
"000102030405060708090a0b0c0d0e0f", "m/0H/1/2H/2/1000000000",
"471b76e389e528d6de6d816857e012c5455051cad6660850e58372a6c3e6e7c8"
),
# --- BIP32 Testvector 2 ---
(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c"
"999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"m",
"4b03d6fc340455b363f51020ad3ecca4f0850280cf436c70c727923f6db46c3e"
),
(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c"
"999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"m/0",
"abe74a98f6c7eabee0428f53798f0ab8aa1bd37873999041703c742f15ac7e1e"
),
(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c"
"999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"m/0/2147483647H",
"877c779ad9687164e9c2f4f0f4ff0340814392330693ce95a58fe18fd52e6e93"
),
(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c"
"999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"m/0/2147483647H/1",
"704addf544a06e5ee4bea37098463c23613da32020d604506da8c0518e1da4b7"
),
(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c"
"999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"m/0/2147483647H/1/2147483646H",
"f1c7c871a54a804afe328b4c83a1c33b8e5ff48f5087273f04efa83b247d6a2d"
),
(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c"
"999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"m/0/2147483647H/1/2147483646H/2",
"bb7d39bdb83ecf58f2fd82b6d918341cbef428661ef01ab97c28a4842125ac23"
),
# --- BIP32 Testvector 3 ---
# NOTE: Leading zeros bug https://github.com/iancoleman/bip39/issues/58
(
"4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45"
"d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be",
"m",
# NOTE Contains leading zero byte (which was the bug)
"00ddb80b067e0d4993197fe10f2657a844a384589847602d56f0c629c81aae32"
),
(
"4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45"
"d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be",
"m/0H",
"491f7a2eebc7b57028e0d3faa0acda02e75c33b03c48fb288c41e2ea44e1daef"
)
])
def test_bip32_testvectors(seed, path, key):
assert HDPath(path).derive(bytes.fromhex(seed)).hex() == key