Add gen subcommand for BIP39 mnemonic generation with dice entropy, master fingerprint to outputs, and test subcommand with Trezor vector

This commit is contained in:
LC
2026-01-04 16:45:18 +00:00
parent 4d4b94a5d5
commit 68460d40e7

View File

@@ -4,12 +4,14 @@ hdwallet_recovery.py (Python 3.12)
Subcommands:
- fetchkey (online): download ASCII-armored PGP pubkey, print SHA256 + fingerprint
- derive (default): derive ADDRESSES ONLY for ETH/SOL/BTC
- gen: generate BIP39 mnemonic (12/15/18/24 words) with optional passphrase and dice entropy
- recover (default): derive ADDRESSES ONLY for ETH/SOL/BTC from mnemonic/seed
- optionally encrypt a secret payload using a PGP public key
Security invariant:
- derive never performs network I/O
- recover never performs network I/O
- fetchkey refuses mnemonic/seed/passphrase inputs
- gen generates secure mnemonics compliant with BIP39
"""
import argparse
@@ -47,6 +49,14 @@ def require_for_derive(export_private: bool, chains: List[str]):
_require("base58", "base58")
def get_master_fingerprint(seed_bytes: bytes) -> str:
from bip_utils import Bip32Slip10Secp256k1, Hash160
master = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
pubkey_bytes = master.PublicKey().RawCompressed().ToBytes()
hash160 = Hash160.QuickDigest(pubkey_bytes)
return hash160[:4].hex().upper()
# -----------------------------------------------------------------------------
# PGP helpers (PGPy)
# -----------------------------------------------------------------------------
@@ -267,7 +277,88 @@ def format_addresses_human(result: Dict[str, Any]) -> str:
return "\n".join(lines)
def cmd_derive(args):
def cmd_gen(args):
require_for_derive(False, args.chains)
import secrets
from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator
words_to_entropy = {12: 128, 15: 160, 18: 192, 24: 256}
entropy_bits = words_to_entropy[args.words]
entropy_bytes_len = entropy_bits // 8
if args.dice_rolls:
rolls = args.dice_rolls.strip().split()
if not rolls or not all(r.isdigit() and 1 <= int(r) <= 6 for r in rolls):
raise ValueError("--dice-rolls must be space-separated integers 1-6")
dice_bytes = ' '.join(rolls).encode('utf-8')
crypto_bytes = secrets.token_bytes(entropy_bytes_len)
combined = dice_bytes + crypto_bytes
entropy = hashlib.sha256(combined).digest()[:entropy_bytes_len]
else:
entropy = secrets.token_bytes(entropy_bytes_len)
generator = Bip39MnemonicGenerator(Bip39Languages.ENGLISH)
mnemonic = generator.FromEntropy(entropy)
print(f"📍 Generated {args.words}-word BIP39 mnemonic...")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "")
fingerprint_with = get_master_fingerprint(seed_bytes)
fingerprint_without = None
if args.passphrase:
seed_without = Bip39SeedGenerator(mnemonic).Generate("")
fingerprint_without = get_master_fingerprint(seed_without)
result = derive_addresses_and_maybe_secrets(seed_bytes, args.chains, args.addresses, False)
if args.output == "json":
data = {
"mnemonic": mnemonic,
"passphrase_set": bool(args.passphrase),
"master_fingerprint": fingerprint_with,
"addresses": result["addresses"]
}
if fingerprint_without:
data["master_fingerprint_no_passphrase"] = fingerprint_without
if args.dice_rolls:
data["dice_rolls_used"] = True
out_text = json.dumps(data, indent=2)
else:
fp_text = f"Master Fingerprint: {fingerprint_with}"
if fingerprint_without:
fp_text += f"\nMaster Fingerprint (no passphrase): {fingerprint_without}"
dice_note = "\nDice rolls used for extra entropy: Yes" if args.dice_rolls else ""
out_text = f"Generated Mnemonic ({args.words} words):\n{mnemonic}\n\nPassphrase set: {bool(args.passphrase)}\n{fp_text}{dice_note}\n\n" + format_addresses_human(result)
print(out_text)
if args.file:
with open(args.file, "w", encoding="utf-8") as f:
f.write(out_text)
print(f"✅ Saved to {args.file}")
if args.pgp_pubkey_file:
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
pgp_pub = f.read()
payload = {
"version": "v4",
"purpose": "hdwallet generated mnemonic",
"mnemonic": mnemonic,
"passphrase": args.passphrase or "",
"master_fingerprint": fingerprint_with,
"dice_rolls_used": bool(args.dice_rolls),
"addresses": result["addresses"]
}
if fingerprint_without:
payload["master_fingerprint_no_passphrase"] = fingerprint_without
armored = pgp_encrypt_ascii_armored(pgp_pub, payload)
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
print(armored)
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
def cmd_recover(args):
require_for_derive(args.export_private, args.chains)
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
@@ -303,14 +394,26 @@ def cmd_derive(args):
raise ValueError(f"Seed must be 64 bytes (128 hex chars), got {len(b)}")
seed_bytes = b
print(f"📍 Deriving {len(args.chains)} chain(s), {args.addresses} address(es) each...")
fingerprint_with = get_master_fingerprint(seed_bytes)
fingerprint_without = None
if args.passphrase and mnemonic:
seed_without = Bip39SeedGenerator(mnemonic).Generate("")
fingerprint_without = get_master_fingerprint(seed_without)
print(f"📍 Recovering {len(args.chains)} chain(s), {args.addresses} address(es) each...")
result = derive_addresses_and_maybe_secrets(seed_bytes, args.chains, args.addresses, args.export_private)
# Always print addresses-only output (safe)
if args.output == "json":
out_text = json.dumps({"addresses": result["addresses"]}, indent=2)
data = {"addresses": result["addresses"], "master_fingerprint": fingerprint_with}
if fingerprint_without:
data["master_fingerprint_no_passphrase"] = fingerprint_without
out_text = json.dumps(data, indent=2)
else:
out_text = format_addresses_human({"addresses": result["addresses"]})
fp_text = f"Master Fingerprint: {fingerprint_with}"
if fingerprint_without:
fp_text += f"\nMaster Fingerprint (no passphrase): {fingerprint_without}"
out_text = fp_text + "\n\n" + format_addresses_human({"addresses": result["addresses"]})
print(out_text)
if args.file:
@@ -326,7 +429,10 @@ def cmd_derive(args):
payload: Dict[str, Any] = {
"version": "v4",
"purpose": "hdwallet recovery secret payload",
"master_fingerprint": fingerprint_with,
}
if fingerprint_without:
payload["master_fingerprint_no_passphrase"] = fingerprint_without
# Always include mnemonic/seed_hex if present (your requirement)
if mnemonic:
@@ -349,12 +455,67 @@ def cmd_derive(args):
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
def cmd_test(args):
require_for_derive(False, ["ethereum"]) # minimal
from bip_utils import Bip39SeedGenerator, Bip39MnemonicValidator
print("🧪 Running Trezor BIP39/BIP32 test vectors...")
# Trezor vector with passphrase
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
passphrase = "crypto"
expected_seed_hex = "92c58d3f4fe52f0111d314f3fa8f10ba498751c37e7c36475c2a5b60145b29708576e11bf6c5c46efac1add0cc26e07c69eb65476742082f9c6460f132181cfe"
expected_fp = "AECCC350"
tests = [
{
"mnemonic": mnemonic,
"passphrase": passphrase,
"expected_seed_hex": expected_seed_hex,
"expected_fp": expected_fp
}
]
all_passed = True
for i, test in enumerate(tests, 1):
print(f"\nTest {i}: With passphrase '{test['passphrase']}'")
if not Bip39MnemonicValidator().IsValid(test["mnemonic"]):
print(f"❌ Invalid mnemonic")
all_passed = False
continue
seed_bytes = Bip39SeedGenerator(test["mnemonic"]).Generate(test["passphrase"])
seed_hex = seed_bytes.hex()
if seed_hex != test["expected_seed_hex"]:
print(f"❌ Seed mismatch: got {seed_hex}, expected {test['expected_seed_hex']}")
all_passed = False
else:
print(f"✅ Seed correct")
fp = get_master_fingerprint(seed_bytes)
if fp != test["expected_fp"]:
print(f"❌ Fingerprint mismatch: got {fp}, expected {test['expected_fp']}")
all_passed = False
else:
print(f"✅ Fingerprint correct: {fp}")
if all_passed:
print("\n🎉 All tests passed!")
else:
print("\n❌ Some tests failed!")
sys.exit(1)
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="HD wallet recovery (addresses only) + fetchkey helper")
parser = argparse.ArgumentParser(description="HD wallet recovery + fetchkey + mnemonic generation")
subparsers = parser.add_subparsers(dest="cmd")
p_fetch = subparsers.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL")
@@ -362,18 +523,30 @@ def main():
p_fetch.add_argument("--out", help="Write key to file (recommended)", default=None)
p_fetch.add_argument("--timeout", type=int, default=15, help="HTTP timeout seconds")
# derive mode args (default when no subcommand is given)
p_test = subparsers.add_parser("test", help="Run tests against Trezor BIP39/BIP32 vectors")
p_gen = subparsers.add_parser("gen", help="Generate BIP39 mnemonic with optional dice entropy")
p_recover = subparsers.add_parser("recover", help="Derive addresses from mnemonic/seed")
# Common args for gen and recover
for p in [p_gen, p_recover, parser]: # parser for default recover
p.add_argument("--passphrase", default="", help="BIP39 passphrase (optional)")
p.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
default=["ethereum", "solana", "bitcoin"])
p.add_argument("--addresses", type=int, default=5)
p.add_argument("--output", choices=["text", "json"], default="text")
p.add_argument("--file", help="Save output to file", default=None)
p.add_argument("--pgp-pubkey-file", help="ASCII-armored PGP public key file to encrypt payload", default=None)
# Gen specific
p_gen.add_argument("--words", type=int, choices=[12,15,18,24], default=12, help="Number of words for mnemonic")
p_gen.add_argument("--dice-rolls", help="Space-separated die rolls (1-6) for extra entropy")
# Recover specific
parser.add_argument("--mnemonic", help="BIP39 mnemonic (12/24 words)")
parser.add_argument("--seed", help="64-byte seed hex (128 hex chars)")
parser.add_argument("--interactive", action="store_true", help="Prompt for input via stdin")
parser.add_argument("--passphrase", default="", help="BIP39 passphrase (optional)")
parser.add_argument("--passphrase-hint", default="", help="Hint/reminder for passphrase (stored only in encrypted payload when --export-private)")
parser.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
default=["ethereum", "solana", "bitcoin"])
parser.add_argument("--addresses", type=int, default=5)
parser.add_argument("--output", choices=["text", "json"], default="text")
parser.add_argument("--file", help="Save address output to file", default=None)
parser.add_argument("--pgp-pubkey-file", help="ASCII-armored PGP public key file to encrypt secrets", default=None)
parser.add_argument("--export-private", action="store_true",
help="Encrypt derived private keys into the PGP payload (never printed). Requires --pgp-pubkey-file.")
@@ -389,16 +562,29 @@ def main():
getattr(args, "passphrase_hint", ""),
getattr(args, "pgp_pubkey_file", None),
getattr(args, "export_private", False),
getattr(args, "words", 12) != 12,
getattr(args, "dice_rolls", None),
])
if forbidden:
raise ValueError("fetchkey mode must not be used with mnemonic/seed/passphrase/export-private options")
raise ValueError("fetchkey mode must not be used with mnemonic/seed/passphrase/export-private/words/dice-rolls options")
cmd_fetchkey(args.url, args.out, args.timeout)
return
if not (args.mnemonic or args.seed or args.interactive):
raise ValueError("Provide --mnemonic or --seed or --interactive (or use subcommand fetchkey)")
elif args.cmd == "gen":
if any([getattr(args, "mnemonic", None), getattr(args, "seed", None), getattr(args, "interactive", False), getattr(args, "export_private", False), getattr(args, "passphrase_hint", "")]):
raise ValueError("gen mode must not be used with mnemonic/seed/interactive/export-private/passphrase-hint options")
cmd_gen(args)
return
cmd_derive(args)
elif args.cmd == "test":
cmd_test(args)
return
# recover (default or explicit)
if not (args.mnemonic or args.seed or args.interactive):
raise ValueError("Provide --mnemonic or --seed or --interactive (or use subcommand gen/fetchkey)")
cmd_recover(args)
except Exception as e:
print(f"❌ Error: {e}", file=sys.stderr)