Update project files: move recovery script to _toDelete and add new wallet scripts
This commit is contained in:
@@ -12,6 +12,10 @@ Security invariant:
|
|||||||
- recover never performs network I/O
|
- recover never performs network I/O
|
||||||
- fetchkey refuses mnemonic/seed/passphrase inputs
|
- fetchkey refuses mnemonic/seed/passphrase inputs
|
||||||
- gen generates secure mnemonics compliant with BIP39
|
- 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 argparse
|
||||||
@@ -19,9 +23,10 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import getpass
|
import getpass
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Any, Optional
|
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]):
|
def require_for_derive(export_private: bool, chains: List[str]):
|
||||||
_require("bip_utils", "bip-utils")
|
_require("bip_utils", "bip-utils")
|
||||||
_require("pgpy", "PGPy")
|
_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("nacl", "PyNaCl")
|
||||||
_require("base58", "base58")
|
_require("base58", "base58")
|
||||||
|
|
||||||
@@ -144,7 +151,6 @@ class BtcAddrOut:
|
|||||||
|
|
||||||
def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]:
|
def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]:
|
||||||
# Phantom-compatible 64-byte secret key = seed32 || pubkey32 (ed25519).
|
# Phantom-compatible 64-byte secret key = seed32 || pubkey32 (ed25519).
|
||||||
# PyNaCl SigningKey(seed32) supports 32-byte seeds.
|
|
||||||
from nacl.signing import SigningKey
|
from nacl.signing import SigningKey
|
||||||
import base58
|
import base58
|
||||||
|
|
||||||
@@ -152,9 +158,74 @@ def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]:
|
|||||||
pub32 = sk.verify_key.encode()
|
pub32 = sk.verify_key.encode()
|
||||||
secret64 = seed32 + pub32
|
secret64 = seed32 + pub32
|
||||||
|
|
||||||
return {
|
return {"phantom_base58": base58.b58encode(secret64).decode("ascii")}
|
||||||
"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]:
|
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:
|
if export_private and secrets:
|
||||||
out["secrets"]["ethereum"] = 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:
|
if "solana" in chains:
|
||||||
root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
|
from nacl.signing import SigningKey
|
||||||
ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
|
import base58
|
||||||
|
|
||||||
addrs: List[Dict[str, Any]] = []
|
addrs: List[Dict[str, Any]] = []
|
||||||
secrets: List[Dict[str, Any]] = []
|
secrets: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
node = ctx.AddressIndex(i)
|
path = f"m/44'/501'/{i}'/0'"
|
||||||
addrs.append({"index": i, "path": f"m/44'/501'/0'/0'/{i}'", "address": node.PublicKey().ToAddress()})
|
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:
|
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({
|
secrets.append({
|
||||||
"index": i,
|
"index": i,
|
||||||
"path": f"m/44'/501'/0'/0'/{i}'",
|
"path": path,
|
||||||
"phantom": _solana_phantom_export_from_seed32(seed32),
|
"phantom": _solana_phantom_export_from_seed32(seed32),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -291,7 +367,7 @@ def cmd_gen(args):
|
|||||||
rolls = args.dice_rolls.strip().split()
|
rolls = args.dice_rolls.strip().split()
|
||||||
if not rolls or not all(r.isdigit() and 1 <= int(r) <= 6 for r in rolls):
|
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")
|
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)
|
crypto_bytes = secrets.token_bytes(entropy_bytes_len)
|
||||||
combined = dice_bytes + crypto_bytes
|
combined = dice_bytes + crypto_bytes
|
||||||
entropy = hashlib.sha256(combined).digest()[:entropy_bytes_len]
|
entropy = hashlib.sha256(combined).digest()[:entropy_bytes_len]
|
||||||
@@ -308,6 +384,7 @@ def cmd_gen(args):
|
|||||||
if args.passphrase:
|
if args.passphrase:
|
||||||
seed_without = Bip39SeedGenerator(mnemonic).Generate("")
|
seed_without = Bip39SeedGenerator(mnemonic).Generate("")
|
||||||
fingerprint_without = get_master_fingerprint(seed_without)
|
fingerprint_without = get_master_fingerprint(seed_without)
|
||||||
|
|
||||||
result = derive_addresses_and_maybe_secrets(seed_bytes, args.chains, args.addresses, False)
|
result = derive_addresses_and_maybe_secrets(seed_bytes, args.chains, args.addresses, False)
|
||||||
|
|
||||||
if args.output == "json":
|
if args.output == "json":
|
||||||
@@ -315,7 +392,7 @@ def cmd_gen(args):
|
|||||||
"mnemonic": mnemonic,
|
"mnemonic": mnemonic,
|
||||||
"passphrase_set": bool(args.passphrase),
|
"passphrase_set": bool(args.passphrase),
|
||||||
"master_fingerprint": fingerprint_with,
|
"master_fingerprint": fingerprint_with,
|
||||||
"addresses": result["addresses"]
|
"addresses": result["addresses"],
|
||||||
}
|
}
|
||||||
if fingerprint_without:
|
if fingerprint_without:
|
||||||
data["master_fingerprint_no_passphrase"] = fingerprint_without
|
data["master_fingerprint_no_passphrase"] = fingerprint_without
|
||||||
@@ -327,7 +404,11 @@ def cmd_gen(args):
|
|||||||
if fingerprint_without:
|
if fingerprint_without:
|
||||||
fp_text += f"\nMaster Fingerprint (no passphrase): {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 ""
|
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)
|
print(out_text)
|
||||||
|
|
||||||
@@ -347,7 +428,7 @@ def cmd_gen(args):
|
|||||||
"passphrase": args.passphrase or "",
|
"passphrase": args.passphrase or "",
|
||||||
"master_fingerprint": fingerprint_with,
|
"master_fingerprint": fingerprint_with,
|
||||||
"dice_rolls_used": bool(args.dice_rolls),
|
"dice_rolls_used": bool(args.dice_rolls),
|
||||||
"addresses": result["addresses"]
|
"addresses": result["addresses"],
|
||||||
}
|
}
|
||||||
if fingerprint_without:
|
if fingerprint_without:
|
||||||
payload["master_fingerprint_no_passphrase"] = 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["passphrase_hint"] = args.passphrase_hint or ""
|
||||||
payload["note"] = "Private keys were derived from mnemonic/seed + (optional) passphrase. Passphrase value is intentionally omitted."
|
payload["note"] = "Private keys were derived from mnemonic/seed + (optional) passphrase. Passphrase value is intentionally omitted."
|
||||||
payload["derived_private_keys"] = result.get("secrets", {})
|
payload["derived_private_keys"] = result.get("secrets", {})
|
||||||
payload["addresses"] = result["addresses"] # include for verification
|
payload["addresses"] = result["addresses"]
|
||||||
else:
|
else:
|
||||||
payload["passphrase"] = args.passphrase or ""
|
payload["passphrase"] = args.passphrase or ""
|
||||||
|
|
||||||
@@ -457,33 +538,28 @@ def cmd_recover(args):
|
|||||||
|
|
||||||
def cmd_test(args):
|
def cmd_test(args):
|
||||||
require_for_derive(False, ["ethereum"]) # minimal
|
require_for_derive(False, ["ethereum"]) # minimal
|
||||||
|
|
||||||
from bip_utils import Bip39SeedGenerator, Bip39MnemonicValidator
|
from bip_utils import Bip39SeedGenerator, Bip39MnemonicValidator
|
||||||
|
|
||||||
print("🧪 Running Trezor BIP39/BIP32 test vectors...")
|
print("🧪 Running Trezor BIP39/BIP32 test vectors...")
|
||||||
|
|
||||||
# Trezor vector with passphrase
|
|
||||||
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
passphrase = "crypto"
|
passphrase = "crypto"
|
||||||
expected_seed_hex = "92c58d3f4fe52f0111d314f3fa8f10ba498751c37e7c36475c2a5b60145b29708576e11bf6c5c46efac1add0cc26e07c69eb65476742082f9c6460f132181cfe"
|
expected_seed_hex = "92c58d3f4fe52f0111d314f3fa8f10ba498751c37e7c36475c2a5b60145b29708576e11bf6c5c46efac1add0cc26e07c69eb65476742082f9c6460f132181cfe"
|
||||||
expected_fp = "AECCC350"
|
expected_fp = "AECCC350"
|
||||||
|
|
||||||
tests = [
|
tests = [{
|
||||||
{
|
"mnemonic": mnemonic,
|
||||||
"mnemonic": mnemonic,
|
"passphrase": passphrase,
|
||||||
"passphrase": passphrase,
|
"expected_seed_hex": expected_seed_hex,
|
||||||
"expected_seed_hex": expected_seed_hex,
|
"expected_fp": expected_fp,
|
||||||
"expected_fp": expected_fp
|
}]
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
all_passed = True
|
all_passed = True
|
||||||
|
|
||||||
for i, test in enumerate(tests, 1):
|
for i, test in enumerate(tests, 1):
|
||||||
print(f"\nTest {i}: With passphrase '{test['passphrase']}'")
|
print(f"\nTest {i}: With passphrase '{test['passphrase']}'")
|
||||||
|
|
||||||
if not Bip39MnemonicValidator().IsValid(test["mnemonic"]):
|
if not Bip39MnemonicValidator().IsValid(test["mnemonic"]):
|
||||||
print(f"❌ Invalid mnemonic")
|
print("❌ Invalid mnemonic")
|
||||||
all_passed = False
|
all_passed = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -494,7 +570,7 @@ def cmd_test(args):
|
|||||||
print(f"❌ Seed mismatch: got {seed_hex}, expected {test['expected_seed_hex']}")
|
print(f"❌ Seed mismatch: got {seed_hex}, expected {test['expected_seed_hex']}")
|
||||||
all_passed = False
|
all_passed = False
|
||||||
else:
|
else:
|
||||||
print(f"✅ Seed correct")
|
print("✅ Seed correct")
|
||||||
|
|
||||||
fp = get_master_fingerprint(seed_bytes)
|
fp = get_master_fingerprint(seed_bytes)
|
||||||
if fp != test["expected_fp"]:
|
if fp != test["expected_fp"]:
|
||||||
@@ -529,20 +605,20 @@ def main():
|
|||||||
p_recover = subparsers.add_parser("recover", help="Derive addresses from mnemonic/seed")
|
p_recover = subparsers.add_parser("recover", help="Derive addresses from mnemonic/seed")
|
||||||
|
|
||||||
# Common args for gen and recover
|
# 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("--passphrase", default="", help="BIP39 passphrase (optional)")
|
||||||
p.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
|
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("--addresses", type=int, default=5)
|
||||||
p.add_argument("--output", choices=["text", "json"], default="text")
|
p.add_argument("--output", choices=["text", "json"], default="text")
|
||||||
p.add_argument("--file", help="Save output to file", default=None)
|
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)
|
p.add_argument("--pgp-pubkey-file", help="ASCII-armored PGP public key file to encrypt payload", default=None)
|
||||||
|
|
||||||
# Gen specific
|
# 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")
|
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("--mnemonic", help="BIP39 mnemonic (12/24 words)")
|
||||||
parser.add_argument("--seed", help="64-byte seed hex (128 hex chars)")
|
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("--interactive", action="store_true", help="Prompt for input via stdin")
|
||||||
@@ -571,7 +647,13 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
elif args.cmd == "gen":
|
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")
|
raise ValueError("gen mode must not be used with mnemonic/seed/interactive/export-private/passphrase-hint options")
|
||||||
cmd_gen(args)
|
cmd_gen(args)
|
||||||
return
|
return
|
||||||
520
_toDelete/mypywallet.py
Normal file
520
_toDelete/mypywallet.py
Normal 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
604
src/hdwallet_t.py
Normal 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()
|
||||||
Reference in New Issue
Block a user