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

@@ -12,6 +12,10 @@ Security invariant:
- recover never performs network I/O
- fetchkey refuses mnemonic/seed/passphrase inputs
- gen generates secure mnemonics compliant with BIP39
Solana derivation (Mode "A"):
- Derive accounts as: m/44'/501'/{i}'/0'
- Use SLIP-0010 ed25519 hardened derivation from the BIP39 seed. [page:0][page:1]
"""
import argparse
@@ -19,9 +23,10 @@ import sys
import json
import getpass
import hashlib
import hmac
import urllib.request
from dataclasses import dataclass, asdict
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
from typing import Dict, List, Any, Optional, Tuple
# -----------------------------------------------------------------------------
@@ -44,7 +49,9 @@ def require_for_fetchkey():
def require_for_derive(export_private: bool, chains: List[str]):
_require("bip_utils", "bip-utils")
_require("pgpy", "PGPy")
if export_private and ("solana" in chains):
# Needed to compute Solana pubkey/address + optional Phantom secret exports
if "solana" in chains:
_require("nacl", "PyNaCl")
_require("base58", "base58")
@@ -144,7 +151,6 @@ class BtcAddrOut:
def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]:
# Phantom-compatible 64-byte secret key = seed32 || pubkey32 (ed25519).
# PyNaCl SigningKey(seed32) supports 32-byte seeds.
from nacl.signing import SigningKey
import base58
@@ -152,9 +158,74 @@ def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]:
pub32 = sk.verify_key.encode()
secret64 = seed32 + pub32
return {
"phantom_base58": base58.b58encode(secret64).decode("ascii"),
}
return {"phantom_base58": base58.b58encode(secret64).decode("ascii")}
# ---- SLIP-0010 ed25519 (matches micro-ed25519-hdkey behavior) ----
# Master key generation for ed25519 is:
# I = HMAC-SHA512(key="ed25519 seed", data=seed)
# k = I_L (32 bytes), c = I_R (32 bytes)
# Child derivation (hardened only):
# I = HMAC-SHA512(key=c_par, data=0x00 || k_par || ser32(i))
# k_i = I_L, c_i = I_R
# This is per SLIP-0010. [page:0]
_ED25519_SEED_KEY = b"ed25519 seed"
def _hmac_sha512(key: bytes, data: bytes) -> bytes:
return hmac.new(key, data, hashlib.sha512).digest()
def _ser32(i: int) -> bytes:
return i.to_bytes(4, "big", signed=False)
def _parse_path_ed25519_hardened(path: str) -> List[int]:
"""
Parse BIP32-like path; for ed25519 we only support hardened indices.
Accept both "m/44'/501'/0'/0'" and "m/44/501/0/0" by promoting to hardened,
matching the "force hardened" convenience described by micro-ed25519-hdkey. [page:1]
"""
if not path.startswith("m/"):
raise ValueError("Path must start with m/")
out: List[int] = []
for comp in path[2:].split("/"):
if comp == "":
continue
hardened = comp.endswith("'")
n_str = comp[:-1] if hardened else comp
if not n_str.isdigit():
raise ValueError(f"Invalid path component: {comp}")
n = int(n_str)
# Promote to hardened (ed25519 hardened-only). [page:0][page:1]
n = (n | 0x80000000)
out.append(n)
return out
def _slip10_ed25519_master(seed_bytes: bytes) -> Tuple[bytes, bytes]:
I = _hmac_sha512(_ED25519_SEED_KEY, seed_bytes)
return I[:32], I[32:]
def _slip10_ed25519_ckd_priv(k_par: bytes, c_par: bytes, index_hardened: int) -> Tuple[bytes, bytes]:
if (index_hardened & 0x80000000) == 0:
raise ValueError("ed25519 SLIP-0010 supports hardened derivation only") # [page:0]
data = b"\x00" + k_par + _ser32(index_hardened)
I = _hmac_sha512(c_par, data)
return I[:32], I[32:]
def solana_seed32_from_bip39_seed_slip10(seed_bytes: bytes, path: str) -> bytes:
"""
Derive 32-byte ed25519 private key bytes (seed) from BIP39 seed bytes using SLIP-0010. [page:0]
"""
idxs = _parse_path_ed25519_hardened(path)
k, c = _slip10_ed25519_master(seed_bytes)
for i in idxs:
k, c = _slip10_ed25519_ckd_priv(k, c, i)
return k
def derive_addresses_and_maybe_secrets(seed_bytes: bytes, chains: List[str], count: int, export_private: bool) -> Dict[str, Any]:
@@ -189,23 +260,28 @@ def derive_addresses_and_maybe_secrets(seed_bytes: bytes, chains: List[str], cou
if export_private and secrets:
out["secrets"]["ethereum"] = secrets
# SOL (BIP44 m/44'/501'/0'/0'/i')
# SOL (Mode "A": m/44'/501'/{i}'/0') derived via SLIP-0010 like micro-ed25519-hdkey. [page:0][page:1]
if "solana" in chains:
root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
from nacl.signing import SigningKey
import base58
addrs: List[Dict[str, Any]] = []
secrets: List[Dict[str, Any]] = []
for i in range(count):
node = ctx.AddressIndex(i)
addrs.append({"index": i, "path": f"m/44'/501'/0'/0'/{i}'", "address": node.PublicKey().ToAddress()})
path = f"m/44'/501'/{i}'/0'"
seed32 = solana_seed32_from_bip39_seed_slip10(seed_bytes, path)
sk = SigningKey(seed32)
pub32 = sk.verify_key.encode()
address = base58.b58encode(pub32).decode("ascii")
addrs.append({"index": i, "path": path, "address": address})
if export_private:
seed32 = node.PrivateKey().Raw().ToBytes()
if len(seed32) != 32:
raise ValueError(f"Unexpected Solana private key length from bip_utils: {len(seed32)} bytes")
secrets.append({
"index": i,
"path": f"m/44'/501'/0'/0'/{i}'",
"path": path,
"phantom": _solana_phantom_export_from_seed32(seed32),
})
@@ -291,7 +367,7 @@ def cmd_gen(args):
rolls = args.dice_rolls.strip().split()
if not rolls or not all(r.isdigit() and 1 <= int(r) <= 6 for r in rolls):
raise ValueError("--dice-rolls must be space-separated integers 1-6")
dice_bytes = ' '.join(rolls).encode('utf-8')
dice_bytes = " ".join(rolls).encode("utf-8")
crypto_bytes = secrets.token_bytes(entropy_bytes_len)
combined = dice_bytes + crypto_bytes
entropy = hashlib.sha256(combined).digest()[:entropy_bytes_len]
@@ -308,6 +384,7 @@ def cmd_gen(args):
if args.passphrase:
seed_without = Bip39SeedGenerator(mnemonic).Generate("")
fingerprint_without = get_master_fingerprint(seed_without)
result = derive_addresses_and_maybe_secrets(seed_bytes, args.chains, args.addresses, False)
if args.output == "json":
@@ -315,7 +392,7 @@ def cmd_gen(args):
"mnemonic": mnemonic,
"passphrase_set": bool(args.passphrase),
"master_fingerprint": fingerprint_with,
"addresses": result["addresses"]
"addresses": result["addresses"],
}
if fingerprint_without:
data["master_fingerprint_no_passphrase"] = fingerprint_without
@@ -327,7 +404,11 @@ def cmd_gen(args):
if fingerprint_without:
fp_text += f"\nMaster Fingerprint (no passphrase): {fingerprint_without}"
dice_note = "\nDice rolls used for extra entropy: Yes" if args.dice_rolls else ""
out_text = f"Generated Mnemonic ({args.words} words):\n{mnemonic}\n\nPassphrase set: {bool(args.passphrase)}\n{fp_text}{dice_note}\n\n" + format_addresses_human(result)
out_text = (
f"Generated Mnemonic ({args.words} words):\n{mnemonic}\n\n"
f"Passphrase set: {bool(args.passphrase)}\n{fp_text}{dice_note}\n\n"
+ format_addresses_human(result)
)
print(out_text)
@@ -347,7 +428,7 @@ def cmd_gen(args):
"passphrase": args.passphrase or "",
"master_fingerprint": fingerprint_with,
"dice_rolls_used": bool(args.dice_rolls),
"addresses": result["addresses"]
"addresses": result["addresses"],
}
if fingerprint_without:
payload["master_fingerprint_no_passphrase"] = fingerprint_without
@@ -445,7 +526,7 @@ def cmd_recover(args):
payload["passphrase_hint"] = args.passphrase_hint or ""
payload["note"] = "Private keys were derived from mnemonic/seed + (optional) passphrase. Passphrase value is intentionally omitted."
payload["derived_private_keys"] = result.get("secrets", {})
payload["addresses"] = result["addresses"] # include for verification
payload["addresses"] = result["addresses"]
else:
payload["passphrase"] = args.passphrase or ""
@@ -457,33 +538,28 @@ def cmd_recover(args):
def cmd_test(args):
require_for_derive(False, ["ethereum"]) # minimal
from bip_utils import Bip39SeedGenerator, Bip39MnemonicValidator
print("🧪 Running Trezor BIP39/BIP32 test vectors...")
# Trezor vector with passphrase
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
passphrase = "crypto"
expected_seed_hex = "92c58d3f4fe52f0111d314f3fa8f10ba498751c37e7c36475c2a5b60145b29708576e11bf6c5c46efac1add0cc26e07c69eb65476742082f9c6460f132181cfe"
expected_fp = "AECCC350"
tests = [
{
"mnemonic": mnemonic,
"passphrase": passphrase,
"expected_seed_hex": expected_seed_hex,
"expected_fp": expected_fp
}
]
tests = [{
"mnemonic": mnemonic,
"passphrase": passphrase,
"expected_seed_hex": expected_seed_hex,
"expected_fp": expected_fp,
}]
all_passed = True
for i, test in enumerate(tests, 1):
print(f"\nTest {i}: With passphrase '{test['passphrase']}'")
if not Bip39MnemonicValidator().IsValid(test["mnemonic"]):
print(f"❌ Invalid mnemonic")
print("❌ Invalid mnemonic")
all_passed = False
continue
@@ -494,7 +570,7 @@ def cmd_test(args):
print(f"❌ Seed mismatch: got {seed_hex}, expected {test['expected_seed_hex']}")
all_passed = False
else:
print(f"✅ Seed correct")
print("✅ Seed correct")
fp = get_master_fingerprint(seed_bytes)
if fp != test["expected_fp"]:
@@ -529,20 +605,20 @@ def main():
p_recover = subparsers.add_parser("recover", help="Derive addresses from mnemonic/seed")
# Common args for gen and recover
for p in [p_gen, p_recover, parser]: # parser for default recover
for p in [p_gen, p_recover, parser]:
p.add_argument("--passphrase", default="", help="BIP39 passphrase (optional)")
p.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
default=["ethereum", "solana", "bitcoin"])
default=["ethereum", "solana", "bitcoin"])
p.add_argument("--addresses", type=int, default=5)
p.add_argument("--output", choices=["text", "json"], default="text")
p.add_argument("--file", help="Save output to file", default=None)
p.add_argument("--pgp-pubkey-file", help="ASCII-armored PGP public key file to encrypt payload", default=None)
# Gen specific
p_gen.add_argument("--words", type=int, choices=[12,15,18,24], default=12, help="Number of words for mnemonic")
p_gen.add_argument("--words", type=int, choices=[12, 15, 18, 24], default=12, help="Number of words for mnemonic")
p_gen.add_argument("--dice-rolls", help="Space-separated die rolls (1-6) for extra entropy")
# Recover specific
# Recover specific (default parser args, so recover works without subcommand too)
parser.add_argument("--mnemonic", help="BIP39 mnemonic (12/24 words)")
parser.add_argument("--seed", help="64-byte seed hex (128 hex chars)")
parser.add_argument("--interactive", action="store_true", help="Prompt for input via stdin")
@@ -571,7 +647,13 @@ def main():
return
elif args.cmd == "gen":
if any([getattr(args, "mnemonic", None), getattr(args, "seed", None), getattr(args, "interactive", False), getattr(args, "export_private", False), getattr(args, "passphrase_hint", "")]):
if any([
getattr(args, "mnemonic", None),
getattr(args, "seed", None),
getattr(args, "interactive", False),
getattr(args, "export_private", False),
getattr(args, "passphrase_hint", ""),
]):
raise ValueError("gen mode must not be used with mnemonic/seed/interactive/export-private/passphrase-hint options")
cmd_gen(args)
return

520
_toDelete/mypywallet.py Normal file
View File

@@ -0,0 +1,520 @@
#!/usr/bin/env python3
"""
mypywallet.py (Python 3.12)
Offline HD wallet gen/recover for ETH/SOL/BTC + optional online fetchkey.
Solana profiles:
- js_ed25519_hd_key (DEFAULT): fixed path m/44'/501'/0'/0' (matches common JS examples using ed25519-hd-key derivePath)
- phantom_bip44change: m/44'/501'/{index}'/0' (Phantom bip44Change grouping) [web:7]
- phantom_bip44: m/44'/501'/{index}' (Phantom bip44 grouping) [web:7]
- phantom_deprecated: m/501'/{index}'/0/0 (Phantom deprecated) [web:7]
- solana_bip39_first32: seed[0:32] (Solana Cookbook "BIP39 format mnemonics") [web:47]
Notes:
- Some Ed25519 derivation libs do not support non-hardened indices; for phantom_deprecated we try
m/501'/{i}'/0/0 first, then fallback to m/501'/{i}'/0'/0' if required.
"""
import argparse
import sys
import json
import getpass
import hashlib
import urllib.request
from typing import Dict, List, Any, Optional, Tuple
# -----------------------------------------------------------------------------
# Dependency checks
# -----------------------------------------------------------------------------
def _require(mod: str, pkg: str):
try:
__import__(mod)
except ImportError:
print(f"❌ Missing dependency: {pkg}", file=sys.stderr)
print(f"Install with: pip install {pkg}", file=sys.stderr)
sys.exit(1)
def require_for_fetchkey():
_require("pgpy", "PGPy")
def require_for_derive(export_private: bool, chains: List[str]):
_require("bip_utils", "bip-utils")
_require("base58", "base58")
_require("nacl", "PyNaCl")
_require("pgpy", "PGPy")
# -----------------------------------------------------------------------------
# Offline network guard
# -----------------------------------------------------------------------------
class NetworkGuard:
def __init__(self, mode_name: str):
self.mode_name = mode_name
self._orig = None
def __enter__(self):
self._orig = urllib.request.urlopen
def blocked_urlopen(*args, **kwargs):
raise RuntimeError(f"Network I/O is disabled in {self.mode_name} mode")
urllib.request.urlopen = blocked_urlopen
return self
def __exit__(self, exc_type, exc, tb):
urllib.request.urlopen = self._orig
return False
# -----------------------------------------------------------------------------
# BIP32 master fingerprint
# -----------------------------------------------------------------------------
def get_master_fingerprint(seed_bytes: bytes) -> str:
from bip_utils import Bip32Slip10Secp256k1, Hash160
master = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
pubkey_bytes = master.PublicKey().RawCompressed().ToBytes()
h160 = Hash160.QuickDigest(pubkey_bytes)
return h160[:4].hex().upper()
# -----------------------------------------------------------------------------
# PGP helpers (PGPy)
# -----------------------------------------------------------------------------
def pgp_load_pubkey(armored: str):
import pgpy
key, _ = pgpy.PGPKey.from_blob(armored)
if not key.is_public:
raise ValueError("Provided key is not a public key")
return key
def pgp_fingerprint(armored: str) -> str:
return pgp_load_pubkey(armored).fingerprint
def pgp_encrypt_ascii_armored(pubkey_armored: str, payload: Dict[str, Any], ignore_usage_flags: bool = False) -> str:
import pgpy
pub_key = pgpy.PGPKey.from_blob(pubkey_armored)[0]
if ignore_usage_flags:
pub_key._require_usage_flags = False
msg = pgpy.PGPMessage.new(json.dumps(payload, indent=2, ensure_ascii=False))
enc = pub_key.encrypt(msg)
return str(enc)
# -----------------------------------------------------------------------------
# fetchkey (network allowed ONLY here)
# -----------------------------------------------------------------------------
def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]:
req = urllib.request.Request(url, headers={"User-Agent": "mypywallet/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
text = data.decode("utf-8", errors="strict")
if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text:
raise ValueError("Downloaded content does not look like an ASCII-armored PGP public key")
return text, data
def sha256_hex_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int):
require_for_fetchkey()
armored_text, data = fetch_ascii_armored_text(url, timeout=timeout)
s256 = sha256_hex_bytes(data)
fpr = pgp_fingerprint(armored_text)
if out_path:
with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(armored_text)
print("✅ Downloaded PGP public key")
print(f"URL: {url}")
if out_path:
print(f"Saved: {out_path}")
print(f"SHA256: {s256}")
print(f"Fingerprint: {fpr}")
# -----------------------------------------------------------------------------
# Solana helpers
# -----------------------------------------------------------------------------
def sol_pubkey_b58_from_seed32(seed32: bytes) -> str:
from nacl.signing import SigningKey
import base58
sk = SigningKey(seed32)
pub32 = sk.verify_key.encode()
return base58.b58encode(pub32).decode("ascii")
def sol_secret64_b58_from_seed32(seed32: bytes) -> str:
# Phantom-compatible 64-byte secret = seed32 || pubkey32. [web:7]
from nacl.signing import SigningKey
import base58
sk = SigningKey(seed32)
pub32 = sk.verify_key.encode()
secret64 = seed32 + pub32
return base58.b58encode(secret64).decode("ascii")
def derive_ed25519_seed32_by_path(seed_bytes: bytes, path: str) -> bytes:
from bip_utils import Bip32Slip10Ed25519
bip32 = Bip32Slip10Ed25519.FromSeedAndPath(seed_bytes, path)
seed32 = bip32.PrivateKey().Raw().ToBytes()
if len(seed32) != 32:
raise ValueError(f"Unexpected derived private key length: {len(seed32)}")
return seed32
def sol_seed32_phantom_deprecated(seed_bytes: bytes, index: int) -> Tuple[str, bytes]:
# Phantom documents deprecated structure m/501'/{index}'/0/0. [web:7]
path1 = f"m/501'/{index}'/0/0"
try:
return path1, derive_ed25519_seed32_by_path(seed_bytes, path1)
except Exception:
# Fallback for libs that reject non-hardened indices under Ed25519 derivation.
path2 = f"m/501'/{index}'/0'/0'"
return path2, derive_ed25519_seed32_by_path(seed_bytes, path2)
# -----------------------------------------------------------------------------
# Derivation core
# -----------------------------------------------------------------------------
def derive_addresses(
seed_bytes: bytes,
chains: List[str],
count: int,
sol_profile: str,
sol_match: str = "",
sol_scan: int = 50,
export_private: bool = False,
) -> Dict[str, Any]:
from bip_utils import (
Bip44, Bip44Coins, Bip44Changes,
Bip49, Bip49Coins,
Bip84, Bip84Coins,
)
out: Dict[str, Any] = {"addresses": {}}
if export_private:
out["secrets"] = {}
# ETH
if "ethereum" in chains:
root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
out["addresses"]["ethereum"] = [
{"index": i, "path": f"m/44'/60'/0'/0/{i}", "address": ctx.AddressIndex(i).PublicKey().ToAddress()}
for i in range(count)
]
# BTC
if "bitcoin" in chains:
addrs = []
r44 = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN)
c44 = r44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
r49 = Bip49.FromSeed(seed_bytes, Bip49Coins.BITCOIN)
c49 = r49.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
r84 = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
c84 = r84.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
for i in range(count):
addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": c84.AddressIndex(i).PublicKey().ToAddress()})
addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": c49.AddressIndex(i).PublicKey().ToAddress()})
addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": c44.AddressIndex(i).PublicKey().ToAddress()})
out["addresses"]["bitcoin"] = addrs
# SOL
if "solana" in chains:
from bip_utils import Bip44, Bip44Coins, Bip44Changes
root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
base = root.Purpose().Coin()
def derive_one(profile: str, i: int) -> Tuple[str, str, bytes]:
# returns (path, address, seed32)
if profile == "js_ed25519_hd_key":
# Fixed path: m/44'/501'/0'/0' (JS derivePath examples)
path = "m/44'/501'/0'/0'"
seed32 = derive_ed25519_seed32_by_path(seed_bytes, path)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "phantom_bip44change":
path = f"m/44'/501'/{i}'/0'"
seed32 = derive_ed25519_seed32_by_path(seed_bytes, path)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "phantom_bip44":
path = f"m/44'/501'/{i}'"
seed32 = derive_ed25519_seed32_by_path(seed_bytes, path)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "phantom_deprecated":
# m/501'/{index}'/0/0 [web:7]
path, seed32 = sol_seed32_phantom_deprecated(seed_bytes, i)
addr = sol_pubkey_b58_from_seed32(seed32)
return path, addr, seed32
if profile == "solana_bip39_first32":
# Solana Cookbook: seed[0:32] [web:47]
seed32 = seed_bytes[:32]
addr = sol_pubkey_b58_from_seed32(seed32)
return "BIP39 seed[0:32]", addr, seed32
raise ValueError(f"Unknown Solana profile: {profile}")
# Profiles that only yield a single address (fixed seed/path)
if sol_profile in ("solana_bip39_first32", "js_ed25519_hd_key"):
count = 1
if sol_match:
profiles = ["js_ed25519_hd_key", "phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"]
for prof in profiles:
max_i = 1 if prof in ("solana_bip39_first32", "js_ed25519_hd_key") else sol_scan
for i in range(max_i):
path, addr, _seed32 = derive_one(prof, i)
if addr == sol_match:
out["addresses"]["solana_match"] = [{"profile": prof, "index": i, "path": path, "address": addr}]
return out
out["addresses"]["solana_match"] = []
return out
addrs = []
secrets = []
for i in range(count):
path, addr, seed32 = derive_one(sol_profile, i)
addrs.append({"index": i, "path": path, "address": addr})
if export_private:
secrets.append({"index": i, "path": path, "phantom_secret_base58": sol_secret64_b58_from_seed32(seed32)})
out["addresses"]["solana"] = addrs
if export_private:
out["secrets"]["solana"] = secrets
if export_private:
out["secrets"] = {k: v for k, v in out["secrets"].items() if v}
if not out["secrets"]:
del out["secrets"]
return out
def format_addresses_human(addresses: Dict[str, Any]) -> str:
lines: List[str] = []
lines.append("\n" + "=" * 80)
lines.append("MULTI-CHAIN ADDRESS DERIVATION (ADDRESSES ONLY)")
lines.append("=" * 80 + "\n")
for chain, addrs in addresses.items():
lines.append("" * 80)
lines.append(f"{chain.upper()} ADDRESSES")
lines.append("" * 80)
if chain == "bitcoin":
by_type: Dict[str, List[Dict[str, Any]]] = {}
for a in addrs:
by_type.setdefault(a["address_type"], []).append(a)
for t in ["native_segwit", "segwit", "legacy"]:
if t in by_type:
lines.append(f"\n {t.upper()}:")
for a in by_type[t]:
lines.append(f" [{a['index']}] {a['path']}")
lines.append(f"{a['address']}")
else:
for a in addrs:
prof = a.get("profile")
if prof:
lines.append(f" [{a.get('index', 0)}] {a['path']} (profile={prof})")
else:
lines.append(f" [{a.get('index', 0)}] {a['path']}")
lines.append(f"{a['address']}")
lines.append("")
lines.append("=" * 80 + "\n")
return "\n".join(lines)
# -----------------------------------------------------------------------------
# Commands
# -----------------------------------------------------------------------------
def cmd_gen(args):
with NetworkGuard("gen"):
require_for_derive(False, args.chains)
import secrets
from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator
words_to_entropy = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256}
entropy_len = (words_to_entropy[args.words] // 8)
if args.dice_rolls:
rolls = args.dice_rolls.strip().split()
if not rolls or not all(r.isdigit() and 1 <= int(r) <= 6 for r in rolls):
raise ValueError("--dice-rolls must be space-separated integers 1-6")
dice_bytes = (" ".join(rolls)).encode("utf-8")
crypto_bytes = secrets.token_bytes(entropy_len)
entropy = hashlib.sha256(dice_bytes + crypto_bytes).digest()[:entropy_len]
else:
entropy = secrets.token_bytes(entropy_len)
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(entropy)
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "")
fp_with = get_master_fingerprint(seed_bytes)
print(f"📍 Generated {args.words}-word BIP39 mnemonic.")
if args.unsafe_print:
print(f"\nMnemonic:\n{mnemonic}\n")
result = derive_addresses(seed_bytes, args.chains, args.addresses, args.sol_profile)
if args.output == "json":
out_text = json.dumps({
"master_fingerprint": fp_with,
"solana_profile": args.sol_profile,
"addresses": result["addresses"],
}, indent=2, ensure_ascii=False)
else:
out_text = f"Master Fingerprint: {fp_with}\n\n" + format_addresses_human(result["addresses"])
print(out_text)
def cmd_recover(args):
with NetworkGuard("recover"):
require_for_derive(args.export_private, args.chains)
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
mnemonic = None
seed_hex = None
if args.interactive:
mode = input("Enter 'm' for mnemonic or 's' for seed: ").strip().lower()
if mode == "m":
mnemonic = getpass.getpass("BIP39 mnemonic (hidden): ").strip()
elif mode == "s":
seed_hex = getpass.getpass("Seed hex (hidden, 128 hex chars): ").strip()
else:
raise ValueError("Invalid choice")
elif args.mnemonic:
mnemonic = args.mnemonic.strip()
elif args.seed:
seed_hex = args.seed.strip()
else:
raise ValueError("Missing input")
if mnemonic:
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Invalid BIP39 mnemonic")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "")
else:
b = bytes.fromhex(seed_hex)
if len(b) != 64:
raise ValueError(f"Seed must be 64 bytes (128 hex chars), got {len(b)} bytes")
seed_bytes = b
fp_with = get_master_fingerprint(seed_bytes)
print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...")
print(f"Master Fingerprint: {fp_with}\n")
result = derive_addresses(
seed_bytes=seed_bytes,
chains=args.chains,
count=args.addresses,
sol_profile=args.sol_profile,
sol_match=(args.sol_match or "").strip(),
sol_scan=args.sol_scan,
export_private=args.export_private,
)
if args.output == "json":
out_text = json.dumps({
"master_fingerprint": fp_with,
"solana_profile": args.sol_profile,
"solana_match": (args.sol_match or "").strip(),
"addresses": result["addresses"],
}, indent=2, ensure_ascii=False)
else:
out_text = format_addresses_human(result["addresses"])
print(out_text)
def main():
parser = argparse.ArgumentParser(description="Offline HD wallet gen/recover + online fetchkey")
subparsers = parser.add_subparsers(dest="cmd")
p_fetch = subparsers.add_parser("fetchkey", help="Download ASCII-armored PGP pubkey from URL (online)")
p_fetch.add_argument("url", help="URL to fetch (e.g., https://github.com/<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_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()

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