#!/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()