Files
pyhdwallet/tests/test_vectors.py
LC mac 0949fe9792 feat(bip85): add gen-child command (v1.0.6)
Implements BIP85 child mnemonic derivation with full interoperability.

Features:
- Derives child BIP39 mnemonics (12/15/18/21/24 words) from master mnemonic
- BIP85 path: m/83696968'/39'/0'/{words}'/{index}'
- Supports optional master BIP39 passphrase
- Reuses existing input modes (--interactive, --mnemonic-stdin)
- Follows existing UX patterns (--off-screen, --file, PGP encryption)
- Offline-first with NetworkGuard protection

Testing:
- Adds deterministic regression tests for BIP85 spec compliance
- Verified against official BIP85 test vectors
- CLI smoke tests for end-to-end validation

Interoperability:
- Produces mnemonics compatible with Coldcard, Ian Coleman tool, etc.
- Test vector verified: 'girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose'

Version bumped to v1.0.6
2026-01-09 18:46:19 +08:00

222 lines
7.2 KiB
Python

import sys
import os
import json
import pytest
import subprocess
from pathlib import Path
# Add src to path at the very top
TEST_DIR = Path(__file__).parent
SRC_DIR = TEST_DIR.parent / "src"
sys.path.insert(0, str(SRC_DIR))
import pyhdwallet
from bip_utils import Bip39SeedGenerator
DATA_DIR = TEST_DIR / "data"
VECTORS_FILE = TEST_DIR / "vectors.json"
@pytest.fixture
def vectors():
if not VECTORS_FILE.exists():
pytest.fail("tests/vectors.json missing. Run tests/bootstrap_vectors.py first.")
with open(VECTORS_FILE, "r") as f:
return json.load(f)
@pytest.fixture
def recipient_key_content():
path = DATA_DIR / "recipient.asc"
if not path.exists():
pytest.fail("tests/data/recipient.asc missing.")
with open(path, "r", encoding="utf-8") as f:
return f.read()
def test_pgp_fingerprint_calculation(vectors, recipient_key_content):
"""
Verifies that pgp_fingerprint computes the expected fingerprint
for the stored recipient key.
"""
expected = vectors["pgp"]["expected_fingerprint"]
actual = pyhdwallet.pgp_fingerprint(recipient_key_content)
assert actual == expected, "Fingerprint calculation logic has changed!"
def test_pgp_require_fingerprint_match(vectors):
"""
Verifies the safety check require_fingerprint_match enforces exact matches.
"""
expected = vectors["pgp"]["expected_fingerprint"]
# Should not raise
pyhdwallet.require_fingerprint_match(expected, expected, "test")
# Should raise on mismatch
wrong = expected.replace("A", "B") if "A" in expected else expected.replace("0", "1")
with pytest.raises(ValueError, match="test: PGP fingerprint mismatch"):
pyhdwallet.require_fingerprint_match(wrong, expected, "test")
def test_bip39_seed_derivation(vectors):
"""
Verifies that mnemonics convert to seeds exactly as they did at bootstrap time.
"""
for case in vectors["bip39"]:
mnemonic = case["mnemonic"]
passphrase = case["passphrase"]
expected_hex = case["expected_seed_hex"]
# Verify internal Bip39SeedGenerator usage matches expected hex
actual_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
assert actual_bytes.hex() == expected_hex
def test_address_derivation_integrity(vectors):
"""
Verifies derive_all produces the exact same addresses for supported chains.
"""
for case in vectors["bip39"]:
seed_hex = case["expected_seed_hex"]
seed_bytes = bytes.fromhex(seed_hex)
for profile, expected_addresses in case["derived_addresses"].items():
# Infer count from ethereum addresses in the expected data
# (Ethereum always generates `count` addresses, unlike Solana which depends on profile)
count = len(expected_addresses.get("ethereum", []))
result = pyhdwallet.derive_all(
seed_bytes,
chains=["ethereum", "bitcoin", "solana"],
count=count,
sol_profile=profile,
export_private=False
)
assert result["addresses"] == expected_addresses, f"Mismatch for profile {profile}"
def test_bip85_derivation(vectors):
"""
Verifies BIP85 child mnemonic derivation matches expected test vectors.
Tests:
- Path construction
- Entropy64 derivation (HMAC-SHA512)
- Entropy truncation
- Child mnemonic generation
"""
from bip_utils import Bip39SeedGenerator
for case in vectors["bip85"]:
master_mnemonic = case["master_mnemonic"]
master_passphrase = case["master_passphrase"]
child_words = case["child_words"]
index = case["index"]
# Generate master seed
master_seed = Bip39SeedGenerator(master_mnemonic).Generate(master_passphrase)
# Derive child using BIP85
path, child_mnemonic, entropy64, truncated_entropy = pyhdwallet.bip85_derive_child_mnemonic(
master_seed,
child_words,
index
)
# Assert path
assert path == case["bip85_path"], f"Path mismatch: {path} != {case['bip85_path']}"
# Assert entropy64 (full 64-byte HMAC output)
assert entropy64.hex() == case["expected_entropy64_hex"], \
f"Entropy64 mismatch for {case['description']}"
# Assert truncated entropy
assert truncated_entropy.hex() == case["expected_entropy_truncated_hex"], \
f"Truncated entropy mismatch for {case['description']}"
# Assert child mnemonic
assert child_mnemonic == case["expected_child_mnemonic"], \
f"Child mnemonic mismatch for {case['description']}"
def test_cli_gen_child_smoke(tmp_path, vectors):
"""
CLI smoke test for gen-child command.
Verifies:
- Command runs without error
- ZIP file is created
- Off-screen mode works
- File output works
"""
vector = vectors["bip85"][0] # Use first BIP85 test case
master_mnemonic = vector["master_mnemonic"]
cmd = [
sys.executable, "src/pyhdwallet.py", "gen-child",
"--mnemonic-stdin",
"--index", "0",
"--words", "12",
"--off-screen",
"--file",
"--zip-password-mode", "auto",
"--wallet-location", str(tmp_path)
]
result = subprocess.run(
cmd,
input=master_mnemonic.encode("utf-8"),
capture_output=True
)
assert result.returncode == 0, f"CLI gen-child failed: {result.stderr.decode()}"
# Check ZIP file created
zips = list(tmp_path.glob("*.zip"))
assert len(zips) == 1, f"Expected exactly one zip file, found {len(zips)}"
# Check filename pattern
zip_name = zips[0].name
assert zip_name.startswith("bip85_child_"), f"Unexpected zip name: {zip_name}"
def test_cli_recover_smoke(tmp_path, vectors):
"""
Runs the CLI in a subprocess to verify end-to-end wiring
without network (recover mode).
"""
vector = vectors["bip39"][0]
mnemonic = vector["mnemonic"]
expected_fp = vectors["pgp"]["expected_fingerprint"]
recipient_file = DATA_DIR / "recipient.asc"
# 1. Successful Recovery
cmd = [
sys.executable, "src/pyhdwallet.py", "recover",
"--mnemonic-stdin",
"--pgp-pubkey-file", str(recipient_file),
"--expected-fingerprint", expected_fp,
"--file",
"--wallet-location", str(tmp_path),
"--off-screen",
"--zip-password-mode", "auto"
]
result = subprocess.run(
cmd,
input=mnemonic.encode("utf-8"),
capture_output=True
)
assert result.returncode == 0, f"CLI failed: {result.stderr.decode()}"
zips = list(tmp_path.glob("*.zip"))
assert len(zips) == 1, "Expected exactly one zip file output"
# 2. Failed Recovery (Wrong Fingerprint)
wrong_fp = expected_fp.replace("A", "B")
cmd_fail = list(cmd)
cmd_fail[cmd_fail.index(expected_fp)] = wrong_fp
result_fail = subprocess.run(
cmd_fail,
input=mnemonic.encode("utf-8"),
capture_output=True
)
assert result_fail.returncode != 0
assert b"fingerprint mismatch" in result_fail.stderr or b"fingerprint mismatch" in result_fail.stdout