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:
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pyhdwallet v1.0.5 (Python 3.11+)
|
||||
pyhdwallet v1.0.6 (Python 3.11+)
|
||||
|
||||
Commands:
|
||||
- fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint
|
||||
@@ -15,7 +15,7 @@ Behavior (current):
|
||||
- ZIP password is prompted via getpass; if echo-hiding fails, fallback to input() with loud warning.
|
||||
- When stdout is not a TTY, refuse to print sensitive data unless --force is provided.
|
||||
|
||||
Recover input policy (v1.0.5):
|
||||
Recover input policy (v1.0.6):
|
||||
- Seed recovery removed (mnemonic only).
|
||||
- No plaintext mnemonic via CLI args.
|
||||
- Use either:
|
||||
@@ -274,6 +274,135 @@ def get_master_fingerprint(seed_bytes: bytes) -> str:
|
||||
h160 = Hash160.QuickDigest(pubkey_bytes)
|
||||
return h160[:4].hex().upper()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# BIP85 helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def bip85_path(words: int, index: int, language: int = 0) -> str:
|
||||
"""
|
||||
Build BIP85 derivation path for BIP39 application.
|
||||
|
||||
BIP85 path format: m/83696968'/39'/language'/words'/index'
|
||||
For English (language=0): m/83696968'/39'/0'/words'/index'
|
||||
|
||||
Args:
|
||||
words: Child mnemonic word count (12/15/18/21/24)
|
||||
index: Derivation index
|
||||
language: BIP39 language (0=English, currently only 0 supported)
|
||||
|
||||
Returns:
|
||||
BIP85 path string
|
||||
"""
|
||||
if words not in {12, 15, 18, 21, 24}:
|
||||
raise ValueError(f"Invalid word count for BIP85: {words}")
|
||||
if index < 0:
|
||||
raise ValueError(f"BIP85 index must be >= 0, got {index}")
|
||||
return f"m/83696968'/39'/{language}'/{words}'/{index}'"
|
||||
|
||||
|
||||
def bip85_derive_entropy(seed_bytes: bytes, path: str) -> bytes:
|
||||
"""
|
||||
Derive 64-byte BIP85 entropy from master seed using specified path.
|
||||
|
||||
BIP85 spec:
|
||||
1. Derive BIP32 extended private key at path
|
||||
2. Extract 32-byte private key k
|
||||
3. HMAC-SHA512(key="bip-entropy-from-k", msg=k) -> 64 bytes
|
||||
|
||||
Args:
|
||||
seed_bytes: Master BIP39 seed (typically 64 bytes)
|
||||
path: BIP85 derivation path
|
||||
|
||||
Returns:
|
||||
64-byte entropy
|
||||
"""
|
||||
from bip_utils import Bip32Slip10Secp256k1
|
||||
import hmac
|
||||
|
||||
# Derive BIP32 node at path
|
||||
master = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
|
||||
child_node = master.DerivePath(path)
|
||||
|
||||
# Extract 32-byte private key
|
||||
privkey_bytes = child_node.PrivateKey().Raw().ToBytes()
|
||||
if len(privkey_bytes) != 32:
|
||||
raise ValueError(f"BIP85: Expected 32-byte private key, got {len(privkey_bytes)}")
|
||||
|
||||
# HMAC-SHA512 with specific key
|
||||
hmac_key = b"bip-entropy-from-k"
|
||||
entropy64 = hmac.digest(hmac_key, privkey_bytes, hashlib.sha512)
|
||||
|
||||
if len(entropy64) != 64:
|
||||
raise ValueError(f"BIP85: HMAC output should be 64 bytes, got {len(entropy64)}")
|
||||
|
||||
return entropy64
|
||||
|
||||
|
||||
def bip85_entropy_to_mnemonic(entropy64: bytes, words: int) -> str:
|
||||
"""
|
||||
Convert BIP85 64-byte entropy to BIP39 mnemonic.
|
||||
|
||||
Truncates entropy64 to required length for specified word count:
|
||||
- 12 words: 16 bytes (128 bits)
|
||||
- 15 words: 20 bytes (160 bits)
|
||||
- 18 words: 24 bytes (192 bits)
|
||||
- 21 words: 28 bytes (224 bits)
|
||||
- 24 words: 32 bytes (256 bits)
|
||||
|
||||
Args:
|
||||
entropy64: 64-byte BIP85 entropy
|
||||
words: Target mnemonic word count
|
||||
|
||||
Returns:
|
||||
BIP39 English mnemonic string
|
||||
"""
|
||||
from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39EntropyGenerator
|
||||
|
||||
words_to_bytes = {12: 16, 15: 20, 18: 24, 21: 28, 24: 32}
|
||||
|
||||
if words not in words_to_bytes:
|
||||
raise ValueError(f"Invalid word count: {words}")
|
||||
|
||||
if len(entropy64) != 64:
|
||||
raise ValueError(f"BIP85: Expected 64-byte entropy, got {len(entropy64)}")
|
||||
|
||||
# Truncate to required length
|
||||
required_bytes = words_to_bytes[words]
|
||||
truncated_entropy = entropy64[:required_bytes]
|
||||
|
||||
# Generate mnemonic from truncated entropy
|
||||
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(truncated_entropy)
|
||||
|
||||
return str(mnemonic)
|
||||
|
||||
|
||||
def bip85_derive_child_mnemonic(
|
||||
master_seed_bytes: bytes,
|
||||
child_words: int,
|
||||
index: int
|
||||
) -> Tuple[str, str, bytes, bytes]:
|
||||
"""
|
||||
Complete BIP85 derivation: master seed -> child mnemonic.
|
||||
|
||||
Args:
|
||||
master_seed_bytes: Master BIP39 seed
|
||||
child_words: Child mnemonic word count (12/15/18/21/24)
|
||||
index: BIP85 index
|
||||
|
||||
Returns:
|
||||
Tuple of (bip85_path, child_mnemonic, entropy64, truncated_entropy)
|
||||
"""
|
||||
path = bip85_path(child_words, index, language=0)
|
||||
entropy64 = bip85_derive_entropy(master_seed_bytes, path)
|
||||
|
||||
# Calculate truncated entropy for test vectors
|
||||
words_to_bytes = {12: 16, 15: 20, 18: 24, 21: 28, 24: 32}
|
||||
truncated_entropy = entropy64[:words_to_bytes[child_words]]
|
||||
|
||||
child_mnemonic = bip85_entropy_to_mnemonic(entropy64, child_words)
|
||||
|
||||
return path, child_mnemonic, entropy64, truncated_entropy
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PGP helpers (PGPy)
|
||||
@@ -315,7 +444,7 @@ def pgp_fingerprint(armored: str) -> str:
|
||||
|
||||
|
||||
def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "pyhdwallet/1.0.5"})
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "pyhdwallet/1.0.6"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read()
|
||||
text = data.decode("utf-8", errors="strict")
|
||||
@@ -610,7 +739,7 @@ def build_payload_gen(
|
||||
addresses: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"version": "pyhdwallet v1.0.5",
|
||||
"version": "pyhdwallet v1.0.6",
|
||||
"purpose": "generated mnemonic backup",
|
||||
"mnemonic": mnemonic,
|
||||
"passphrase_used": passphrase_used,
|
||||
@@ -634,7 +763,7 @@ def build_payload_recover(
|
||||
addresses: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"version": "pyhdwallet v1.0.5",
|
||||
"version": "pyhdwallet v1.0.6",
|
||||
"purpose": "recovery payload",
|
||||
"master_fingerprint": fp,
|
||||
"solana_profile": sol_profile,
|
||||
@@ -658,10 +787,56 @@ def deterministic_inner_name(is_encrypted: bool, ts: str) -> str:
|
||||
return f"encrypted_wallet_{ts}.asc"
|
||||
return f"test_wallet_{ts}.json"
|
||||
|
||||
|
||||
def deterministic_zip_name(prefix: str, ts: str) -> str:
|
||||
return f"{prefix}_{ts}.zip"
|
||||
|
||||
def build_payload_gen_child(
|
||||
master_fingerprint: str,
|
||||
master_word_count: int,
|
||||
child_mnemonic: str,
|
||||
child_word_count: int,
|
||||
index: int,
|
||||
bip85_path: str,
|
||||
passphrase_used: bool,
|
||||
passphrase_hint: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build JSON payload for gen-child output.
|
||||
|
||||
Args:
|
||||
master_fingerprint: Master seed fingerprint (8 hex chars)
|
||||
master_word_count: Master mnemonic word count
|
||||
child_mnemonic: Derived child mnemonic
|
||||
child_word_count: Child mnemonic word count
|
||||
index: BIP85 index
|
||||
bip85_path: Full BIP85 derivation path
|
||||
passphrase_used: Whether master passphrase was used
|
||||
passphrase_hint: Optional passphrase hint
|
||||
|
||||
Returns:
|
||||
Payload dict ready for JSON serialization
|
||||
"""
|
||||
return {
|
||||
"version": "pyhdwallet v1.0.6",
|
||||
"purpose": "BIP85 derived child mnemonic",
|
||||
"master_fingerprint": master_fingerprint,
|
||||
"master_word_count": master_word_count,
|
||||
"passphrase_used": passphrase_used,
|
||||
"passphrase_hint": passphrase_hint,
|
||||
"bip85_metadata": {
|
||||
"path": bip85_path,
|
||||
"index": index,
|
||||
"language": 0, # English-only in v1
|
||||
"child_word_count": child_word_count,
|
||||
},
|
||||
"child_mnemonic": child_mnemonic,
|
||||
"interoperability_note": (
|
||||
"This child mnemonic is derived using BIP85 and is interoperable "
|
||||
"with any BIP85-compatible tool given the same master mnemonic, "
|
||||
"passphrase, and derivation parameters."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Commands
|
||||
@@ -881,6 +1056,160 @@ def cmd_recover(args) -> None:
|
||||
seed_bytes = None
|
||||
passphrase = None
|
||||
|
||||
def cmd_gen_child(args) -> None:
|
||||
"""
|
||||
Derive child BIP39 mnemonic from master using BIP85.
|
||||
|
||||
Offline command - runs under NetworkGuard.
|
||||
Input: master mnemonic via --interactive or --mnemonic-stdin
|
||||
Output: derived child mnemonic + metadata
|
||||
"""
|
||||
with NetworkGuard("gen-child"):
|
||||
require_for_offline([]) # Only need bip_utils, no chain-specific deps
|
||||
|
||||
# Validate passphrase-hint requires passphrase
|
||||
if args.passphrase_hint and not args.passphrase:
|
||||
raise ValueError("--passphrase-hint requires --passphrase")
|
||||
|
||||
# Validate index
|
||||
if args.index < 0:
|
||||
raise ValueError(f"--index must be >= 0, got {args.index}")
|
||||
|
||||
if args.off_screen:
|
||||
print("⚠️ Off-screen mode enabled: Child mnemonic will not be printed to stdout.")
|
||||
else:
|
||||
# Print warning banner (similar to gen)
|
||||
warn_gen_stdout_banner()
|
||||
require_tty_or_force(args.force, "derived child mnemonic to stdout")
|
||||
|
||||
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
|
||||
|
||||
# Read master mnemonic
|
||||
if args.interactive:
|
||||
print("📍 Enter MASTER mnemonic (word-by-word):")
|
||||
master_mnemonic = interactive_mnemonic_word_by_word()
|
||||
elif args.mnemonic_stdin:
|
||||
master_mnemonic = _read_stdin_all()
|
||||
if not master_mnemonic:
|
||||
raise ValueError("Empty stdin for --mnemonic-stdin")
|
||||
else:
|
||||
raise ValueError("Missing input mode (use --interactive / --mnemonic-stdin)")
|
||||
|
||||
# Validate master mnemonic
|
||||
if not Bip39MnemonicValidator().IsValid(master_mnemonic):
|
||||
raise ValueError("Invalid master BIP39 mnemonic")
|
||||
|
||||
# Count master words
|
||||
master_word_count = len(master_mnemonic.strip().split())
|
||||
|
||||
# Get master passphrase if requested
|
||||
passphrase = ""
|
||||
if args.passphrase:
|
||||
passphrase = getpass.getpass("Enter master BIP39 passphrase (hidden): ")
|
||||
|
||||
# Derive master seed
|
||||
master_seed_bytes = Bip39SeedGenerator(master_mnemonic).Generate(passphrase)
|
||||
master_fp = get_master_fingerprint(master_seed_bytes)
|
||||
|
||||
# BIP85 derivation
|
||||
print(f"📍 Deriving BIP85 child mnemonic ({args.words} words, index={args.index})...")
|
||||
bip85_path_str, child_mnemonic, entropy64, truncated_entropy = bip85_derive_child_mnemonic(
|
||||
master_seed_bytes,
|
||||
args.words,
|
||||
args.index
|
||||
)
|
||||
|
||||
# Print metadata and child mnemonic (unless off-screen)
|
||||
if not args.off_screen:
|
||||
print("\n" + "=" * 80)
|
||||
print("BIP85 DERIVED CHILD MNEMONIC")
|
||||
print("=" * 80)
|
||||
print(f"Master Fingerprint: {master_fp}")
|
||||
print(f"Master Word Count: {master_word_count}")
|
||||
print(f"Master Passphrase Used: {'Yes' if passphrase else 'No'}")
|
||||
if args.passphrase_hint:
|
||||
print(f"Passphrase Hint: {args.passphrase_hint}")
|
||||
print(f"\nBIP85 Derivation Path: {bip85_path_str}")
|
||||
print(f"Child Index: {args.index}")
|
||||
print(f"Child Word Count: {args.words}")
|
||||
print(f"\nDerived Child Mnemonic:\n{child_mnemonic}")
|
||||
print("\n" + "=" * 80)
|
||||
print("INTEROPERABILITY NOTE:")
|
||||
print("This child mnemonic is reproducible with any BIP85-compatible tool")
|
||||
print("given the same master mnemonic, passphrase, and derivation parameters.")
|
||||
print("=" * 80 + "\n")
|
||||
else:
|
||||
# In off-screen mode, print metadata only
|
||||
print(f"Master Fingerprint: {master_fp}")
|
||||
print(f"Master Word Count: {master_word_count}")
|
||||
print(f"Master Passphrase Used: {'Yes' if passphrase else 'No'}")
|
||||
print(f"BIP85 Path: {bip85_path_str}")
|
||||
print(f"Child Index: {args.index}")
|
||||
print(f"Child Word Count: {args.words}")
|
||||
print("✅ Derivation complete (child mnemonic suppressed in off-screen mode)")
|
||||
|
||||
# File output if requested
|
||||
if args.file:
|
||||
require_for_zip()
|
||||
ts = utc_timestamp_compact()
|
||||
is_pgp = bool(args.pgp_pubkey_file)
|
||||
|
||||
# Build payload
|
||||
payload = build_payload_gen_child(
|
||||
master_fingerprint=master_fp,
|
||||
master_word_count=master_word_count,
|
||||
child_mnemonic=child_mnemonic,
|
||||
child_word_count=args.words,
|
||||
index=args.index,
|
||||
bip85_path=bip85_path_str,
|
||||
passphrase_used=bool(passphrase),
|
||||
passphrase_hint=args.passphrase_hint or "",
|
||||
)
|
||||
|
||||
wallet_dir = Path(args.wallet_location) if args.wallet_location else default_wallet_dir()
|
||||
ensure_dir(wallet_dir)
|
||||
|
||||
if is_pgp:
|
||||
# PGP-encrypt inner content
|
||||
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
|
||||
pub = f.read()
|
||||
fpr = pgp_fingerprint(pub)
|
||||
require_fingerprint_match(fpr, args.expected_fingerprint, "encrypt(gen-child)")
|
||||
|
||||
inner_name = f"bip85_child_{ts}.asc"
|
||||
inner_text = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags)
|
||||
inner_bytes = inner_text.encode("utf-8")
|
||||
zip_prefix = "bip85_child_encrypted"
|
||||
else:
|
||||
# Plain JSON inner content
|
||||
inner_name = f"bip85_child_{ts}.json"
|
||||
inner_text = json.dumps(payload, indent=2, ensure_ascii=False)
|
||||
inner_bytes = inner_text.encode("utf-8")
|
||||
zip_prefix = "bip85_child"
|
||||
|
||||
# Get ZIP password
|
||||
if args.zip_password_mode == "prompt":
|
||||
zip_password = prompt_zip_password_hidden_or_warn()
|
||||
else:
|
||||
zip_password = gen_base58_password(args.zip_password_len)
|
||||
if args.show_generated_password:
|
||||
print(f"ZIP password (auto-generated, base58): {zip_password}", file=sys.stderr)
|
||||
|
||||
# Write AES-encrypted ZIP
|
||||
zip_name = deterministic_zip_name(zip_prefix, ts)
|
||||
zip_path = wallet_dir / zip_name
|
||||
write_aes_zip(zip_path, inner_name, inner_bytes, zip_password)
|
||||
|
||||
print(f"✅ Wrote AES-encrypted ZIP: {zip_path}")
|
||||
print(f" Contains: {inner_name}")
|
||||
|
||||
# Clear sensitive data
|
||||
if args.off_screen:
|
||||
master_mnemonic = None
|
||||
master_seed_bytes = None
|
||||
passphrase = None
|
||||
child_mnemonic = None
|
||||
|
||||
|
||||
def cmd_test(args) -> None:
|
||||
with NetworkGuard("test"):
|
||||
@@ -915,8 +1244,9 @@ def cmd_test(args) -> None:
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="pyhdwallet v1.0.5 - Secure HD Wallet Tool")
|
||||
parser.add_argument("--version", action="version", version="pyhdwallet v1.0.5")
|
||||
parser = argparse.ArgumentParser(description="pyhdwallet v1.0.6 - Secure HD Wallet Tool")
|
||||
parser.add_argument("--version", action="version", version="pyhdwallet v1.0.6")
|
||||
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
p_fetch = sub.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL (online)")
|
||||
@@ -983,6 +1313,54 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
p_rec = sub.add_parser("recover", help="Recover addresses from mnemonic (offline)")
|
||||
add_common(p_rec)
|
||||
|
||||
p_child = sub.add_parser("gen-child", help="Derive child BIP39 mnemonic from master using BIP85 (offline)")
|
||||
|
||||
# Input modes (mutually exclusive, required)
|
||||
g_child = p_child.add_mutually_exclusive_group(required=True)
|
||||
g_child.add_argument("--interactive", action="store_true",
|
||||
help="Guided master mnemonic entry (English-only, per-word validation)")
|
||||
g_child.add_argument("--mnemonic-stdin", dest="mnemonic_stdin", action="store_true",
|
||||
help="Read master BIP39 mnemonic from stdin (non-interactive)")
|
||||
|
||||
# Child parameters
|
||||
p_child.add_argument("--words", type=int, choices=[12, 15, 18, 21, 24], default=12,
|
||||
help="Word count for derived child mnemonic (default: 12)")
|
||||
p_child.add_argument("--index", type=int, default=0,
|
||||
help="BIP85 derivation index (default: 0)")
|
||||
|
||||
# Passphrase
|
||||
p_child.add_argument("--passphrase", action="store_true",
|
||||
help="Prompt for master BIP39 passphrase interactively")
|
||||
p_child.add_argument("--passphrase-hint", default="",
|
||||
help="Hint only; never store the passphrase itself")
|
||||
|
||||
# Output modes
|
||||
p_child.add_argument("--force", action="store_true",
|
||||
help="Allow printing sensitive output even when stdout is not a TTY (dangerous).")
|
||||
p_child.add_argument("--off-screen", action="store_true",
|
||||
help="Suppress printing derived child mnemonic to stdout.")
|
||||
|
||||
# File output
|
||||
p_child.add_argument("--file", action="store_true",
|
||||
help="Write output to AES-encrypted ZIP in ./.wallet (deterministic name).")
|
||||
p_child.add_argument("--wallet-location", default="",
|
||||
help="Override default ./.wallet folder for --file output.")
|
||||
p_child.add_argument("--zip-password-mode", choices=["prompt", "auto"], default="prompt",
|
||||
help="ZIP password mode: prompt or auto-generate base58.")
|
||||
p_child.add_argument("--zip-password-len", type=int, default=12,
|
||||
help="Password length when --zip-password-mode auto is used.")
|
||||
p_child.add_argument("--show-generated-password", action="store_true",
|
||||
help="When using --zip-password-mode auto, print generated password to stderr.")
|
||||
|
||||
# PGP encryption (optional)
|
||||
p_child.add_argument("--pgp-pubkey-file", default=None,
|
||||
help="Path to ASCII-armored PGP public key for inner payload encryption")
|
||||
p_child.add_argument("--pgp-ignore-usage-flags", action="store_true",
|
||||
help="Ignore PGP key usage flags when encrypting")
|
||||
p_child.add_argument("--expected-fingerprint", default="",
|
||||
help="Refuse if PGP recipient key fingerprint does not match.")
|
||||
|
||||
g = p_rec.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--interactive", action="store_true", help="Guided mnemonic entry (English-only, per-word validation)")
|
||||
g.add_argument("--mnemonic-stdin", dest="mnemonic_stdin", action="store_true", help="Read BIP39 mnemonic from stdin (non-interactive)")
|
||||
@@ -1007,6 +1385,9 @@ def main() -> None:
|
||||
if args.cmd == "recover":
|
||||
cmd_recover(args)
|
||||
return
|
||||
if args.cmd == "gen-child":
|
||||
cmd_gen_child(args)
|
||||
return
|
||||
if args.cmd == "test":
|
||||
cmd_test(args)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user