#!/usr/bin/env python3 """ hdwallet_recovery.py (Python 3.12) Subcommands: - fetchkey (online): download ASCII-armored PGP pubkey, print SHA256 + fingerprint - 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: - recover never performs network I/O - fetchkey refuses mnemonic/seed/passphrase inputs - gen generates secure mnemonics compliant with BIP39 Solana derivation (Mode "A"): - Derive accounts as: m/44'/501'/{i}'/0' - Use SLIP-0010 ed25519 hardened derivation from the BIP39 seed. [page:0][page:1] """ import argparse import sys import json import getpass import hashlib import hmac import urllib.request from dataclasses import dataclass from typing import Dict, List, Any, Optional, Tuple # ----------------------------------------------------------------------------- # Dependency checks # ----------------------------------------------------------------------------- def _require(mod: str, pkg: str): try: __import__(mod) except ImportError: print(f"❌ Missing dependency: {pkg}", file=sys.stderr) print(f"Install with: pip install {pkg}", file=sys.stderr) sys.exit(1) def require_for_fetchkey(): _require("pgpy", "PGPy") def require_for_derive(export_private: bool, chains: List[str]): _require("bip_utils", "bip-utils") _require("pgpy", "PGPy") # Needed to compute Solana pubkey/address + optional Phantom secret exports if "solana" in chains: _require("nacl", "PyNaCl") _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) # ----------------------------------------------------------------------------- def pgp_load_pubkey(armored: str): import pgpy key, _ = pgpy.PGPKey.from_blob(armored) if not key.is_public: raise ValueError("Provided key is not a public key") return key def pgp_fingerprint(armored: str) -> str: return pgp_load_pubkey(armored).fingerprint def pgp_encrypt_ascii_armored(pubkey_armored: str, payload: Dict[str, Any]) -> str: import pgpy pub_key = pgp_load_pubkey(pubkey_armored) msg = pgpy.PGPMessage.new(json.dumps(payload, indent=2)) enc = pub_key.encrypt(msg) return str(enc) # ----------------------------------------------------------------------------- # fetchkey (network allowed ONLY here) # ----------------------------------------------------------------------------- def fetch_ascii_armored_text(url: str, timeout: int = 15) -> str: req = urllib.request.Request(url, headers={"User-Agent": "hdwallet-recovery/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: data = resp.read() text = data.decode("utf-8", errors="strict") if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text: raise ValueError("Downloaded content does not look like an ASCII-armored PGP public key") return text def sha256_hex_text(s: str) -> str: return hashlib.sha256(s.encode("utf-8")).hexdigest() def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int): require_for_fetchkey() armored = fetch_ascii_armored_text(url, timeout=timeout) s256 = sha256_hex_text(armored) fpr = pgp_fingerprint(armored) if out_path: with open(out_path, "w", encoding="utf-8") as f: f.write(armored) print("✅ Downloaded PGP public key") print(f"URL: {url}") if out_path: print(f"Saved: {out_path}") print(f"SHA256: {s256}") print(f"Fingerprint: {fpr}") if not out_path: print("\n-----BEGIN DOWNLOADED KEY-----") print(armored.strip()) print("-----END DOWNLOADED KEY-----") # ----------------------------------------------------------------------------- # Derivation # ----------------------------------------------------------------------------- @dataclass class AddrOut: index: int path: str address: str @dataclass class BtcAddrOut: index: int path: str address_type: str address: str def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]: # Phantom-compatible 64-byte secret key = seed32 || pubkey32 (ed25519). from nacl.signing import SigningKey import base58 sk = SigningKey(seed32) pub32 = sk.verify_key.encode() secret64 = seed32 + pub32 return {"phantom_base58": base58.b58encode(secret64).decode("ascii")} # ---- SLIP-0010 ed25519 (matches micro-ed25519-hdkey behavior) ---- # Master key generation for ed25519 is: # I = HMAC-SHA512(key="ed25519 seed", data=seed) # k = I_L (32 bytes), c = I_R (32 bytes) # Child derivation (hardened only): # I = HMAC-SHA512(key=c_par, data=0x00 || k_par || ser32(i)) # k_i = I_L, c_i = I_R # This is per SLIP-0010. [page:0] _ED25519_SEED_KEY = b"ed25519 seed" def _hmac_sha512(key: bytes, data: bytes) -> bytes: return hmac.new(key, data, hashlib.sha512).digest() def _ser32(i: int) -> bytes: return i.to_bytes(4, "big", signed=False) def _parse_path_ed25519_hardened(path: str) -> List[int]: """ Parse BIP32-like path; for ed25519 we only support hardened indices. Accept both "m/44'/501'/0'/0'" and "m/44/501/0/0" by promoting to hardened, matching the "force hardened" convenience described by micro-ed25519-hdkey. [page:1] """ if not path.startswith("m/"): raise ValueError("Path must start with m/") out: List[int] = [] for comp in path[2:].split("/"): if comp == "": continue hardened = comp.endswith("'") n_str = comp[:-1] if hardened else comp if not n_str.isdigit(): raise ValueError(f"Invalid path component: {comp}") n = int(n_str) # Promote to hardened (ed25519 hardened-only). [page:0][page:1] n = (n | 0x80000000) out.append(n) return out def _slip10_ed25519_master(seed_bytes: bytes) -> Tuple[bytes, bytes]: I = _hmac_sha512(_ED25519_SEED_KEY, seed_bytes) return I[:32], I[32:] def _slip10_ed25519_ckd_priv(k_par: bytes, c_par: bytes, index_hardened: int) -> Tuple[bytes, bytes]: if (index_hardened & 0x80000000) == 0: raise ValueError("ed25519 SLIP-0010 supports hardened derivation only") # [page:0] data = b"\x00" + k_par + _ser32(index_hardened) I = _hmac_sha512(c_par, data) return I[:32], I[32:] def solana_seed32_from_bip39_seed_slip10(seed_bytes: bytes, path: str) -> bytes: """ Derive 32-byte ed25519 private key bytes (seed) from BIP39 seed bytes using SLIP-0010. [page:0] """ idxs = _parse_path_ed25519_hardened(path) k, c = _slip10_ed25519_master(seed_bytes) for i in idxs: k, c = _slip10_ed25519_ckd_priv(k, c, i) return k def derive_addresses_and_maybe_secrets(seed_bytes: bytes, chains: List[str], count: int, export_private: bool) -> Dict[str, Any]: from bip_utils import ( Bip44, Bip44Coins, Bip44Changes, Bip49, Bip49Coins, Bip84, Bip84Coins, ) out: Dict[str, Any] = {"addresses": {}} if export_private: out["secrets"] = {} # ETH (BIP44 m/44'/60'/0'/0/i) if "ethereum" in chains: root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM) ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) addrs: List[Dict[str, Any]] = [] secrets: List[Dict[str, Any]] = [] for i in range(count): node = ctx.AddressIndex(i) addrs.append({"index": i, "path": f"m/44'/60'/0'/0/{i}", "address": node.PublicKey().ToAddress()}) if export_private: secrets.append({ "index": i, "path": f"m/44'/60'/0'/0/{i}", "privkey_hex": node.PrivateKey().Raw().ToHex(), }) out["addresses"]["ethereum"] = addrs if export_private and secrets: out["secrets"]["ethereum"] = secrets # SOL (Mode "A": m/44'/501'/{i}'/0') derived via SLIP-0010 like micro-ed25519-hdkey. [page:0][page:1] if "solana" in chains: from nacl.signing import SigningKey import base58 addrs: List[Dict[str, Any]] = [] secrets: List[Dict[str, Any]] = [] for i in range(count): path = f"m/44'/501'/{i}'/0'" seed32 = solana_seed32_from_bip39_seed_slip10(seed_bytes, path) sk = SigningKey(seed32) pub32 = sk.verify_key.encode() address = base58.b58encode(pub32).decode("ascii") addrs.append({"index": i, "path": path, "address": address}) if export_private: secrets.append({ "index": i, "path": path, "phantom": _solana_phantom_export_from_seed32(seed32), }) out["addresses"]["solana"] = addrs if export_private and secrets: out["secrets"]["solana"] = secrets # BTC (addresses only even with --export-private) if "bitcoin" in chains: addrs: List[Dict[str, Any]] = [] r44 = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN) c44 = r44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) r49 = Bip49.FromSeed(seed_bytes, Bip49Coins.BITCOIN) c49 = r49.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) r84 = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN) c84 = r84.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) for i in range(count): n84 = c84.AddressIndex(i) n49 = c49.AddressIndex(i) n44 = c44.AddressIndex(i) addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": n84.PublicKey().ToAddress()}) addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": n49.PublicKey().ToAddress()}) addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": n44.PublicKey().ToAddress()}) out["addresses"]["bitcoin"] = addrs # Don't create empty secrets dicts if export_private and "secrets" in out: out["secrets"] = {k: v for k, v in out["secrets"].items() if v} if not out["secrets"]: del out["secrets"] return out def format_addresses_human(result: Dict[str, Any]) -> str: lines: List[str] = [] lines.append("\n" + "=" * 80) lines.append("MULTI-CHAIN ADDRESS DERIVATION (ADDRESSES ONLY)") lines.append("=" * 80 + "\n") for chain, addrs in result["addresses"].items(): lines.append("─" * 80) lines.append(f"{chain.upper()} ADDRESSES") lines.append("─" * 80) if chain == "bitcoin": by_type: Dict[str, List[Dict[str, Any]]] = {} for a in addrs: by_type.setdefault(a["address_type"], []).append(a) for t in ["native_segwit", "segwit", "legacy"]: if t in by_type: lines.append(f"\n {t.upper()}:") for a in by_type[t]: lines.append(f" [{a['index']}] {a['path']}") lines.append(f" → {a['address']}") else: for a in addrs: lines.append(f" [{a['index']}] {a['path']}") lines.append(f" → {a['address']}") lines.append("") lines.append("=" * 80 + "\n") return "\n".join(lines) 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\n" f"Passphrase 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 if args.export_private and not args.pgp_pubkey_file: raise ValueError("--export-private requires --pgp-pubkey-file (secrets must not go to stdout)") mnemonic = None seed_hex = None if args.interactive: mode = input("Enter 'm' for mnemonic or 's' for seed: ").strip().lower() if mode == "m": mnemonic = getpass.getpass("BIP39 mnemonic (hidden): ").strip() elif mode == "s": seed_hex = getpass.getpass("Seed hex (hidden): ").strip() else: raise ValueError("Invalid choice") elif args.mnemonic: mnemonic = args.mnemonic.strip() elif args.seed: seed_hex = args.seed.strip() else: raise ValueError("Missing input") if mnemonic: if not Bip39MnemonicValidator().IsValid(mnemonic): raise ValueError("Invalid BIP39 mnemonic") seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "") else: b = bytes.fromhex(seed_hex) if len(b) != 64: raise ValueError(f"Seed must be 64 bytes (128 hex chars), got {len(b)}") seed_bytes = b 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": 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: 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: with open(args.file, "w", encoding="utf-8") as f: f.write(out_text) print(f"✅ Addresses saved to {args.file}") # Optional encrypted payload if args.pgp_pubkey_file: with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: pgp_pub = f.read() 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: payload["mnemonic"] = mnemonic else: payload["seed_hex"] = seed_hex if args.export_private: payload["passphrase_set"] = bool(args.passphrase) payload["passphrase_hint"] = args.passphrase_hint or "" payload["note"] = "Private keys were derived from mnemonic/seed + (optional) passphrase. Passphrase value is intentionally omitted." payload["derived_private_keys"] = result.get("secrets", {}) payload["addresses"] = result["addresses"] else: payload["passphrase"] = args.passphrase or "" armored = pgp_encrypt_ascii_armored(pgp_pub, payload) print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") print(armored) print("===== END PGP ENCRYPTED PAYLOAD =====\n") def cmd_test(args): with NetworkGuard("test"): require_for_offline(["ethereum", "solana"]) from bip_utils import Bip39SeedGenerator print("🧪 Running tests...") # --- Existing vector (yours) --- mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" passphrase = "" # empty seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase) # --- NEW: Solana path->address test --- expected_addr = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" path = "m/44'/501'/0'/0'" seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) # or slip10_ed25519_seed32_by_path(...) got_addr = sol_pubkey_b58_from_seed32(seed32) if got_addr != expected_addr: raise RuntimeError(f"Solana address mismatch for {path}: got {got_addr}, expected {expected_addr}") print(f"✅ Solana OK: {path} => {got_addr}") print("✅ All tests passed") # ----------------------------------------------------------------------------- # Main # ----------------------------------------------------------------------------- def main(): 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") p_fetch.add_argument("url", help="URL to fetch (e.g., https://github.com/.gpg)") 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") 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]: 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 (default parser args, so recover works without subcommand too) 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-hint", default="", help="Hint/reminder for passphrase (stored only in encrypted payload when --export-private)") parser.add_argument("--export-private", action="store_true", help="Encrypt derived private keys into the PGP payload (never printed). Requires --pgp-pubkey-file.") args = parser.parse_args() try: if args.cmd == "fetchkey": forbidden = any([ getattr(args, "mnemonic", None), getattr(args, "seed", None), getattr(args, "interactive", False), getattr(args, "passphrase", ""), 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/words/dice-rolls options") cmd_fetchkey(args.url, args.out, args.timeout) return 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 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) sys.exit(1) if __name__ == "__main__": main()