Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
python-version: ['3.8', '3.9', '3.10']
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ keywords = ["bitcoin", "slip10", "hdwallet"]

[tool.poetry.dependencies]
cryptography = "*"
ecdsa = "*"
python = ">=3.8,<4.0"

[tool.poetry.group.dev.dependencies]
pytest = "*"
black = ">=20"
isort = "^5"
# Fix pytest for python 3.10
# See https://github.com/pytest-dev/pytest/issues/12177#issue-2220516002
exceptiongroup = { version = "*", markers = "python_version == '3.10'" }
tomli = { version = "*", markers = "python_version == '3.10'" }

[build-system]
requires = ["poetry-core"]
Expand Down
100 changes: 80 additions & 20 deletions slip10/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import hmac
import re

import ecdsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
Expand Down Expand Up @@ -31,10 +32,14 @@ class SLIP10DerivationError(Exception):


class WeierstrassCurve:
def __init__(self, name, modifier, curve):
def __init__(self, name, modifier, curve, a, modulus, order):
self.name = name
self.modifier = modifier
self.curve = curve
self.a = a
self.modulus = modulus
self.order = order
self.point_at_infinity = bytes(1)

def generate_master(self, seed):
"""Master key generation in SLIP-0010
Expand Down Expand Up @@ -72,8 +77,8 @@ def derive_private_child(self, privkey, chaincode, index):

while True:
tweak = int.from_bytes(payload[:32], "big")
child_private = (tweak + int.from_bytes(privkey, "big")) % self.curve.order
if tweak <= self.curve.order and child_private != 0:
child_private = (tweak + int.from_bytes(privkey, "big")) % self.order
if tweak < self.order and child_private != 0:
break
payload = hmac.new(
chaincode,
Expand All @@ -92,8 +97,6 @@ def derive_public_child(self, pubkey, chaincode, index):

:return: (child_pubkey, child_chaincode)
"""
from ecdsa.ellipticcurve import INFINITY

assert isinstance(pubkey, bytes) and isinstance(chaincode, bytes)
if index & HARDENED_INDEX != 0:
raise SLIP10DerivationError("Hardened derivation is not possible.")
Expand All @@ -104,31 +107,76 @@ def derive_public_child(self, pubkey, chaincode, index):
).digest()
while True:
tweak = int.from_bytes(payload[:32], "big")
point = ecdsa.VerifyingKey.from_string(pubkey, self.curve).pubkey.point
point += self.curve.generator * tweak
if tweak <= self.curve.order and point != INFINITY:
point = self.add_points(pubkey, self.multiply_generator(tweak))
if tweak < self.order and point != self.point_at_infinity:
break
payload = hmac.new(
chaincode,
b"\x01" + payload[32:] + index.to_bytes(4, "big"),
hashlib.sha512,
).digest()
return point.to_bytes("compressed"), payload[32:]
return point, payload[32:]

def privkey_is_valid(self, privkey):
key = int.from_bytes(privkey, "big")
return 0 < key < self.curve.order
return 0 < key < self.order

def add_points(self, first: bytes, second: bytes) -> bytes:
if first == self.point_at_infinity:
return second

if second == self.point_at_infinity:
return first

p1 = ec.EllipticCurvePublicKey.from_encoded_point(self.curve, first)
p2 = ec.EllipticCurvePublicKey.from_encoded_point(self.curve, second)

x1 = p1.public_numbers().x
y1 = p1.public_numbers().y
x2 = p2.public_numbers().x
y2 = p2.public_numbers().y

if x1 == x2 and y1 == -y2 % self.modulus:
return self.point_at_infinity

if x1 == x2 and y1 == y2:
# doubling
slope = (
(3 * x1 * x1 + self.a) * pow(2 * y1, -1, self.modulus) % self.modulus
)
else:
slope = (y2 - y1) * pow(x2 - x1, -1, self.modulus) % self.modulus

x3 = (slope * slope - x1 - x2) % self.modulus
y3 = (slope * (x1 - x3) - y1) % self.modulus

return bytes([0x02 if y3 % 2 == 0 else 0x03]) + x3.to_bytes(32, "big")

def multiply_generator(self, scalar: int) -> bytes:
scalar %= self.order

if scalar == 0:
return self.point_at_infinity

sk = ec.derive_private_key(scalar, self.curve)
return sk.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.CompressedPoint,
)

def pubkey_is_valid(self, pubkey):
try:
ecdsa.VerifyingKey.from_string(pubkey, self.curve)
ec.EllipticCurvePublicKey.from_encoded_point(self.curve, pubkey)
return True
except ecdsa.errors.MalformedPointError:
except ValueError:
return False

def privkey_to_pubkey(self, privkey):
sk = ecdsa.SigningKey.from_string(privkey, self.curve)
return sk.get_verifying_key().to_string("compressed")
def privkey_to_pubkey(self, privkey: bytes) -> bytes:
sk = ec.derive_private_key(int.from_bytes(privkey, "big"), self.curve)
return sk.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.CompressedPoint,
)


class EdwardsCurve:
Expand Down Expand Up @@ -189,16 +237,28 @@ def pubkey_is_valid(self, pubkey):
return True

def privkey_to_pubkey(self, privkey):
from cryptography.hazmat.primitives import serialization

sk = self.private_key_class.from_private_bytes(privkey)
key_encoding = serialization.Encoding.Raw
key_format = serialization.PublicFormat.Raw
return b"\x00" + sk.public_key().public_bytes(key_encoding, key_format)


SECP256K1 = WeierstrassCurve("secp256k1", b"Bitcoin seed", ecdsa.SECP256k1)
SECP256R1 = WeierstrassCurve("secp256r1", b"Nist256p1 seed", ecdsa.NIST256p)
SECP256K1 = WeierstrassCurve(
"secp256k1",
b"Bitcoin seed",
ec.SECP256K1(),
0,
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F,
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141,
)
SECP256R1 = WeierstrassCurve(
"secp256r1",
b"Nist256p1 seed",
ec.SECP256R1(),
0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC,
0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF,
0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551,
)
ED25519 = EdwardsCurve("ed25519", b"ed25519 seed", Ed25519PrivateKey, Ed25519PublicKey)
X25519 = EdwardsCurve(
"curve25519", b"curve25519 seed", X25519PrivateKey, X25519PublicKey
Expand Down
28 changes: 23 additions & 5 deletions tests/test_slip10.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import os

import ecdsa
import pytest

from slip10 import HARDENED_INDEX, SLIP10, InvalidInputError, PrivateDerivationError
from slip10.utils import SECP256K1

SEED_1 = "000102030405060708090a0b0c0d0e0f"
SEED_2 = "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"
Expand Down Expand Up @@ -440,10 +440,9 @@ def test_sanity_checks():
== slip10.get_xpriv_from_path([])
)
non_extended_pubkey = slip10.get_privkey_from_path("m")
pubkey = ecdsa.SigningKey.from_string(
non_extended_pubkey, ecdsa.SECP256k1
).get_verifying_key()
assert pubkey.to_string("compressed") == slip10.get_pubkey_from_path("m")
assert SECP256K1.privkey_to_pubkey(
non_extended_pubkey
) == slip10.get_pubkey_from_path("m")
# But getting from "m'" does not make sense
with pytest.raises(ValueError, match="invalid format"):
slip10.get_pubkey_from_path("m'")
Expand Down Expand Up @@ -914,3 +913,22 @@ def test_slip10_vectors():
assert node.chaincode.hex() == chaincode
assert node.privkey.hex() == privkey
assert node.pubkey.hex() == pubkey


def test_secp256r1_derivation_retry():
# Test retry in public key to public key derivation
# https://github.com/satoshilabs/slips/blob/master/slip-0010.md#test-derivation-retry-for-nist256p1
chaincode, pubkey = SLIP10.from_seed(
bytes.fromhex(SEED_1), curve_name="secp256r1"
).get_extended_pubkey_from_path("m/28578'")
chaincode, pubkey = SLIP10(
chaincode, pubkey=pubkey, curve_name="secp256r1"
).get_extended_pubkey_from_path("m/33941")
assert (
chaincode.hex()
== "9e87fe95031f14736774cd82f25fd885065cb7c358c1edf813c72af535e83071"
)
assert (
pubkey.hex()
== "0235bfee614c0d5b2cae260000bb1d0d84b270099ad790022c1ae0b2e782efe120"
)
Comment on lines +918 to +934
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works, although ideally we should have a test that takes the path in each test vector, converts it to a list _deriv_path_str_to_list(path) and counts how many indices at the end are non-hardened. Then it should derive the node up to the last hardened index using get_child_from_path() and from there derive using only public keys. Basically the above test but automated. (Doesn't need to be done in this PR.)

21 changes: 21 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import pytest

from slip10.utils import SECP256K1, SECP256R1


@pytest.mark.parametrize("curve", (SECP256K1, SECP256R1))
def test_curve_arithmetic(curve):
generator = curve.multiply_generator(1)
minus_generator = curve.multiply_generator(curve.order - 1)
double_generator = curve.multiply_generator(2)
triple_generator = curve.multiply_generator(3)

assert curve.add_points(curve.point_at_infinity, generator) == generator
assert curve.add_points(generator, curve.point_at_infinity) == generator
assert curve.add_points(generator, minus_generator) == curve.point_at_infinity
assert curve.add_points(generator, generator) == double_generator
assert curve.add_points(generator, double_generator) == triple_generator

assert curve.multiply_generator(0) == curve.point_at_infinity
assert curve.multiply_generator(curve.order) == curve.point_at_infinity
assert curve.multiply_generator(curve.order + 1) == generator