init proj skeleton
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.vscode/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
.envrc
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.pyre/
|
||||||
|
.pytype/
|
||||||
|
.cache/
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.asc
|
||||||
174
playbook.md
Normal file
174
playbook.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
Below is a practical playbook you can save as `PLAYBOOK.md` next to `hdwallet_recovery.py`.
|
||||||
|
|
||||||
|
## Purpose (what this tool does)
|
||||||
|
- **Derive addresses** (ETH/SOL/BTC) from either a BIP39 mnemonic (+ optional passphrase) or a raw 64‑byte BIP39 seed hex.
|
||||||
|
- Optionally **encrypt a payload** to a PGP public key (ASCII armored) so secrets are not shown in plaintext on screen.
|
||||||
|
- `fetchkey` mode is the only mode that touches the network (downloads a PGP public key).
|
||||||
|
|
||||||
|
## Prerequisites (software + packages)
|
||||||
|
|
||||||
|
### OS / Python
|
||||||
|
- Use **Python 3.12** (recommended for compatibility with `bip_utils`, `PGPy`, etc.).
|
||||||
|
- macOS: install Python 3.12 via Homebrew and create a clean venv.
|
||||||
|
|
||||||
|
### Python packages
|
||||||
|
Install into a venv:
|
||||||
|
|
||||||
|
**Base (derive addresses + encrypt payload):**
|
||||||
|
- `bip-utils`
|
||||||
|
- `PGPy`
|
||||||
|
|
||||||
|
**Only needed if you use `--export-private` and include `solana`:**
|
||||||
|
- `PyNaCl`
|
||||||
|
- `base58`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -U pip
|
||||||
|
pip install bip-utils PGPy
|
||||||
|
pip install PyNaCl base58 # only if exporting Solana private keys
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files you need
|
||||||
|
- `hdwallet_recovery.py` (the script)
|
||||||
|
- `kccleoc.asc` (or any `*.asc`) = ASCII-armored **PGP public key** used to encrypt the payload
|
||||||
|
|
||||||
|
## Operating modes
|
||||||
|
|
||||||
|
### Mode A — fetchkey (online, no secrets allowed)
|
||||||
|
Use this to download a PGP public key from a URL and verify it.
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py fetchkey "https://github.com/<user>.gpg" --out key.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to check:**
|
||||||
|
- The script prints **SHA256** of the downloaded key and the **PGP fingerprint**.
|
||||||
|
- Independently verify the fingerprint matches the intended owner (don’t trust only the URL).
|
||||||
|
|
||||||
|
**Safety rule:**
|
||||||
|
- Never pass mnemonic/seed/passphrase flags together with `fetchkey`. The script should refuse.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Mode B — derive (offline, addresses only)
|
||||||
|
Derives addresses and prints them to stdout.
|
||||||
|
|
||||||
|
**Command (addresses only):**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py \
|
||||||
|
--mnemonic '... your words ...' \
|
||||||
|
--passphrase '' \
|
||||||
|
--chains ethereum solana bitcoin \
|
||||||
|
--addresses 10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- This prints addresses (safe) but still requires you to supply the mnemonic (sensitive) on the command line unless you use `--interactive`.
|
||||||
|
|
||||||
|
**Preferred (avoid shell history):**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py --interactive --chains ethereum solana bitcoin --addresses 10
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Mode C — derive + PGP encrypt payload (recommended)
|
||||||
|
Derives addresses, prints addresses, and prints an **encrypted PGP block** containing recovery material.
|
||||||
|
|
||||||
|
**Command (include mnemonic + passphrase):**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py \
|
||||||
|
--interactive \
|
||||||
|
--chains ethereum solana bitcoin \
|
||||||
|
--addresses 10 \
|
||||||
|
--pgp-pubkey-file key.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decrypt later:**
|
||||||
|
- Use your PGP private key (on a safe machine) to decrypt the PGP message.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### Mode D — derive + export private keys (encrypted only)
|
||||||
|
This is for when you need to import per-account keys into hot wallets (e.g., Phantom) but **don’t want to type the seed phrase into the app**.
|
||||||
|
|
||||||
|
**Behavior (as implemented):**
|
||||||
|
- Still prints addresses to stdout.
|
||||||
|
- Produces a PGP-encrypted payload that includes:
|
||||||
|
- the mnemonic (so you can fully recover later)
|
||||||
|
- a note that a passphrase was used (but not the passphrase)
|
||||||
|
- **Ethereum private keys** (hex) for indices `0..--addresses-1`
|
||||||
|
- **Solana Phantom-compatible private keys** (base58 64-byte secret key) for indices `0..--addresses-1`
|
||||||
|
- **Bitcoin: addresses only** (no BTC private keys)
|
||||||
|
|
||||||
|
**Command:**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py \
|
||||||
|
--interactive \
|
||||||
|
--chains ethereum solana bitcoin \
|
||||||
|
--addresses 10 \
|
||||||
|
--export-private \
|
||||||
|
--passphrase 'YOUR_PASSPHRASE' \
|
||||||
|
--passphrase-hint 'memory hint here' \
|
||||||
|
--pgp-pubkey-file key.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hot wallet import guidance:**
|
||||||
|
- Import only the specific derived account key you plan to treat as “hot”.
|
||||||
|
- Fund only that account/address.
|
||||||
|
- Assume the device/app is compromised eventually; rotate keys.
|
||||||
|
|
||||||
|
## Verification checklist (before trusting results)
|
||||||
|
- Confirm you’re using the expected Python and venv:
|
||||||
|
```bash
|
||||||
|
which python
|
||||||
|
python -V
|
||||||
|
pip show bip-utils PGPy
|
||||||
|
```
|
||||||
|
- Confirm the PGP public key fingerprint is correct (out-of-band verified).
|
||||||
|
- Confirm derived addresses match known wallet UI for the same mnemonic/passphrase (test with a small index range first).
|
||||||
|
|
||||||
|
## Security warnings (read this every time)
|
||||||
|
- **Never** run derive mode on a machine you don’t trust.
|
||||||
|
- Avoid passing mnemonics on the command line (`--mnemonic '...'`) because:
|
||||||
|
- shell history may capture it
|
||||||
|
- process lists can expose arguments
|
||||||
|
- Prefer `--interactive` so the mnemonic is hidden input.
|
||||||
|
- The encrypted payload printed to screen can still be:
|
||||||
|
- copied into scrollback logs
|
||||||
|
- captured by screen recording / monitoring
|
||||||
|
- saved by terminal multiplexer logs
|
||||||
|
Treat it as sensitive, even if encrypted.
|
||||||
|
- If `--export-private` is used, the encrypted payload contains a **bundle of hot private keys**. Anyone who decrypts it controls those accounts. Keep it offline and limit distribution.
|
||||||
|
- If a **passphrase** was used, losing it makes recovery impossible even with mnemonic and derived-address list. Store the passphrase separately and securely; the payload only stores a hint/reminder.
|
||||||
|
- Consider using a dedicated “hot” seed (separate mnemonic) for accounts intended for hot-wallet import, rather than exporting keys derived from your main long-term seed.
|
||||||
|
|
||||||
|
## Quick command recipes
|
||||||
|
|
||||||
|
**1) Download and pin a key:**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py fetchkey "https://github.com/<user>.gpg" --out key.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
**2) Offline derive addresses (no encryption):**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py --interactive --chains ethereum solana bitcoin --addresses 10
|
||||||
|
```
|
||||||
|
|
||||||
|
**3) Offline derive + encrypt payload (no private key export):**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py --interactive --chains ethereum solana bitcoin --addresses 10 --pgp-pubkey-file key.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
**4) Offline derive + encrypt payload + export ETH/SOL private keys:**
|
||||||
|
```bash
|
||||||
|
python hdwallet_recovery.py --interactive --chains ethereum solana bitcoin --addresses 10 --export-private --passphrase-hint '...' --pgp-pubkey-file key.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want, the playbook can be turned into a `Makefile` (targets: `venv`, `fetchkey`, `derive`, `export`) so you don’t have to remember flags.
|
||||||
|
|
||||||
|
[1](https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files/attachments/50321846/61044779-f904-4af9-9005-77f3f639e4d6/offline_HD_wallet_generator.html)
|
||||||
1
requirements.in
Normal file
1
requirements.in
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PGPy
|
||||||
16
requirements.txt
Normal file
16
requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.11
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile ./requirements.in
|
||||||
|
#
|
||||||
|
cffi==2.0.0
|
||||||
|
# via cryptography
|
||||||
|
cryptography==46.0.3
|
||||||
|
# via pgpy
|
||||||
|
pgpy==0.6.0
|
||||||
|
# via -r requirements.in
|
||||||
|
pyasn1==0.6.1
|
||||||
|
# via pgpy
|
||||||
|
pycparser==2.23
|
||||||
|
# via cffi
|
||||||
409
src/hdwallet_recovery.py
Normal file
409
src/hdwallet_recovery.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
hdwallet_recovery.py (Python 3.12)
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
- fetchkey (online): download ASCII-armored PGP pubkey, print SHA256 + fingerprint
|
||||||
|
- derive (default): derive ADDRESSES ONLY for ETH/SOL/BTC
|
||||||
|
- optionally encrypt a secret payload using a PGP public key
|
||||||
|
|
||||||
|
Security invariant:
|
||||||
|
- derive never performs network I/O
|
||||||
|
- fetchkey refuses mnemonic/seed/passphrase inputs
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import getpass
|
||||||
|
import hashlib
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Dependency checks
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _require(mod: str, pkg: str):
|
||||||
|
try:
|
||||||
|
__import__(mod)
|
||||||
|
except ImportError:
|
||||||
|
print(f"❌ Missing dependency: {pkg}", file=sys.stderr)
|
||||||
|
print(f"Install with: pip install {pkg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def require_for_fetchkey():
|
||||||
|
_require("pgpy", "PGPy")
|
||||||
|
|
||||||
|
|
||||||
|
def require_for_derive(export_private: bool, chains: List[str]):
|
||||||
|
_require("bip_utils", "bip-utils")
|
||||||
|
_require("pgpy", "PGPy")
|
||||||
|
if export_private and ("solana" in chains):
|
||||||
|
_require("nacl", "PyNaCl")
|
||||||
|
_require("base58", "base58")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# PGP helpers (PGPy)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pgp_load_pubkey(armored: str):
|
||||||
|
import pgpy
|
||||||
|
key, _ = pgpy.PGPKey.from_blob(armored)
|
||||||
|
if not key.is_public:
|
||||||
|
raise ValueError("Provided key is not a public key")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def pgp_fingerprint(armored: str) -> str:
|
||||||
|
return pgp_load_pubkey(armored).fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def pgp_encrypt_ascii_armored(pubkey_armored: str, payload: Dict[str, Any]) -> str:
|
||||||
|
import pgpy
|
||||||
|
pub_key = pgp_load_pubkey(pubkey_armored)
|
||||||
|
msg = pgpy.PGPMessage.new(json.dumps(payload, indent=2))
|
||||||
|
enc = pub_key.encrypt(msg)
|
||||||
|
return str(enc)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# fetchkey (network allowed ONLY here)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fetch_ascii_armored_text(url: str, timeout: int = 15) -> str:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "hdwallet-recovery/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
text = data.decode("utf-8", errors="strict")
|
||||||
|
if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text:
|
||||||
|
raise ValueError("Downloaded content does not look like an ASCII-armored PGP public key")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_hex_text(s: str) -> str:
|
||||||
|
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int):
|
||||||
|
require_for_fetchkey()
|
||||||
|
|
||||||
|
armored = fetch_ascii_armored_text(url, timeout=timeout)
|
||||||
|
s256 = sha256_hex_text(armored)
|
||||||
|
fpr = pgp_fingerprint(armored)
|
||||||
|
|
||||||
|
if out_path:
|
||||||
|
with open(out_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(armored)
|
||||||
|
|
||||||
|
print("✅ Downloaded PGP public key")
|
||||||
|
print(f"URL: {url}")
|
||||||
|
if out_path:
|
||||||
|
print(f"Saved: {out_path}")
|
||||||
|
print(f"SHA256: {s256}")
|
||||||
|
print(f"Fingerprint: {fpr}")
|
||||||
|
|
||||||
|
if not out_path:
|
||||||
|
print("\n-----BEGIN DOWNLOADED KEY-----")
|
||||||
|
print(armored.strip())
|
||||||
|
print("-----END DOWNLOADED KEY-----")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Derivation
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AddrOut:
|
||||||
|
index: int
|
||||||
|
path: str
|
||||||
|
address: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BtcAddrOut:
|
||||||
|
index: int
|
||||||
|
path: str
|
||||||
|
address_type: str
|
||||||
|
address: str
|
||||||
|
|
||||||
|
|
||||||
|
def _solana_phantom_export_from_seed32(seed32: bytes) -> Dict[str, Any]:
|
||||||
|
# Phantom-compatible 64-byte secret key = seed32 || pubkey32 (ed25519).
|
||||||
|
# PyNaCl SigningKey(seed32) supports 32-byte seeds.
|
||||||
|
from nacl.signing import SigningKey
|
||||||
|
import base58
|
||||||
|
|
||||||
|
sk = SigningKey(seed32)
|
||||||
|
pub32 = sk.verify_key.encode()
|
||||||
|
secret64 = seed32 + pub32
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phantom_base58": base58.b58encode(secret64).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def derive_addresses_and_maybe_secrets(seed_bytes: bytes, chains: List[str], count: int, export_private: bool) -> Dict[str, Any]:
|
||||||
|
from bip_utils import (
|
||||||
|
Bip44, Bip44Coins, Bip44Changes,
|
||||||
|
Bip49, Bip49Coins,
|
||||||
|
Bip84, Bip84Coins,
|
||||||
|
)
|
||||||
|
|
||||||
|
out: Dict[str, Any] = {"addresses": {}}
|
||||||
|
if export_private:
|
||||||
|
out["secrets"] = {}
|
||||||
|
|
||||||
|
# ETH (BIP44 m/44'/60'/0'/0/i)
|
||||||
|
if "ethereum" in chains:
|
||||||
|
root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
|
||||||
|
ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
|
||||||
|
addrs: List[Dict[str, Any]] = []
|
||||||
|
secrets: List[Dict[str, Any]] = []
|
||||||
|
for i in range(count):
|
||||||
|
node = ctx.AddressIndex(i)
|
||||||
|
addrs.append({"index": i, "path": f"m/44'/60'/0'/0/{i}", "address": node.PublicKey().ToAddress()})
|
||||||
|
if export_private:
|
||||||
|
secrets.append({
|
||||||
|
"index": i,
|
||||||
|
"path": f"m/44'/60'/0'/0/{i}",
|
||||||
|
"privkey_hex": node.PrivateKey().Raw().ToHex(),
|
||||||
|
})
|
||||||
|
|
||||||
|
out["addresses"]["ethereum"] = addrs
|
||||||
|
if export_private and secrets:
|
||||||
|
out["secrets"]["ethereum"] = secrets
|
||||||
|
|
||||||
|
# SOL (BIP44 m/44'/501'/0'/0'/i')
|
||||||
|
if "solana" in chains:
|
||||||
|
root = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
|
||||||
|
ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
|
||||||
|
addrs: List[Dict[str, Any]] = []
|
||||||
|
secrets: List[Dict[str, Any]] = []
|
||||||
|
for i in range(count):
|
||||||
|
node = ctx.AddressIndex(i)
|
||||||
|
addrs.append({"index": i, "path": f"m/44'/501'/0'/0'/{i}'", "address": node.PublicKey().ToAddress()})
|
||||||
|
if export_private:
|
||||||
|
seed32 = node.PrivateKey().Raw().ToBytes()
|
||||||
|
if len(seed32) != 32:
|
||||||
|
raise ValueError(f"Unexpected Solana private key length from bip_utils: {len(seed32)} bytes")
|
||||||
|
secrets.append({
|
||||||
|
"index": i,
|
||||||
|
"path": f"m/44'/501'/0'/0'/{i}'",
|
||||||
|
"phantom": _solana_phantom_export_from_seed32(seed32),
|
||||||
|
})
|
||||||
|
|
||||||
|
out["addresses"]["solana"] = addrs
|
||||||
|
if export_private and secrets:
|
||||||
|
out["secrets"]["solana"] = secrets
|
||||||
|
|
||||||
|
# BTC (addresses only even with --export-private)
|
||||||
|
if "bitcoin" in chains:
|
||||||
|
addrs: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
r44 = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN)
|
||||||
|
c44 = r44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
|
||||||
|
r49 = Bip49.FromSeed(seed_bytes, Bip49Coins.BITCOIN)
|
||||||
|
c49 = r49.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
|
||||||
|
r84 = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
|
||||||
|
c84 = r84.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
n84 = c84.AddressIndex(i)
|
||||||
|
n49 = c49.AddressIndex(i)
|
||||||
|
n44 = c44.AddressIndex(i)
|
||||||
|
|
||||||
|
addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": n84.PublicKey().ToAddress()})
|
||||||
|
addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": n49.PublicKey().ToAddress()})
|
||||||
|
addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": n44.PublicKey().ToAddress()})
|
||||||
|
|
||||||
|
out["addresses"]["bitcoin"] = addrs
|
||||||
|
|
||||||
|
# Don't create empty secrets dicts
|
||||||
|
if export_private and "secrets" in out:
|
||||||
|
out["secrets"] = {k: v for k, v in out["secrets"].items() if v}
|
||||||
|
if not out["secrets"]:
|
||||||
|
del out["secrets"]
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def format_addresses_human(result: Dict[str, Any]) -> str:
|
||||||
|
lines: List[str] = []
|
||||||
|
lines.append("\n" + "=" * 80)
|
||||||
|
lines.append("MULTI-CHAIN ADDRESS DERIVATION (ADDRESSES ONLY)")
|
||||||
|
lines.append("=" * 80 + "\n")
|
||||||
|
|
||||||
|
for chain, addrs in result["addresses"].items():
|
||||||
|
lines.append("─" * 80)
|
||||||
|
lines.append(f"{chain.upper()} ADDRESSES")
|
||||||
|
lines.append("─" * 80)
|
||||||
|
|
||||||
|
if chain == "bitcoin":
|
||||||
|
by_type: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for a in addrs:
|
||||||
|
by_type.setdefault(a["address_type"], []).append(a)
|
||||||
|
for t in ["native_segwit", "segwit", "legacy"]:
|
||||||
|
if t in by_type:
|
||||||
|
lines.append(f"\n {t.upper()}:")
|
||||||
|
for a in by_type[t]:
|
||||||
|
lines.append(f" [{a['index']}] {a['path']}")
|
||||||
|
lines.append(f" → {a['address']}")
|
||||||
|
else:
|
||||||
|
for a in addrs:
|
||||||
|
lines.append(f" [{a['index']}] {a['path']}")
|
||||||
|
lines.append(f" → {a['address']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("=" * 80 + "\n")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_derive(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
|
||||||
|
|
||||||
|
print(f"📍 Deriving {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":
|
||||||
|
out_text = json.dumps({"addresses": result["addresses"]}, indent=2)
|
||||||
|
else:
|
||||||
|
out_text = 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Always include mnemonic/seed_hex if present (your requirement)
|
||||||
|
if mnemonic:
|
||||||
|
payload["mnemonic"] = mnemonic
|
||||||
|
else:
|
||||||
|
payload["seed_hex"] = seed_hex
|
||||||
|
|
||||||
|
if args.export_private:
|
||||||
|
payload["passphrase_set"] = bool(args.passphrase)
|
||||||
|
payload["passphrase_hint"] = args.passphrase_hint or ""
|
||||||
|
payload["note"] = "Private keys were derived from mnemonic/seed + (optional) passphrase. Passphrase value is intentionally omitted."
|
||||||
|
payload["derived_private_keys"] = result.get("secrets", {})
|
||||||
|
payload["addresses"] = result["addresses"] # include for verification
|
||||||
|
else:
|
||||||
|
payload["passphrase"] = args.passphrase or ""
|
||||||
|
|
||||||
|
armored = pgp_encrypt_ascii_armored(pgp_pub, payload)
|
||||||
|
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
|
||||||
|
print(armored)
|
||||||
|
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="HD wallet recovery (addresses only) + fetchkey helper")
|
||||||
|
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")
|
||||||
|
|
||||||
|
# derive mode args (default when no subcommand is given)
|
||||||
|
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", default="", help="BIP39 passphrase (optional)")
|
||||||
|
parser.add_argument("--passphrase-hint", default="", help="Hint/reminder for passphrase (stored only in encrypted payload when --export-private)")
|
||||||
|
parser.add_argument("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"],
|
||||||
|
default=["ethereum", "solana", "bitcoin"])
|
||||||
|
parser.add_argument("--addresses", type=int, default=5)
|
||||||
|
parser.add_argument("--output", choices=["text", "json"], default="text")
|
||||||
|
parser.add_argument("--file", help="Save address output to file", default=None)
|
||||||
|
parser.add_argument("--pgp-pubkey-file", help="ASCII-armored PGP public key file to encrypt secrets", default=None)
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
if forbidden:
|
||||||
|
raise ValueError("fetchkey mode must not be used with mnemonic/seed/passphrase/export-private options")
|
||||||
|
cmd_fetchkey(args.url, args.out, args.timeout)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (args.mnemonic or args.seed or args.interactive):
|
||||||
|
raise ValueError("Provide --mnemonic or --seed or --interactive (or use subcommand fetchkey)")
|
||||||
|
|
||||||
|
cmd_derive(args)
|
||||||
|
|
||||||
|
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