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

100% test coverage #62

Merged
merged 26 commits into from
Jun 12, 2024
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
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

all:: install build

test:: lint
test::
poetry run pytest -n auto

test-all::
Expand All @@ -13,7 +13,7 @@ test-dist:: clean build install-dist test-integration
test-integration::
poetry run pytest "tests/test_cli.py::TestIntegration" -m "" -n auto

push:: test-all git-off-main git-no-unsaved
push:: lint test-all git-off-main git-no-unsaved
@branch=$$(git symbolic-ref --short HEAD); \
git push origin $$branch

Expand Down Expand Up @@ -49,6 +49,7 @@ check::
poetry run isort . --check
poetry run flake8 . --ignore=E501,W503
bash -n scripts/*.sh
bash -n tests/*.sh

lint::
isort .
Expand Down
13 changes: 1 addition & 12 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "bipsea"
version = "1.1.2"
version = "1.1.3"
description = "Composable Python CLI for Bitcoin mnemonics and BIP-85 secrets."
readme = "README.md"
authors = ["Aneesh Karve <[email protected]>"]
Expand Down Expand Up @@ -42,7 +42,6 @@ importlib-resources = { version = "^6.4.0", python = "<3.9" }
pycryptodome = "~3.20.0"
pytest = "~8.2.1"
pytest-xdist = "~3.6.1"
toml = "^0.10.2"
pytest-cov = "^5.0.0"

[tool.poetry.scripts]
Expand Down
60 changes: 36 additions & 24 deletions src/bipsea/bip32.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from typing import List

from ecdsa import SECP256k1, SigningKey, VerifyingKey
from ecdsa.keys import MalformedPointError
from ecdsa.ellipticcurve import INFINITY
from ecdsa.keys import VerifyingKey as VerifyingKeyType

from .bip32types import VERSIONS, ExtendedKey
from .util import LOGGER_NAME
Expand Down Expand Up @@ -104,6 +105,9 @@ def CKDpriv(
depth: bytes,
version: bytes,
) -> ExtendedKey:
if version not in [VERSIONS[net]["private"] for net in ("mainnet", "testnet")]:
raise ValueError(f"Expected a private version, got version={version}")

hardened = child_number >= TYPED_CHILD_KEY_COUNT
secret_int = int.from_bytes(private_key[1:], "big")
data = (
Expand All @@ -118,16 +122,12 @@ def CKDpriv(
key=chain_code,
data=data + child_number.to_bytes(4, "big"),
)
# BIP-32: In case parse256(IL) ≥ n or ki = 0, the resulting key is invalid
# (Note: this has probability lower than 1 in 2**127.)
parse_256_IL = int.from_bytes(derived[:32], "big")
child_key_int = (
parse_256_IL + int.from_bytes(private_key, "big")
) % SECP256k1.order
if (parse_256_IL >= SECP256k1.order) or not child_key_int:
raise ValueError(
f"Rare invalid child key. Retry with the next child index: {child_number} + 1."
)
validate_private_child_params(parse_256_IL, child_key_int, child_number)

child_key = bytes(1) + child_key_int.to_bytes(32, "big")

return ExtendedKey(
Expand Down Expand Up @@ -169,35 +169,27 @@ def CKDpub(
finger: bytes,
version: bytes,
) -> ExtendedKey:
if version not in [VERSIONS[net]["public"] for net in ("mainnet", "testnet")]:
raise ValueError(f"Expected a public version, got version={version}")

child_number_int = int.from_bytes(child_number, "big")
if child_number_int >= TYPED_CHILD_KEY_COUNT:
raise ValueError(f"Cannot call CKDpub() for hardened child: {child_number_int}")

parent_key = VerifyingKey.from_string(public_key, curve=SECP256k1).pubkey.point
parent_point = VerifyingKey.from_string(public_key, curve=SECP256k1).pubkey.point

derived = hmac_sha512(
key=chain_code,
data=public_key + child_number,
)
parse_256_IL = int.from_bytes(derived[:32], "big")
# BIP-39: In case parse256(IL) ≥ n or Ki is the point at infinity, the resulting
# key is invalid, and one should proceed with the next value for i.
if parse_256_IL >= SECP256k1.order:
raise ValueError(f"Invalid key. Try next child index: {child_number} + 1.")
try:
child_key = VerifyingKey.from_public_point(
SECP256k1.generator * parse_256_IL + parent_key,
curve=SECP256k1,
).to_string("compressed")
except MalformedPointError as mal:
# TODO: Is this in fact how to detect the point at infinity?
# or do we get None back?
raise ValueError(
f"Invalid key (point at infinity?). Try next child index: {child_number} + 1."
) from mal
child_point = VerifyingKey.from_public_point(
SECP256k1.generator * parse_256_IL + parent_point, curve=SECP256k1
)
validate_public_child_params(parse_256_IL, child_point, child_number)

return ExtendedKey(
data=child_key,
data=child_point.to_string("compressed"),
chain_code=derived[32:],
child_number=child_number_int.to_bytes(4, "big"),
depth=depth,
Expand Down Expand Up @@ -246,3 +238,23 @@ def fingerprint(private_key: bytes) -> bytes:

def hmac_sha512(key: bytes, data: bytes) -> bytes:
return hmac.new(key=key, msg=data, digestmod="sha512").digest()


def validate_private_child_params(parse_256_IL: int, child_key: int, child_number: int):
# BIP-32: In case parse256(IL) ≥ n or ki = 0, the resulting key is invalid
# (Note: this has probability lower than 1 in 2**127.)
if (parse_256_IL >= SECP256k1.order) or child_key == 0:
raise ValueError(
f"Rare invalid child key. Retry with the next child index: {child_number} + 1."
)


def validate_public_child_params(
parse_256_IL: int, child_point: VerifyingKeyType, child_number: int
):
# BIP-32: In case parse256(IL) ≥ n or Ki is the point at infinity, the resulting
# key is invalid, and one should proceed with the next value for i.
if parse_256_IL >= SECP256k1.order or child_point == INFINITY:
raise ValueError(
f"Child pub key out of range. Try next child index: {child_number} + 1."
)
74 changes: 40 additions & 34 deletions src/bipsea/bip32types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from collections import namedtuple

import base58
from ecdsa import SECP256k1
from ecdsa import SECP256k1, SigningKey, VerifyingKey
from ecdsa.errors import MalformedPointError

from .util import LOGGER_NAME

Expand Down Expand Up @@ -86,7 +87,7 @@ def __new__(
)


def parse_ext_key(key: str):
def parse_ext_key(key: str, validate: bool = True):
"""
master - bip32 extended key, base 58
"""
Expand All @@ -102,41 +103,46 @@ def parse_ext_key(key: str):
data=master_dec[45:],
)

matches = 0
for net in VERSIONS:
for vis in VERSIONS[net]:
if ext_key.version == VERSIONS[net][vis]:
matches += 1
if net == "mainnet":
assert key.startswith("x")
else:
assert key.startswith("t")
if vis == "public":
assert key[1:4] == "pub"
assert ext_key.is_public()
else:
assert key[1:4] == "prv"
assert ext_key.is_private()
assert matches == 1, f"unrecognized version: {ext_key.version}"

int_key = int.from_bytes(ext_key.data, "big")
if (int_key < 1) or (int_key >= SECP256k1.order):
raise ValueError(f"Key out of bounds: {ext_key.data}")
depth = int.from_bytes(ext_key.depth, "big")
if depth == 0:
assert ext_key.finger == bytes(4)
assert ext_key.child_number == bytes(4)
else:
assert ext_key.finger != bytes(4)

assert len(ext_key.version) == 4
assert len(ext_key.finger) == len(ext_key.child_number) == 4
assert len(ext_key.data) - 1 == 32 == len(ext_key.chain_code)
if validate:
try:
matches = 0
for net in VERSIONS:
for vis in VERSIONS[net]:
if ext_key.version == VERSIONS[net][vis]:
matches += 1
if net == "mainnet":
assert key.startswith("x")
else:
assert key.startswith("t")
if vis == "public":
assert key[1:4] == "pub"
assert ext_key.is_public()
else:
assert key[1:4] == "prv"
assert ext_key.is_private()
assert matches == 1, f"unrecognized version: {ext_key.version}"

if ext_key.is_private():
SigningKey.from_string(ext_key.data[1:], curve=SECP256k1)
else:
VerifyingKey.from_string(ext_key.data, curve=SECP256k1)
depth = int.from_bytes(ext_key.depth, "big")
if depth == 0:
assert ext_key.finger == bytes(4)
assert ext_key.child_number == bytes(4)
else:
assert ext_key.finger != bytes(4)

assert len(ext_key.version) == 4
assert len(ext_key.finger) == len(ext_key.child_number) == 4
assert len(ext_key.data) - 1 == 32 == len(ext_key.chain_code)
except (AssertionError, MalformedPointError) as source:
raise ValueError("Invalid key") from source

return ext_key


def validate_prv(prv: str, private: bool) -> bool:
def validate_prv_str(prv: str, private: bool) -> bool:
try:
key = parse_ext_key(prv)
assert len(str(key)) == 111
Expand All @@ -146,7 +152,7 @@ def validate_prv(prv: str, private: bool) -> bool:
else:
assert key.is_public()

except (AssertionError, ValueError):
except ValueError:
return False

return True
4 changes: 2 additions & 2 deletions src/bipsea/bip39.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import warnings
from hashlib import pbkdf2_hmac

try:
try: # pragma: no cover
from importlib.resources import files
except ImportError:
except ImportError: # pragma: no cover
from importlib_resources import files # for Python 3.8

from typing import List
Expand Down
34 changes: 9 additions & 25 deletions src/bipsea/bipsea.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import click

from .bip32 import to_master_key
from .bip32types import parse_ext_key, validate_prv
from .bip32types import parse_ext_key, validate_prv_str
from .bip39 import (
LANGUAGES,
N_WORDS_ALLOWED,
Expand Down Expand Up @@ -38,26 +38,15 @@

ISO_TO_LANGUAGE = {v["code"]: k for k, v in LANGUAGES.items()}

MNEMONIC_TO_VALUES = list(ISO_TO_LANGUAGE.keys())
N_WORDS_ALLOWED_STR = [str(n) for n in N_WORDS_ALLOWED]

SEED_FROM_VALUES = [
MNEMONIC_TO_VALUES = list(ISO_TO_LANGUAGE.keys())
MNEMONIC_FROM_VALUES = [
"any",
"random",
] + list(ISO_TO_LANGUAGE.keys())


SEED_TO_VALUES = [
"tprv",
"xprv",
] + list(ISO_TO_LANGUAGE.keys())


ENTROPY_TO_VALUES = list(ISO_TO_LANGUAGE.keys())

N_WORDS_ALLOWED_STR = [str(n) for n in N_WORDS_ALLOWED]

TIMEOUT = 0.08


logger = logging.getLogger(LOGGER_NAME)

Expand Down Expand Up @@ -217,13 +206,13 @@ def xprv(mnemonic, passphrase, mainnet):
help="Output language for `--application mnemonic`.",
)
def derive_cli(application, number, index, special, xprv, to):
if not xprv:
xprv = try_for_pipe_input()
else:
if xprv:
xprv = xprv.strip()
else:
xprv = try_for_pipe_input()
no_empty_param("--xprv", xprv)

if not validate_prv(xprv, private=True):
if not validate_prv_str(xprv, private=True):
raise click.BadParameter("Bad xprv or tprv.", param_hint="--xprv (or pipe)")

if number is not None:
Expand Down Expand Up @@ -264,11 +253,6 @@ def derive_cli(application, number, index, special, xprv, to):
elif application == "dice":
check_range(number, application)
path += f"/{special}'/{number}'/{index}'"
else:
raise click.BadOptionUsage(
option_name="--application",
message=f"unrecognized {application}",
)

derived = derive(master, path)
if application == "drng":
Expand Down Expand Up @@ -312,4 +296,4 @@ def try_for_pipe_input():


if __name__ == "__main__":
cli()
cli() # pragma: no cover
6 changes: 6 additions & 0 deletions src/bipsea/util.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""constants and utilities"""

import binascii
import contextlib
import logging
import math
import random
Expand Down Expand Up @@ -87,3 +88,8 @@ def shuffle(lst: List[str]) -> List[str]:
lst[cursor], lst[choice] = lst[choice], lst[cursor]

return lst


@contextlib.contextmanager
def no_raise():
yield
Loading