Update project files: move recovery script to _toDelete and add new wallet scripts

This commit is contained in:
LC
2026-01-04 20:10:16 +00:00
parent 68460d40e7
commit 9ce1a834f6
3 changed files with 1245 additions and 39 deletions

View File

@@ -1,595 +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
"""
import argparse
import sys
import json
import getpass
import hashlib
import urllib.request
from dataclasses import dataclass, asdict
from typing import Dict, List, Any, Optional
# -----------------------------------------------------------------------------
# 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")
if export_private and ("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).
# PyNaCl SigningKey(seed32) supports 32-byte seeds.
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"),
}
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 (BIP44 m/44'/501'/0'/0'/i')
if "solana" in chains:
root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
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'/501'/0'/0'/{i}'", "address": node.PublicKey().ToAddress()})
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}'",
"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\nPassphrase set: {bool(args.passphrase)}\n{fp_text}{dice_note}\n\n" + format_addresses_human(result)
print(out_text)
if args.file:
with open(args.file, "w", encoding="utf-8") as f:
f.write(out_text)
print(f"✅ Saved to {args.file}")
if args.pgp_pubkey_file:
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
pgp_pub = f.read()
payload = {
"version": "v4",
"purpose": "hdwallet generated mnemonic",
"mnemonic": mnemonic,
"passphrase": args.passphrase or "",
"master_fingerprint": fingerprint_with,
"dice_rolls_used": bool(args.dice_rolls),
"addresses": result["addresses"]
}
if fingerprint_without:
payload["master_fingerprint_no_passphrase"] = fingerprint_without
armored = pgp_encrypt_ascii_armored(pgp_pub, payload)
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
print(armored)
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
def cmd_recover(args):
require_for_derive(args.export_private, args.chains)
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
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"] # include for verification
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):
require_for_derive(False, ["ethereum"]) # minimal
from bip_utils import Bip39SeedGenerator, Bip39MnemonicValidator
print("🧪 Running Trezor BIP39/BIP32 test vectors...")
# Trezor vector with passphrase
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
passphrase = "crypto"
expected_seed_hex = "92c58d3f4fe52f0111d314f3fa8f10ba498751c37e7c36475c2a5b60145b29708576e11bf6c5c46efac1add0cc26e07c69eb65476742082f9c6460f132181cfe"
expected_fp = "AECCC350"
tests = [
{
"mnemonic": mnemonic,
"passphrase": passphrase,
"expected_seed_hex": expected_seed_hex,
"expected_fp": expected_fp
}
]
all_passed = True
for i, test in enumerate(tests, 1):
print(f"\nTest {i}: With passphrase '{test['passphrase']}'")
if not Bip39MnemonicValidator().IsValid(test["mnemonic"]):
print(f"❌ Invalid mnemonic")
all_passed = False
continue
seed_bytes = Bip39SeedGenerator(test["mnemonic"]).Generate(test["passphrase"])
seed_hex = seed_bytes.hex()
if seed_hex != test["expected_seed_hex"]:
print(f"❌ Seed mismatch: got {seed_hex}, expected {test['expected_seed_hex']}")
all_passed = False
else:
print(f"✅ Seed correct")
fp = get_master_fingerprint(seed_bytes)
if fp != test["expected_fp"]:
print(f"❌ Fingerprint mismatch: got {fp}, expected {test['expected_fp']}")
all_passed = False
else:
print(f"✅ Fingerprint correct: {fp}")
if all_passed:
print("\n🎉 All tests passed!")
else:
print("\n❌ Some tests failed!")
sys.exit(1)
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="HD wallet recovery + 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/<user>.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]: # parser for default recover
p.add_argument("--passphrase", default="", help="BIP39 passphrase (optional)")
p.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
default=["ethereum", "solana", "bitcoin"])
p.add_argument("--addresses", type=int, default=5)
p.add_argument("--output", choices=["text", "json"], default="text")
p.add_argument("--file", help="Save output to file", default=None)
p.add_argument("--pgp-pubkey-file", help="ASCII-armored PGP public key file to encrypt payload", default=None)
# Gen specific
p_gen.add_argument("--words", type=int, choices=[12,15,18,24], default=12, help="Number of words for mnemonic")
p_gen.add_argument("--dice-rolls", help="Space-separated die rolls (1-6) for extra entropy")
# Recover specific
parser.add_argument("--mnemonic", help="BIP39 mnemonic (12/24 words)")
parser.add_argument("--seed", help="64-byte seed hex (128 hex chars)")
parser.add_argument("--interactive", action="store_true", help="Prompt for input via stdin")
parser.add_argument("--passphrase-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()

604
src/hdwallet_t.py Normal file
View File

@@ -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()