diff --git a/.gitignore b/.gitignore index 5810a44..19691db 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ logs/ *.log coverage/ _toDelete/ +_toDelete/ dist/ build/ *.egg-info/ diff --git a/_toDelete/hdwallet_recovery.py b/_toDelete/hdwallet_recovery.py deleted file mode 100644 index e9dbf4b..0000000 --- a/_toDelete/hdwallet_recovery.py +++ /dev/null @@ -1,655 +0,0 @@ -#!/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() diff --git a/_toDelete/mypywallet.py b/_toDelete/mypywallet.py deleted file mode 100644 index 5ba74e3..0000000 --- a/_toDelete/mypywallet.py +++ /dev/null @@ -1,520 +0,0 @@ -#!/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/pyhdwallet.py b/src/pyhdwallet.py index 3a8bd8a..63af953 100644 --- a/src/pyhdwallet.py +++ b/src/pyhdwallet.py @@ -406,7 +406,7 @@ def cmd_gen(args): with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=True) as temp_file: temp_file.write(out_text) temp_file.flush() - print(f"Output written to temp file: {temp_file.name} (auto-deleted on exit)") + print("Output written to temporary file (auto-deleted on exit)") else: with open(args.file, 'w') as f: f.write(out_text) @@ -449,6 +449,13 @@ def cmd_recover(args): if args.export_private and not args.pgp_pubkey_file: raise ValueError("--export-private requires --pgp-pubkey-file") + # Validate input methods + input_methods = [bool(args.mnemonic), bool(args.seed), args.interactive] + if sum(input_methods) > 1: + raise ValueError("Provide only one of --mnemonic, --seed, or --interactive") + if sum(input_methods) == 0: + raise ValueError("Missing input (use --mnemonic/--seed/--interactive)") + mnemonic = None seed_hex = None @@ -464,8 +471,6 @@ def cmd_recover(args): 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): @@ -512,7 +517,7 @@ def cmd_recover(args): with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=True) as temp_file: temp_file.write(out_text) temp_file.flush() - print(f"Output written to temp file: {temp_file.name} (auto-deleted on exit)") + print("Output written to temporary file (auto-deleted on exit)") else: with open(args.file, 'w') as f: f.write(out_text)