diff --git a/src/hdwallet_recovery.py b/_toDelete/hdwallet_recovery.py similarity index 81% rename from src/hdwallet_recovery.py rename to _toDelete/hdwallet_recovery.py index 8216072..e5b5f2d 100644 --- a/src/hdwallet_recovery.py +++ b/_toDelete/hdwallet_recovery.py @@ -12,6 +12,10 @@ 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 @@ -19,9 +23,10 @@ import sys import json import getpass import hashlib +import hmac import urllib.request -from dataclasses import dataclass, asdict -from typing import Dict, List, Any, Optional +from dataclasses import dataclass +from typing import Dict, List, Any, Optional, Tuple # ----------------------------------------------------------------------------- @@ -44,7 +49,9 @@ def require_for_fetchkey(): def require_for_derive(export_private: bool, chains: List[str]): _require("bip_utils", "bip-utils") _require("pgpy", "PGPy") - if export_private and ("solana" in chains): + + # Needed to compute Solana pubkey/address + optional Phantom secret exports + if "solana" in chains: _require("nacl", "PyNaCl") _require("base58", "base58") @@ -144,7 +151,6 @@ class BtcAddrOut: def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]: # Phantom-compatible 64-byte secret key = seed32 || pubkey32 (ed25519). - # PyNaCl SigningKey(seed32) supports 32-byte seeds. from nacl.signing import SigningKey import base58 @@ -152,9 +158,74 @@ def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]: pub32 = sk.verify_key.encode() secret64 = seed32 + pub32 - return { - "phantom_base58": base58.b58encode(secret64).decode("ascii"), - } + 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]: @@ -189,23 +260,28 @@ def derive_addresses_and_maybe_secrets(seed_bytes: bytes, chains: List[str], cou if export_private and secrets: out["secrets"]["ethereum"] = secrets - # SOL (BIP44 m/44'/501'/0'/0'/i') + # SOL (Mode "A": m/44'/501'/{i}'/0') derived via SLIP-0010 like micro-ed25519-hdkey. [page:0][page:1] if "solana" in chains: - root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA) - ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) + from nacl.signing import SigningKey + import base58 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'/501'/0'/0'/{i}'", "address": node.PublicKey().ToAddress()}) + 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: - seed32 = node.PrivateKey().Raw().ToBytes() - if len(seed32) != 32: - raise ValueError(f"Unexpected Solana private key length from bip_utils: {len(seed32)} bytes") secrets.append({ "index": i, - "path": f"m/44'/501'/0'/0'/{i}'", + "path": path, "phantom": _solana_phantom_export_from_seed32(seed32), }) @@ -291,7 +367,7 @@ def cmd_gen(args): 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') + 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] @@ -308,6 +384,7 @@ def cmd_gen(args): 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": @@ -315,7 +392,7 @@ def cmd_gen(args): "mnemonic": mnemonic, "passphrase_set": bool(args.passphrase), "master_fingerprint": fingerprint_with, - "addresses": result["addresses"] + "addresses": result["addresses"], } if fingerprint_without: data["master_fingerprint_no_passphrase"] = fingerprint_without @@ -327,7 +404,11 @@ def cmd_gen(args): 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) + 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) @@ -347,7 +428,7 @@ def cmd_gen(args): "passphrase": args.passphrase or "", "master_fingerprint": fingerprint_with, "dice_rolls_used": bool(args.dice_rolls), - "addresses": result["addresses"] + "addresses": result["addresses"], } if fingerprint_without: payload["master_fingerprint_no_passphrase"] = fingerprint_without @@ -445,7 +526,7 @@ def cmd_recover(args): 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"] # include for verification + payload["addresses"] = result["addresses"] else: payload["passphrase"] = args.passphrase or "" @@ -457,33 +538,28 @@ def cmd_recover(args): 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 - } - ] + 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") + print("โŒ Invalid mnemonic") all_passed = False continue @@ -494,7 +570,7 @@ def cmd_test(args): print(f"โŒ Seed mismatch: got {seed_hex}, expected {test['expected_seed_hex']}") all_passed = False else: - print(f"โœ… Seed correct") + print("โœ… Seed correct") fp = get_master_fingerprint(seed_bytes) if fp != test["expected_fp"]: @@ -529,20 +605,20 @@ def main(): 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 + 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"]) + 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("--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 + # 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") @@ -571,7 +647,13 @@ def main(): 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", "")]): + 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 diff --git a/_toDelete/mypywallet.py b/_toDelete/mypywallet.py new file mode 100644 index 0000000..5ba74e3 --- /dev/null +++ b/_toDelete/mypywallet.py @@ -0,0 +1,520 @@ +#!/usr/bin/env python3 +""" +mypywallet.py (Python 3.12) + +Offline HD wallet gen/recover for ETH/SOL/BTC + optional online fetchkey. + +Solana profiles: +- js_ed25519_hd_key (DEFAULT): fixed path m/44'/501'/0'/0' (matches common JS examples using ed25519-hd-key derivePath) +- phantom_bip44change: m/44'/501'/{index}'/0' (Phantom bip44Change grouping) [web:7] +- phantom_bip44: m/44'/501'/{index}' (Phantom bip44 grouping) [web:7] +- phantom_deprecated: m/501'/{index}'/0/0 (Phantom deprecated) [web:7] +- solana_bip39_first32: seed[0:32] (Solana Cookbook "BIP39 format mnemonics") [web:47] + +Notes: +- Some Ed25519 derivation libs do not support non-hardened indices; for phantom_deprecated we try + m/501'/{i}'/0/0 first, then fallback to m/501'/{i}'/0'/0' if required. +""" + +import argparse +import sys +import json +import getpass +import hashlib +import urllib.request +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("base58", "base58") + _require("nacl", "PyNaCl") + _require("pgpy", "PGPy") + + +# ----------------------------------------------------------------------------- +# Offline network guard +# ----------------------------------------------------------------------------- + +class NetworkGuard: + def __init__(self, mode_name: str): + self.mode_name = mode_name + self._orig = None + + def __enter__(self): + self._orig = urllib.request.urlopen + + def blocked_urlopen(*args, **kwargs): + raise RuntimeError(f"Network I/O is disabled in {self.mode_name} mode") + + urllib.request.urlopen = blocked_urlopen + return self + + def __exit__(self, exc_type, exc, tb): + urllib.request.urlopen = self._orig + return False + + +# ----------------------------------------------------------------------------- +# BIP32 master fingerprint +# ----------------------------------------------------------------------------- + +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() + h160 = Hash160.QuickDigest(pubkey_bytes) + return h160[: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], ignore_usage_flags: bool = False) -> str: + import pgpy + pub_key = pgpy.PGPKey.from_blob(pubkey_armored)[0] + if ignore_usage_flags: + pub_key._require_usage_flags = False + msg = pgpy.PGPMessage.new(json.dumps(payload, indent=2, ensure_ascii=False)) + enc = pub_key.encrypt(msg) + return str(enc) + + +# ----------------------------------------------------------------------------- +# fetchkey (network allowed ONLY here) +# ----------------------------------------------------------------------------- + +def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]: + req = urllib.request.Request(url, headers={"User-Agent": "mypywallet/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, data + + +def sha256_hex_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int): + require_for_fetchkey() + armored_text, data = fetch_ascii_armored_text(url, timeout=timeout) + s256 = sha256_hex_bytes(data) + fpr = pgp_fingerprint(armored_text) + + if out_path: + with open(out_path, "w", encoding="utf-8", newline="\n") as f: + f.write(armored_text) + + 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}") + + +# ----------------------------------------------------------------------------- +# Solana helpers +# ----------------------------------------------------------------------------- + +def sol_pubkey_b58_from_seed32(seed32: bytes) -> str: + from nacl.signing import SigningKey + import base58 + sk = SigningKey(seed32) + pub32 = sk.verify_key.encode() + return base58.b58encode(pub32).decode("ascii") + + +def sol_secret64_b58_from_seed32(seed32: bytes) -> str: + # Phantom-compatible 64-byte secret = seed32 || pubkey32. [web:7] + from nacl.signing import SigningKey + import base58 + sk = SigningKey(seed32) + pub32 = sk.verify_key.encode() + secret64 = seed32 + pub32 + return base58.b58encode(secret64).decode("ascii") + + +def derive_ed25519_seed32_by_path(seed_bytes: bytes, path: str) -> bytes: + from bip_utils import Bip32Slip10Ed25519 + bip32 = Bip32Slip10Ed25519.FromSeedAndPath(seed_bytes, path) + seed32 = bip32.PrivateKey().Raw().ToBytes() + if len(seed32) != 32: + raise ValueError(f"Unexpected derived private key length: {len(seed32)}") + return seed32 + + +def sol_seed32_phantom_deprecated(seed_bytes: bytes, index: int) -> Tuple[str, bytes]: + # Phantom documents deprecated structure m/501'/{index}'/0/0. [web:7] + path1 = f"m/501'/{index}'/0/0" + try: + return path1, derive_ed25519_seed32_by_path(seed_bytes, path1) + except Exception: + # Fallback for libs that reject non-hardened indices under Ed25519 derivation. + path2 = f"m/501'/{index}'/0'/0'" + return path2, derive_ed25519_seed32_by_path(seed_bytes, path2) + + +# ----------------------------------------------------------------------------- +# Derivation core +# ----------------------------------------------------------------------------- + +def derive_addresses( + seed_bytes: bytes, + chains: List[str], + count: int, + sol_profile: str, + sol_match: str = "", + sol_scan: int = 50, + export_private: bool = False, +) -> 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 + if "ethereum" in chains: + root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM) + ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) + out["addresses"]["ethereum"] = [ + {"index": i, "path": f"m/44'/60'/0'/0/{i}", "address": ctx.AddressIndex(i).PublicKey().ToAddress()} + for i in range(count) + ] + + # BTC + if "bitcoin" in chains: + addrs = [] + + 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): + addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": c84.AddressIndex(i).PublicKey().ToAddress()}) + addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": c49.AddressIndex(i).PublicKey().ToAddress()}) + addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": c44.AddressIndex(i).PublicKey().ToAddress()}) + + out["addresses"]["bitcoin"] = addrs + + # SOL + if "solana" in chains: + from bip_utils import Bip44, Bip44Coins, Bip44Changes + root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA) + base = root.Purpose().Coin() + + def derive_one(profile: str, i: int) -> Tuple[str, str, bytes]: + # returns (path, address, seed32) + if profile == "js_ed25519_hd_key": + # Fixed path: m/44'/501'/0'/0' (JS derivePath examples) + path = "m/44'/501'/0'/0'" + seed32 = derive_ed25519_seed32_by_path(seed_bytes, path) + addr = sol_pubkey_b58_from_seed32(seed32) + return path, addr, seed32 + + if profile == "phantom_bip44change": + path = f"m/44'/501'/{i}'/0'" + seed32 = derive_ed25519_seed32_by_path(seed_bytes, path) + addr = sol_pubkey_b58_from_seed32(seed32) + return path, addr, seed32 + + if profile == "phantom_bip44": + path = f"m/44'/501'/{i}'" + seed32 = derive_ed25519_seed32_by_path(seed_bytes, path) + addr = sol_pubkey_b58_from_seed32(seed32) + return path, addr, seed32 + + + if profile == "phantom_deprecated": + # m/501'/{index}'/0/0 [web:7] + path, seed32 = sol_seed32_phantom_deprecated(seed_bytes, i) + addr = sol_pubkey_b58_from_seed32(seed32) + return path, addr, seed32 + + if profile == "solana_bip39_first32": + # Solana Cookbook: seed[0:32] [web:47] + seed32 = seed_bytes[:32] + addr = sol_pubkey_b58_from_seed32(seed32) + return "BIP39 seed[0:32]", addr, seed32 + + raise ValueError(f"Unknown Solana profile: {profile}") + + # Profiles that only yield a single address (fixed seed/path) + if sol_profile in ("solana_bip39_first32", "js_ed25519_hd_key"): + count = 1 + + if sol_match: + profiles = ["js_ed25519_hd_key", "phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"] + for prof in profiles: + max_i = 1 if prof in ("solana_bip39_first32", "js_ed25519_hd_key") else sol_scan + for i in range(max_i): + path, addr, _seed32 = derive_one(prof, i) + if addr == sol_match: + out["addresses"]["solana_match"] = [{"profile": prof, "index": i, "path": path, "address": addr}] + return out + out["addresses"]["solana_match"] = [] + return out + + addrs = [] + secrets = [] + for i in range(count): + path, addr, seed32 = derive_one(sol_profile, i) + addrs.append({"index": i, "path": path, "address": addr}) + if export_private: + secrets.append({"index": i, "path": path, "phantom_secret_base58": sol_secret64_b58_from_seed32(seed32)}) + + out["addresses"]["solana"] = addrs + if export_private: + out["secrets"]["solana"] = secrets + + if export_private: + 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(addresses: 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 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: + prof = a.get("profile") + if prof: + lines.append(f" [{a.get('index', 0)}] {a['path']} (profile={prof})") + else: + lines.append(f" [{a.get('index', 0)}] {a['path']}") + lines.append(f" โ†’ {a['address']}") + lines.append("") + + lines.append("=" * 80 + "\n") + return "\n".join(lines) + + +# ----------------------------------------------------------------------------- +# Commands +# ----------------------------------------------------------------------------- + +def cmd_gen(args): + with NetworkGuard("gen"): + require_for_derive(False, args.chains) + + import secrets + from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator + + words_to_entropy = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256} + entropy_len = (words_to_entropy[args.words] // 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_len) + entropy = hashlib.sha256(dice_bytes + crypto_bytes).digest()[:entropy_len] + else: + entropy = secrets.token_bytes(entropy_len) + + mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(entropy) + seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "") + fp_with = get_master_fingerprint(seed_bytes) + + print(f"๐Ÿ“ Generated {args.words}-word BIP39 mnemonic.") + if args.unsafe_print: + print(f"\nMnemonic:\n{mnemonic}\n") + + result = derive_addresses(seed_bytes, args.chains, args.addresses, args.sol_profile) + + if args.output == "json": + out_text = json.dumps({ + "master_fingerprint": fp_with, + "solana_profile": args.sol_profile, + "addresses": result["addresses"], + }, indent=2, ensure_ascii=False) + else: + out_text = f"Master Fingerprint: {fp_with}\n\n" + format_addresses_human(result["addresses"]) + print(out_text) + + +def cmd_recover(args): + with NetworkGuard("recover"): + require_for_derive(args.export_private, args.chains) + + from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator + + 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, 128 hex chars): ").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)} bytes") + seed_bytes = b + + fp_with = get_master_fingerprint(seed_bytes) + print(f"๐Ÿ“ Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...") + print(f"Master Fingerprint: {fp_with}\n") + + result = derive_addresses( + seed_bytes=seed_bytes, + chains=args.chains, + count=args.addresses, + sol_profile=args.sol_profile, + sol_match=(args.sol_match or "").strip(), + sol_scan=args.sol_scan, + export_private=args.export_private, + ) + + if args.output == "json": + out_text = json.dumps({ + "master_fingerprint": fp_with, + "solana_profile": args.sol_profile, + "solana_match": (args.sol_match or "").strip(), + "addresses": result["addresses"], + }, indent=2, ensure_ascii=False) + else: + out_text = format_addresses_human(result["addresses"]) + print(out_text) + + +def main(): + parser = argparse.ArgumentParser(description="Offline HD wallet gen/recover + online fetchkey") + subparsers = parser.add_subparsers(dest="cmd") + + p_fetch = subparsers.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL (online)") + 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_gen = subparsers.add_parser("gen", help="Generate BIP39 mnemonic (offline)") + p_recover = subparsers.add_parser("recover", help="Recover addresses from mnemonic/seed (offline)") + + for p in [p_gen, p_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("--sol-profile", + choices=["js_ed25519_hd_key", "phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"], + default="js_ed25519_hd_key", + help="Solana derivation behavior; Phantom supports multiple groupings; Solana cookbook also shows a BIP39-first32 shortcut. [web:7][web:47]") + p.add_argument("--export-private", action="store_true", + help="Print Solana secret keys (base58) to stdout (danger).") + + p_gen.add_argument("--words", type=int, choices=[12, 15, 18, 21, 24], default=12) + p_gen.add_argument("--dice-rolls", default="") + p_gen.add_argument("--unsafe-print", action="store_true") + + p_recover.add_argument("--mnemonic", default="") + p_recover.add_argument("--seed", default="") + p_recover.add_argument("--interactive", action="store_true") + p_recover.add_argument("--sol-match", default="", + help="Search for this Solana address across supported Solana profiles. [web:7]") + p_recover.add_argument("--sol-scan", type=int, default=50, + help="How many indices to scan when using --sol-match.") + + args = parser.parse_args() + + try: + if args.cmd == "fetchkey": + cmd_fetchkey(args.url, args.out, args.timeout) + return + if args.cmd == "gen": + cmd_gen(args) + return + if args.cmd == "recover": + cmd_recover(args) + return + + parser.print_help() + sys.exit(2) + + except Exception as e: + print(f"โŒ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/hdwallet_t.py b/src/hdwallet_t.py new file mode 100644 index 0000000..145055d --- /dev/null +++ b/src/hdwallet_t.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python3 +""" +hdwallet_recovery.py (Python 3.12) + +Commands: + - fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint + - gen (offline): generate BIP39 mnemonic; optionally encrypt payload to PGP pubkey + - recover (offline): derive addresses from mnemonic/seed; optionally encrypt payload to PGP pubkey + - test (offline): minimal self-test + +Usability fix: + - If you run without a subcommand (e.g. --mnemonic ...), it behaves like "recover". + +Security: + - gen/recover/test block network I/O via NetworkGuard. + - Never stores plaintext passphrase inside payload (only passphrase_used + passphrase_hint). + - If encrypting, mnemonic is not printed unless --unsafe-print. + +Solana derivation: + - Default: phantom_bip44change => m/44'/501'/{i}'/0' (Phantom-supported grouping). [web:13] + - Also: phantom_bip44 => m/44'/501'/{i}' (Phantom-supported grouping). [web:13] + - Also: solana_bip39_first32 => seed[0:32] shortcut (single address). + - Also: phantom_deprecated => m/501'/{i}'/0/0 (best-effort; some libs only support hardened derivation). + If the exact path fails, falls back to m/501'/{i}'/0'/0' and marks it in output. + +Implementation uses bip_utils SLIP-0010 ed25519 path derivation via Bip32Slip10Ed25519.FromSeedAndPath. [web:219] +PGP encryption uses PGPMessage.new(json.dumps(payload)) style. [web:203] +""" + +import argparse +import sys +import json +import getpass +import hashlib +import urllib.request +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_offline(chains: List[str]): + _require("bip_utils", "bip-utils") + if "solana" in chains: + _require("nacl", "PyNaCl") + _require("base58", "base58") + _require("pgpy", "PGPy") + + +# ----------------------------------------------------------------------------- +# Offline network guard +# ----------------------------------------------------------------------------- + +class NetworkGuard: + def __init__(self, mode_name: str): + self.mode_name = mode_name + self._orig = None + + def __enter__(self): + self._orig = urllib.request.urlopen + + def blocked_urlopen(*args, **kwargs): + raise RuntimeError(f"Network I/O is disabled in {self.mode_name} mode") + + urllib.request.urlopen = blocked_urlopen + return self + + def __exit__(self, exc_type, exc, tb): + urllib.request.urlopen = self._orig + return False + + +# ----------------------------------------------------------------------------- +# Fingerprint +# ----------------------------------------------------------------------------- + +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() + h160 = Hash160.QuickDigest(pubkey_bytes) + return h160[: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_encrypt_ascii_armored(pubkey_armored: str, payload: Dict[str, Any], ignore_usage_flags: bool = False) -> str: + import pgpy + pub_key = pgp_load_pubkey(pubkey_armored) + if ignore_usage_flags: + pub_key._require_usage_flags = False + msg = pgpy.PGPMessage.new(json.dumps(payload, ensure_ascii=False, indent=2)) + enc = pub_key.encrypt(msg) + return str(enc) + + +def pgp_fingerprint(armored: str) -> str: + return pgp_load_pubkey(armored).fingerprint + + +# ----------------------------------------------------------------------------- +# fetchkey (online) +# ----------------------------------------------------------------------------- + +def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]: + 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, data + + +def sha256_hex_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int): + require_for_fetchkey() + armored, raw = fetch_ascii_armored_text(url, timeout=timeout) + + s256 = sha256_hex_bytes(raw) + fpr = pgp_fingerprint(armored) + + if out_path: + with open(out_path, "w", encoding="utf-8", newline="\n") 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}") + + +# ----------------------------------------------------------------------------- +# Solana helpers +# ----------------------------------------------------------------------------- + +def sol_pubkey_b58_from_seed32(seed32: bytes) -> str: + from nacl.signing import SigningKey + import base58 + sk = SigningKey(seed32) + pub32 = sk.verify_key.encode() + return base58.b58encode(pub32).decode("ascii") + + +def sol_secret64_b58_from_seed32(seed32: bytes) -> str: + from nacl.signing import SigningKey + import base58 + sk = SigningKey(seed32) + pub32 = sk.verify_key.encode() + secret64 = seed32 + pub32 + return base58.b58encode(secret64).decode("ascii") + + +def slip10_ed25519_seed32_from_path(seed_bytes: bytes, path: str) -> bytes: + from bip_utils import Bip32Slip10Ed25519 + node = Bip32Slip10Ed25519.FromSeedAndPath(seed_bytes, path) + seed32 = node.PrivateKey().Raw().ToBytes() + if len(seed32) != 32: + raise ValueError(f"Unexpected derived seed length: {len(seed32)}") + return seed32 + + +# ----------------------------------------------------------------------------- +# Derivation (addresses-only by default) +# ----------------------------------------------------------------------------- + +def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: str, 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 + if "ethereum" in chains: + root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM) + ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) + addrs = [] + secrets = [] + for i in range(count): + node = ctx.AddressIndex(i) + path = f"m/44'/60'/0'/0/{i}" + addrs.append({"index": i, "path": path, "address": node.PublicKey().ToAddress()}) + if export_private: + secrets.append({"index": i, "path": path, "privkey_hex": node.PrivateKey().Raw().ToHex()}) + out["addresses"]["ethereum"] = addrs + if export_private: + out["secrets"]["ethereum"] = secrets + + # SOL + if "solana" in chains: + addrs = [] + secrets = [] + + # single-address modes + if sol_profile == "solana_bip39_first32": + count = 1 + + for i in range(count): + if sol_profile == "phantom_bip44change": + path = f"m/44'/501'/{i}'/0'" + seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) + elif sol_profile == "phantom_bip44": + path = f"m/44'/501'/{i}'" + seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) + elif sol_profile == "phantom_deprecated": + # Try documented legacy first, then fallback to hardened last levels if lib rejects non-hardened. + path_try = f"m/501'/{i}'/0/0" + try: + seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path_try) + path = path_try + except Exception: + path = f"m/501'/{i}'/0'/0'" + seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) + elif sol_profile == "solana_bip39_first32": + path = "BIP39 seed[0:32]" + seed32 = seed_bytes[:32] + else: + raise ValueError(f"Unknown sol_profile: {sol_profile}") + + addr = sol_pubkey_b58_from_seed32(seed32) + addrs.append({"index": i, "path": path, "address": addr}) + + if export_private: + secrets.append({"index": i, "path": path, "phantom_secret_base58": sol_secret64_b58_from_seed32(seed32)}) + + out["addresses"]["solana"] = addrs + if export_private: + out["secrets"]["solana"] = secrets + + # BTC (addresses only) + if "bitcoin" in chains: + addrs = [] + 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): + addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": c84.AddressIndex(i).PublicKey().ToAddress()}) + addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": c49.AddressIndex(i).PublicKey().ToAddress()}) + addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": c44.AddressIndex(i).PublicKey().ToAddress()}) + + out["addresses"]["bitcoin"] = addrs + + if export_private: + 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(addresses: 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 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) + + +# ----------------------------------------------------------------------------- +# Commands +# ----------------------------------------------------------------------------- + +def cmd_gen(args): + with NetworkGuard("gen"): + require_for_offline(args.chains) + + import secrets + from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator + + words_to_entropy = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256} + if args.words not in words_to_entropy: + raise ValueError("Invalid --words") + entropy_len = words_to_entropy[args.words] // 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_len) + entropy = hashlib.sha256(dice_bytes + crypto_bytes).digest()[:entropy_len] + dice_used = True + else: + entropy = secrets.token_bytes(entropy_len) + dice_used = False + + mnemonic_obj = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(entropy) + mnemonic = str(mnemonic_obj) # Fix JSON serialization (Mnemonic object -> string). [web:212] + seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "") + + fp = get_master_fingerprint(seed_bytes) + + result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=False) + + if not args.pgp_pubkey_file or args.unsafe_print: + print(f"๐Ÿ“ Generated {args.words}-word BIP39 mnemonic:\n{mnemonic}\n") + else: + print(f"๐Ÿ“ Generated {args.words}-word BIP39 mnemonic (not printed; encryption enabled).") + + if args.output == "json": + out_text = json.dumps({ + "master_fingerprint": fp, + "passphrase_used": bool(args.passphrase), + "passphrase_hint": args.passphrase_hint or "", + "dice_rolls_used": dice_used, + "solana_profile": args.sol_profile, + "addresses": result["addresses"], + }, indent=2, ensure_ascii=False) + else: + out_text = f"Master Fingerprint: {fp}\n\n" + format_addresses_human(result["addresses"]) + + print(out_text) + + if args.file: + with open(args.file, "w", encoding="utf-8") as f: + f.write(out_text) + + if args.pgp_pubkey_file: + with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: + pub = f.read() + + payload = { + "version": "v5", + "purpose": "generated mnemonic backup", + "mnemonic": mnemonic, + "passphrase_used": bool(args.passphrase), + "passphrase_hint": args.passphrase_hint or "", + "master_fingerprint": fp, + "dice_rolls_used": dice_used, + "solana_profile": args.sol_profile, + "addresses": result["addresses"], + } + armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) + print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") + print(armored) + print("===== END PGP ENCRYPTED PAYLOAD =====\n") + + +def cmd_recover(args): + with NetworkGuard("recover"): + require_for_offline(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") + + 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, 128 hex chars): ").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 (use --mnemonic/--seed/--interactive)") + + 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)} bytes") + seed_bytes = b + + fp = get_master_fingerprint(seed_bytes) + + print(f"๐Ÿ“ Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...") + print(f"Master Fingerprint: {fp}\n") + + result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=args.export_private) + + if args.output == "json": + out_text = json.dumps({ + "master_fingerprint": fp, + "solana_profile": args.sol_profile, + "addresses": result["addresses"], + }, indent=2, ensure_ascii=False) + else: + out_text = format_addresses_human(result["addresses"]) + + print(out_text) + + if args.file: + with open(args.file, "w", encoding="utf-8") as f: + f.write(out_text) + + if args.pgp_pubkey_file: + with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: + pub = f.read() + + payload: Dict[str, Any] = { + "version": "v5", + "purpose": "recovery payload", + "master_fingerprint": fp, + "solana_profile": args.sol_profile, + "passphrase_used": bool(args.passphrase), + "passphrase_hint": args.passphrase_hint or "", + } + + # Include source only if you explicitly want it: + if args.include_source: + if mnemonic: + payload["mnemonic"] = mnemonic + else: + payload["seed_hex"] = seed_hex + + if args.export_private: + payload["note"] = "Derived private keys included; passphrase value intentionally omitted." + payload["derived_private_keys"] = result.get("secrets", {}) + payload["addresses"] = result["addresses"] + + armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) + 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"]) + from bip_utils import Bip39SeedGenerator, Bip39MnemonicValidator + + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + passphrase = "crypto" + expected_seed_hex = "92c58d3f4fe52f0111d314f3fa8f10ba498751c37e7c36475c2a5b60145b29708576e11bf6c5c46efac1add0cc26e07c69eb65476742082f9c6460f132181cfe" + + if not Bip39MnemonicValidator().IsValid(mnemonic): + raise RuntimeError("Test mnemonic failed validation") + + seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase) + if seed_bytes.hex() != expected_seed_hex: + raise RuntimeError("Seed mismatch") + + print("โœ… Test passed") + + +# ----------------------------------------------------------------------------- +# CLI +# ----------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="HD wallet gen/recover (offline) + fetchkey (online)") + sub = parser.add_subparsers(dest="cmd") + + # fetchkey + p_fetch = sub.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL (online)") + p_fetch.add_argument("url") + p_fetch.add_argument("--out", default=None) + p_fetch.add_argument("--timeout", type=int, default=15) + + # shared for gen/recover and also top-level (so recover works without explicit subcommand) + def add_common(p: argparse.ArgumentParser): + p.add_argument("--passphrase", default="", help="BIP39 passphrase (optional; avoid CLI for real usage)") + p.add_argument("--passphrase-hint", default="", help="Hint only; never store the passphrase itself") + 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", default=None) + p.add_argument("--pgp-pubkey-file", default=None) + p.add_argument("--pgp-ignore-usage-flags", action="store_true") + p.add_argument("--sol-profile", + choices=["phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"], + default="phantom_bip44change") + p.add_argument("--export-private", action="store_true", + help="If set, derive and include private keys in encrypted payload (never printed)") + p.add_argument("--include-source", action="store_true", + help="If set with --pgp-pubkey-file, include mnemonic/seed in encrypted payload") + + # gen + p_gen = sub.add_parser("gen", help="Generate BIP39 mnemonic (offline)") + add_common(p_gen) + p_gen.add_argument("--words", type=int, choices=[12, 15, 18, 21, 24], default=12) + p_gen.add_argument("--dice-rolls", default="") + p_gen.add_argument("--unsafe-print", action="store_true", + help="Print mnemonic even when encrypting (not recommended)") + + # recover + p_rec = sub.add_parser("recover", help="Recover addresses from mnemonic/seed (offline)") + add_common(p_rec) + p_rec.add_argument("--mnemonic", default="") + p_rec.add_argument("--seed", default="") + p_rec.add_argument("--interactive", action="store_true") + + # test + p_test = sub.add_parser("test", help="Run minimal offline tests") + # no args + + # Also allow recover without subcommand: + add_common(parser) + parser.add_argument("--mnemonic", default="") + parser.add_argument("--seed", default="") + parser.add_argument("--interactive", action="store_true") + + return parser + + +def main(): + parser = build_parser() + args = parser.parse_args() + + try: + if args.cmd == "fetchkey": + cmd_fetchkey(args.url, args.out, args.timeout) + return + if args.cmd == "gen": + cmd_gen(args) + return + if args.cmd == "recover": + cmd_recover(args) + return + if args.cmd == "test": + cmd_test(args) + return + + # No subcommand: treat as recover if user provided recover inputs + if args.mnemonic or args.seed or args.interactive: + cmd_recover(args) + return + + parser.print_help() + sys.exit(2) + + except Exception as e: + print(f"โŒ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()