Skip to content

Commit 1b265ae

Browse files
committed
fuzz against verystable
1 parent f24cb0c commit 1b265ae

File tree

3 files changed

+118
-18
lines changed

3 files changed

+118
-18
lines changed

.github/workflows/tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ jobs:
4141
sudo make install
4242
fi
4343
44+
- name: Run C tests
45+
run: |
46+
make test
47+
4448
- name: Run Python tests
4549
run: |
4650
cd examples/py

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
A fast, secure, low-dependency implementation of BIP32
44

55
- Depends only on [`libsecp256k1`](https://github.com/bitcoin-core/libsecp256k1) and
6-
[`libsodium`](https://github.com/jedisct1/libsodium)
6+
[`libsodium`](https://github.com/jedisct1/libsodium) (for SHA256, HMAC-SHA512, and
7+
context randomization)
78
- No heap allocations (aside from secp context), which allows end users to manage memory securely
89
- Implemented in pure C for ease of FFI
910
- Extensively tested
@@ -31,17 +32,36 @@ git clone https://github.com/jamesob/cbip32.git && \
3132
make && sudo make install
3233
```
3334

35+
## Performance
36+
37+
The Python bindings for this implementation have been shown to be
38+
- *~2x* faster than `python-bip32`, an implementation based on `coincurve` which itself
39+
wraps libsecp256k1, and
40+
- *>100x* faster than `verystable`, which is a "dumb" pure Python implementation.
41+
42+
From fuzzing:
43+
```
44+
- Highest target scores [time per derivation]:
45+
0.0162932 (label='ours')
46+
0.0379755 (label='python-bip32')
47+
48+
- Highest target scores [time per derivation]:
49+
0.00222896 (label='ours')
50+
0.315636 (label='verystable')
51+
```
52+
53+
3454
## Example bindings included
3555

36-
### [Python](./examples/py)
56+
### Python [(`./examples/py`)](./examples/py)
3757

3858
```python
3959
>>> from bindings import derive
4060
>>> derive('0' * 32, 'm/1/2h').get_public().serialize()
4161
'xpub69s9RVsS4kfK2VFed2giqFm9gQ4VmCWuWcPHFJ51Rj6dHvBjCicCZm2HR88Z6J5zRYyHkt7W9LPygBc57RCCPp2t1AxCNa1VtvSq4qWYLqK'
4262
```
4363

44-
### [Go](./examples/go)
64+
### Go [(`./examples/go`)](./examples/go)
4565

4666
```go
4767
package cbip32

examples/py/test_fuzz_cross_impl.py

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44
# "bip32",
55
# "hypothesis",
66
# "pytest",
7+
# "verystable",
78
# ]
89
# ///
10+
import sys
911
import random
1012
import logging
1113
import time
1214
from contextlib import contextmanager
1315

16+
import bip32 as py_bip32
17+
from verystable import bip32 as vs_bip32
18+
1419
import pytest
1520
from hypothesis import given, strategies as st, target, settings
16-
from bip32 import BIP32
1721

1822
from bindings import derive
1923

@@ -32,16 +36,48 @@ def timer(label):
3236
target(time.perf_counter() - start, label=label)
3337

3438

35-
def their_derive(seed_hex_str: str, bip32_path: str) -> str:
36-
bip32 = BIP32.from_seed(bytes.fromhex(seed_hex_str))
39+
def py_derive(seed_hex_str: str, bip32_path: str) -> str:
40+
bip32 = py_bip32.BIP32.from_seed(bytes.fromhex(seed_hex_str))
3741
try:
3842
return bip32.get_xpriv_from_path(bip32_path)
3943
except Exception:
4044
return INVALID_KEY
4145

4246

43-
def their_xpub_derive(base58: str, bip32_path: str) -> str:
44-
bip32 = BIP32.from_xpub(base58)
47+
def _path_str_to_ints(bip32_path) -> list[int] | None:
48+
if not bip32_path.startswith('m'):
49+
return None
50+
51+
path_ints = []
52+
components = filter(None, bip32_path.lstrip('m').split('/'))
53+
54+
for comp in components:
55+
if comp.endswith('h'):
56+
path_ints.append(int(comp[:-1]) | vs_bip32.HARDENED_INDEX)
57+
else:
58+
path_ints.append(int(comp))
59+
60+
return path_ints
61+
62+
63+
def verystable_derive(seed_hex_str: str, bip32_path: str) -> str:
64+
bip32 = vs_bip32.BIP32.from_bytes(bytes.fromhex(seed_hex_str), True)
65+
path_ints = _path_str_to_ints(bip32_path)
66+
67+
if path_ints is None:
68+
return INVALID_KEY
69+
elif not path_ints:
70+
return bip32.serialize()
71+
72+
try:
73+
derived, _ = bip32.derive(*path_ints)
74+
return derived.serialize()
75+
except Exception:
76+
return INVALID_KEY
77+
78+
79+
def py_xpub_derive(base58: str, bip32_path: str) -> str:
80+
bip32 = py_bip32.BIP32.from_xpub(base58)
4581
try:
4682
return bip32.get_xpub_from_path(bip32_path)
4783
except Exception:
@@ -56,38 +92,78 @@ def our_derive(hex_str, path) -> str:
5692
return INVALID_KEY
5793

5894

95+
@st.composite
96+
def py_compatible_bip32_paths(draw):
97+
"""
98+
Given python-bip32's too-large path issue, clamp the max_values that we can fuzz:
99+
https://github.com/darosior/python-bip32/issues/46
100+
"""
101+
MAX_ALLOWED_DEPTH = 255
102+
MAX_UNHARDENED_IDX = 2**31 - 1
103+
104+
depth = draw(st.integers(min_value=0, max_value=(MAX_ALLOWED_DEPTH + 3)))
105+
path_parts = ["m"]
106+
for _ in range(depth):
107+
index = draw(st.integers(min_value=-2, max_value=MAX_UNHARDENED_IDX))
108+
hardened = draw(st.booleans())
109+
path_parts.append(f"{index}{"h" if hardened else ''}")
110+
return "/".join(path_parts)
111+
112+
59113
@st.composite
60114
def bip32_paths(draw):
61-
depth = draw(st.integers(min_value=0, max_value=255))
115+
"""
116+
Generate BIP32 paths with some out of bound values.
117+
"""
118+
MAX_ALLOWED_DEPTH = 255
119+
MAX_UNHARDENED_IDX = 2**31 - 1
120+
121+
depth = draw(st.integers(min_value=0, max_value=(MAX_ALLOWED_DEPTH + 3)))
62122
path_parts = ["m"]
63123
for _ in range(depth):
64-
index = draw(st.integers(min_value=0, max_value=(2**31 - 1)))
124+
index = draw(st.integers(min_value=-2, max_value=(MAX_UNHARDENED_IDX + 2)))
65125
hardened = draw(st.booleans())
66126
path_parts.append(f"{index}{"h" if hardened else ''}")
67127
return "/".join(path_parts)
68128

69129

70-
@given(seed_hex_str=valid_seeds, bip32_path=bip32_paths())
130+
@given(seed_hex_str=valid_seeds, bip32_path=py_compatible_bip32_paths())
71131
@settings(max_examples=2_000)
72-
def test_impls(seed_hex_str, bip32_path):
132+
def test_versus_py(seed_hex_str, bip32_path):
133+
"""Compare implementations of BIP32 on a random seed and path."""
73134
with timer('ours'):
74135
ours = our_derive(seed_hex_str, bip32_path)
75136
with timer('python-bip32'):
76-
theirs = their_derive(seed_hex_str, bip32_path)
77-
assert ours == theirs
137+
pys = py_derive(seed_hex_str, bip32_path)
138+
139+
assert ours == pys
140+
141+
142+
@given(seed_hex_str=valid_seeds, bip32_path=py_compatible_bip32_paths())
143+
@settings(max_examples=100, deadline=5000) # verstable is slooooww, so allow 5s tests
144+
def test_versus_vs(seed_hex_str, bip32_path):
145+
"""
146+
Since the verystable implemention is VERY slow (100x+), limit the number of cases.
147+
"""
148+
with timer('ours'):
149+
ours = our_derive(seed_hex_str, bip32_path)
150+
with timer('verystable'):
151+
vs = verystable_derive(seed_hex_str, bip32_path)
152+
153+
assert ours == vs
78154

79155

80-
@given(bip32_path=bip32_paths())
156+
@given(bip32_path=py_compatible_bip32_paths())
81157
@settings(max_examples=200)
82158
def test_xpub_impls(bip32_path):
83159
xpub = 'xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ'
84160

85161
with timer('ours'):
86162
ours = our_derive(xpub, bip32_path)
87163
with timer('python-bip32'):
88-
theirs = their_xpub_derive(xpub, bip32_path)
89-
assert ours == theirs
164+
pys = py_xpub_derive(xpub, bip32_path)
165+
assert ours == pys
90166

91167

92168
if __name__ == "__main__":
93-
pytest.main([__file__, "-v", "--capture=no", "--hypothesis-show-statistics"])
169+
pytest.main([__file__, "-v", "--capture=no", "--hypothesis-show-statistics", "-x"] + sys.argv[1:])

0 commit comments

Comments
 (0)