Skip to content
Open
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
79 changes: 60 additions & 19 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,13 @@ 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

def generate_master(self, seed):
"""Master key generation in SLIP-0010
Expand Down Expand Up @@ -72,8 +76,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 +96,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 +106,58 @@ 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.privkey_to_pubkey(payload[:32]))
if tweak < self.order and point != bytes(1):
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:
Copy link
Collaborator

Choose a reason for hiding this comment

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

How does this function handle if one of the inputs is a point at infinity? Does from_encoded_point() raise an exception? I think the only way to get infinity as an input is if the tweak payload[:32] comes out zero, which is practically impossible to observe. Maybe privkey_to_pubkey() already raises an exception in that case. We should at least have a comment explaining how this function behaves for infinity inputs and why it's OK.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Besides that, I also realized that privkey_to_pubkey() raises an exception if privkey is greater than the curve's order, which would break this test vector, if we had it included in our tests.

Copy link
Collaborator

@andrewkozlik andrewkozlik Jan 22, 2026

Choose a reason for hiding this comment

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

We have that test vector included in our tests, but it tests retry in master key generation. We should generate a test vector for retry in child key derivation too and add it to the spec.

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 bytes(1) # the 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 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")
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 +218,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
9 changes: 4 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
17 changes: 17 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import pytest

from slip10.utils import SECP256K1, SECP256R1


@pytest.mark.parametrize("curve", (SECP256K1, SECP256R1))
def test_curve_arithmetic(curve):
generator = curve.privkey_to_pubkey((1).to_bytes(32, "big"))
minus_generator = curve.privkey_to_pubkey(
(curve.curve.group_order - 1).to_bytes(32, "big")
)
double_generator = curve.privkey_to_pubkey((2).to_bytes(32, "big"))
triple_generator = curve.privkey_to_pubkey((3).to_bytes(32, "big"))

assert curve.add_points(generator, minus_generator) == bytes.fromhex("00")
assert curve.add_points(generator, generator) == double_generator
assert curve.add_points(generator, double_generator) == triple_generator
Loading