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
This commit is contained in:
LC mac
2026-01-09 18:46:19 +08:00
parent 21b9389591
commit 0949fe9792
5 changed files with 618 additions and 8 deletions

91
tests/generate_bip85_vectors.py Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Generate BIP85 test vectors for vectors.json
Run this from the repo root:
python3 tests/generate_bip85_vectors.py
"""
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pyhdwallet
from bip_utils import Bip39SeedGenerator
import json
# Test cases to generate
test_cases = [
{
"description": "BIP85 test case 1: 12-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 12,
"index": 0,
},
{
"description": "BIP85 test case 2: 18-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 18,
"index": 0,
},
{
"description": "BIP85 test case 3: 24-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 24,
"index": 0,
},
{
"description": "BIP85 test case 4: 12-word child, WITH passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "TREZOR",
"child_words": 12,
"index": 0,
},
]
print("Generating BIP85 test vectors...\n")
print("=" * 80)
vectors = []
for case in test_cases:
print(f"\n{case['description']}")
print("-" * 80)
# Generate master seed
master_seed = Bip39SeedGenerator(case["master_mnemonic"]).Generate(case["master_passphrase"])
# Derive child using BIP85
path, child_mnemonic, entropy64, truncated_entropy = pyhdwallet.bip85_derive_child_mnemonic(
master_seed,
case["child_words"],
case["index"]
)
vector = {
"description": case["description"],
"master_mnemonic": case["master_mnemonic"],
"master_passphrase": case["master_passphrase"],
"child_words": case["child_words"],
"index": case["index"],
"bip85_path": path,
"expected_entropy64_hex": entropy64.hex(),
"expected_entropy_truncated_hex": truncated_entropy.hex(),
"expected_child_mnemonic": child_mnemonic,
}
vectors.append(vector)
print(f"Path: {path}")
print(f"Entropy64: {entropy64.hex()}")
print(f"Truncated: {truncated_entropy.hex()}")
print(f"Child mnemonic: {child_mnemonic}")
print("\n" + "=" * 80)
print("\nJSON output for vectors.json:\n")
print(json.dumps(vectors, indent=2))

View File

@@ -89,6 +89,91 @@ def test_address_derivation_integrity(vectors):
)
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

View File

@@ -478,5 +478,51 @@
}
}
}
],
"bip85": [
{
"description": "BIP85 test case 1: 12-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 12,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/12'/0'",
"expected_entropy64_hex": "ac98dac5d4f4ebad6056682ac95eb9ad9ba94fb68e96848264dad0b4357d002e41b3dd7a4c6f4ebc234be6938495840a73f59e9ba0e8e5c5208c94e6df2d7709",
"expected_entropy_truncated_hex": "ac98dac5d4f4ebad6056682ac95eb9ad",
"expected_child_mnemonic": "prosper short ramp prepare exchange stove life snack client enough purpose fold"
},
{
"description": "BIP85 test case 2: 18-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 18,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/18'/0'",
"expected_entropy64_hex": "fc039f51d67ed7dfd01552f27de28887cf3e58655153e44b023d37578321f7083241970730e522d3f20b38a5296c5e51e57e0429546629704a09c6d1e2d10829",
"expected_entropy_truncated_hex": "fc039f51d67ed7dfd01552f27de28887cf3e58655153e44b",
"expected_child_mnemonic": "winter brother stamp provide uniform useful doctor prevent venue upper peasant auto view club next clerk tone fox"
},
{
"description": "BIP85 test case 3: 24-word child, no passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "",
"child_words": 24,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/24'/0'",
"expected_entropy64_hex": "d5a9cb46670566c4246b6e7af22e1dfc3668744ed831afea7ce2beea44e34e23e348e86091f24394f4be6253a7d5d24b91b1c4e0863b296e9e541e8018288897",
"expected_entropy_truncated_hex": "d5a9cb46670566c4246b6e7af22e1dfc3668744ed831afea7ce2beea44e34e23",
"expected_child_mnemonic": "stick exact spice sock filter ginger museum horse kit multiply manual wear grief demand derive alert quiz fault december lava picture immune decade jaguar"
},
{
"description": "BIP85 test case 4: 12-word child, WITH passphrase",
"master_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"master_passphrase": "TREZOR",
"child_words": 12,
"index": 0,
"bip85_path": "m/83696968'/39'/0'/12'/0'",
"expected_entropy64_hex": "2b1d7c4f311137fa95f6302e64cdb88584d52b51b57d0430ee68e148b82baa3f8c40316397eb404f4573bc0c8e5c4bc14e4aa5f0f472a9d3587f494f1f7b3684",
"expected_entropy_truncated_hex": "2b1d7c4f311137fa95f6302e64cdb885",
"expected_child_mnemonic": "climb typical because giraffe beach wool fit ship common chapter hotel arm"
}
]
}