v1.0.4: Fix bugs in recover input validation and secure mode file handling, clean up _toDelete folder

This commit is contained in:
LC
2026-01-05 16:12:06 +00:00
parent b9ab22c131
commit a4f188af36
4 changed files with 10 additions and 1179 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ logs/
*.log
coverage/
_toDelete/
_toDelete/
dist/
build/
*.egg-info/

View File

@@ -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/<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]:
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()

View File

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

View File

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