patch the recover logic with interactive mode

This commit is contained in:
LC mac
2026-01-07 01:45:35 +08:00
parent ccd070dc56
commit 875fa17d6c
2 changed files with 327 additions and 143 deletions

View File

@@ -0,0 +1,204 @@
diff --git a/src/pyhdwallet.py b/src/pyhdwallet.py
index 1111111..2222222 100755
--- a/src/pyhdwallet.py
+++ b/src/pyhdwallet.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-pyhdwallet v1.0.4 (Python 3.11+)
+pyhdwallet v1.0.4 (Python 3.11+)
Commands:
- fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint
@@ -33,6 +33,7 @@ import tempfile
import urllib.request
import warnings
from pathlib import Path
+from typing import Iterable
from typing import Any, Dict, List, Optional, Tuple
# -----------------------------------------------------------------------------
@@ -112,6 +113,123 @@ def gen_base58_password(length: int) -> str:
return "".join(secrets.choice(alphabet) for _ in range(length))
+# -----------------------------------------------------------------------------
+# Recover input helpers (interactive + stdin)
+# -----------------------------------------------------------------------------
+
+
+def _prompt_hidden_or_visible(prompt: str) -> str:
+ """
+ Prompt for sensitive input.
+
+ Prefer getpass (no echo). If getpass can't reliably hide input in the current
+ environment, warn and fall back to visible input (user-selected behavior B).
+ """
+ try:
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ s = getpass.getpass(prompt)
+ if w:
+ print("⚠️ WARNING: getpass could not reliably hide input in this environment.", file=sys.stderr)
+ print("⚠️ Falling back to visible input. Use a real terminal for hidden input.", file=sys.stderr)
+ return input(prompt.replace("(hidden)", "(VISIBLE)"))
+ return s
+ except (Exception, KeyboardInterrupt):
+ print("⚠️ WARNING: Hidden prompt failed; falling back to visible input.", file=sys.stderr)
+ return input(prompt.replace("(hidden)", "(VISIBLE)"))
+
+
+def _read_stdin_all() -> str:
+ # Read all of stdin (for piping from secure sources).
+ return sys.stdin.read().strip()
+
+
+def _bip39_english_wordset() -> set[str]:
+ """
+ Load BIP39 English wordlist using bip_utils built-in word list getter.
+ """
+ from bip_utils import Bip39Languages, Bip39WordsListGetter
+
+ wl = Bip39WordsListGetter.GetByLanguage(Bip39Languages.ENGLISH)
+ # wl provides the official 2048-word list via bip_utils. [web:342]
+ return set(wl.GetWordList())
+
+
+def _prompt_word_count() -> int:
+ valid_counts = {12, 15, 18, 21, 24}
+ while True:
+ raw = input("Word count (12/15/18/21/24): ").strip()
+ if raw.isdigit() and int(raw) in valid_counts:
+ return int(raw)
+ print("❌ Invalid word count. Choose 12/15/18/21/24.")
+
+
+def interactive_mnemonic_word_by_word() -> str:
+ """
+ Guided mnemonic entry (English-only):
+ - choose word count
+ - enter each word with membership validation (✅/❌)
+ - final checksum validation via Bip39MnemonicValidator
+ """
+ from bip_utils import Bip39MnemonicValidator
+
+ wordset = _bip39_english_wordset()
+ n = _prompt_word_count()
+
+ words: List[str] = []
+ i = 1
+ while i <= n:
+ w = _prompt_hidden_or_visible(f"{i}) Enter word (hidden): ").strip().lower()
+ if w in wordset:
+ print(f"{i}) ✅")
+ words.append(w)
+ i += 1
+ else:
+ print(f"{i}) ❌ Invalid BIP39 English word. Try again.")
+
+ mnemonic = " ".join(words)
+ if not Bip39MnemonicValidator().IsValid(mnemonic):
+ raise ValueError("Mnemonic checksum/format invalid (words valid but checksum failed).")
+ return mnemonic
+
+
# -----------------------------------------------------------------------------
# Fingerprint
# -----------------------------------------------------------------------------
@@ -474,39 +592,52 @@ def cmd_recover(args) -> None:
if args.off_screen:
print("⚠️ Off-screen mode enabled: Sensitive data will not be printed to stdout.")
if args.export_private and not args.pgp_pubkey_file:
raise ValueError("--export-private requires --pgp-pubkey-file")
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
- # Validate input methods
- input_methods = [bool(args.mnemonic), bool(args.seed), args.interactive]
- if sum(input_methods) > 1:
- raise ValueError("Provide only one of --mnemonic, --seed, or --interactive")
- if sum(input_methods) == 0:
- raise ValueError("Missing input (use --mnemonic/--seed/--interactive)")
-
mnemonic: Optional[str] = None
seed_hex: Optional[str] = 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()
- else:
- seed_hex = args.seed.strip()
+ # New input model: exactly one of:
+ # --interactive (guided mnemonic only, English-only)
+ # --mnemonic-stdin (read full phrase from stdin)
+ # --seed-stdin (read seed hex from stdin)
+ if args.interactive:
+ mnemonic = interactive_mnemonic_word_by_word()
+ elif args.mnemonic_stdin:
+ mnemonic = _read_stdin_all()
+ if not mnemonic:
+ raise ValueError("Empty stdin for --mnemonic-stdin")
+ elif args.seed_stdin:
+ seed_hex = _read_stdin_all()
+ if not seed_hex:
+ raise ValueError("Empty stdin for --seed-stdin")
+ else:
+ raise ValueError("Missing input mode (use --interactive / --mnemonic-stdin / --seed-stdin)")
passphrase = getpass.getpass("Enter BIP39 passphrase (hidden): ") if args.passphrase else ""
if mnemonic:
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Invalid BIP39 mnemonic")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
else:
b = bytes.fromhex(seed_hex or "")
if len(b) != 64:
raise ValueError("Invalid seed hex (expected 64 bytes / 128 hex chars)")
seed_bytes = b
fp = get_master_fingerprint(seed_bytes)
@@ -676,18 +807,30 @@ def build_parser() -> argparse.ArgumentParser:
# 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")
+
+ # New safer input model for recover:
+ # - No plaintext secrets in CLI args.
+ # - Use guided interactive entry or stdin.
+ g = p_rec.add_mutually_exclusive_group(required=True)
+ g.add_argument(
+ "--interactive",
+ action="store_true",
+ help="Guided mnemonic entry (English-only, per-word validation)",
+ )
+ g.add_argument(
+ "--mnemonic-stdin",
+ dest="mnemonic_stdin",
+ action="store_true",
+ help="Read BIP39 mnemonic from stdin (non-interactive)",
+ )
+ g.add_argument(
+ "--seed-stdin",
+ dest="seed_stdin",
+ action="store_true",
+ help="Read seed hex (128 hex chars) from stdin (non-interactive)",
+ )
# test
p_test = sub.add_parser("test", help="Run minimal offline tests")
p_test.add_argument("--off-screen", action="store_true", help="Enable off-screen mode: no printing of sensitive data to stdout.")
return parser

View File

@@ -1,30 +1,32 @@
#!/usr/bin/env python3
"""
pyhdwallet v1.0.4 (Python 3.11+)
pyhdwallet v1.0.5 (Python 3.11+)
Commands:
- fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint
- gen (offline): generate BIP39 mnemonic; derive addresses; optionally encrypt payload; optional AES-zip file output
- recover (offline): derive addresses from mnemonic/seed; optionally encrypt payload; optional AES-zip file output
- recover (offline): derive addresses from mnemonic ONLY; optionally encrypt payload; optional AES-zip file output
- test (offline): minimal self-test
Behavior changes (requested):
- Removed --unsafe-print: gen prints mnemonic by default to stdout (human-readable).
- Removed --output: file payload is always JSON when unencrypted, or ASCII-armored PGP message (.asc) when encrypted.
- --file is a boolean: if present, write ONLY an AES-encrypted ZIP in ./ .wallet (or --wallet-location).
Behavior (current):
- gen prints mnemonic by default to stdout (human-readable) unless --off-screen.
- --file is a boolean: if present, write ONLY an AES-encrypted ZIP in ./.wallet (or --wallet-location).
No raw .json/.asc is left on disk (written to zip from memory).
- UTC timestamps used in deterministic names:
- test_wallet_YYYYMMDD_HHMMSSZ.json (no PGP)
- encrypted_wallet_YYYYMMDD_HHMMSSZ.asc (with PGP)
- zipped container: *_YYYYMMDD_HHMMSSZ.zip
- ZIP password is prompted via getpass; if echo-hiding fails, fallback to input() with loud warning.
- When stdout is not a TTY, refuse to print sensitive data unless --force is provided.
Recover input policy (v1.0.5):
- Seed recovery removed (mnemonic only).
- No plaintext mnemonic via CLI args.
- Use either:
- --interactive (guided word-by-word entry, English-only, ✅/❌ validation)
- --mnemonic-stdin (read full mnemonic from stdin; validate once)
Notes:
- "Clear screen" is intentionally NOT used; it does not reliably remove secrets from logs/scrollback.
- ZIP encryption requires dependency: pyzipper (AES). [web:102]
"""
from __future__ import annotations
import argparse
@@ -40,6 +42,7 @@ import warnings
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# -----------------------------------------------------------------------------
# Dependency checks
# -----------------------------------------------------------------------------
@@ -67,7 +70,6 @@ def require_for_offline(chains: List[str]) -> None:
def require_for_zip() -> None:
# AES ZIP creation not supported by stdlib zipfile; use pyzipper. [web:102]
_require("pyzipper", "pyzipper")
@@ -103,12 +105,10 @@ class NetworkGuard:
def utc_timestamp_compact() -> str:
# 20260106_134501Z
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%SZ")
def default_wallet_dir() -> Path:
# Local project folder (repo-friendly). User can override via --wallet-location.
return Path.cwd() / ".wallet"
@@ -117,7 +117,6 @@ def ensure_dir(p: Path) -> None:
def is_tty_stdout() -> bool:
# If stdout is piped/redirected, secrets printed to stdout are likely captured.
return bool(getattr(sys.stdout, "isatty", lambda: False)())
@@ -149,13 +148,12 @@ def prompt_zip_password_hidden_or_warn() -> str:
Prompt for ZIP password without echo using getpass when possible.
If getpass cannot disable echo (some IDEs), it may warn/fallback; in that case,
fall back to input() with a loud warning (user requested behavior B). [web:160]
fall back to input() with a loud warning.
"""
try:
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
pw = getpass.getpass("Enter ZIP password (hidden): ")
# If getpass emitted warnings (e.g., GetPassWarning), treat it as "echo might be visible".
if w:
print("⚠️ WARNING: getpass could not reliably hide input in this environment.", file=sys.stderr)
print("⚠️ Falling back to visible password entry. Use a real terminal for hidden input.", file=sys.stderr)
@@ -163,13 +161,11 @@ def prompt_zip_password_hidden_or_warn() -> str:
return pw2
return pw
except (Exception, KeyboardInterrupt):
# Conservative fallback: visible input with warning.
print("⚠️ WARNING: Hidden password prompt failed; falling back to visible input.", file=sys.stderr)
return input("Enter ZIP password (VISIBLE): ")
def base58_alphabet() -> str:
# Bitcoin-style Base58 alphabet (no 0,O,I,l). Common reference describes the reduced alphabet. [web:131][web:133]
return "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
@@ -180,6 +176,78 @@ def gen_base58_password(length: int) -> str:
return "".join(secrets.choice(alphabet) for _ in range(length))
# -----------------------------------------------------------------------------
# Recover input helpers (mnemonic only)
# -----------------------------------------------------------------------------
def _prompt_hidden_or_visible(prompt: str) -> str:
"""
Prompt for sensitive input.
Prefer getpass (no echo). If getpass can't reliably hide input in the current
environment, warn and fall back to visible input.
"""
try:
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
s = getpass.getpass(prompt)
if w:
print("⚠️ WARNING: getpass could not reliably hide input in this environment.", file=sys.stderr)
print("⚠️ Falling back to visible input. Use a real terminal for hidden input.", file=sys.stderr)
return input(prompt.replace("(hidden)", "(VISIBLE)"))
return s
except (Exception, KeyboardInterrupt):
print("⚠️ WARNING: Hidden prompt failed; falling back to visible input.", file=sys.stderr)
return input(prompt.replace("(hidden)", "(VISIBLE)"))
def _read_stdin_all() -> str:
return sys.stdin.read().strip()
def _bip39_english_words_list():
from bip_utils import Bip39Languages
from bip_utils.bip.bip39.bip39_mnemonic_utils import Bip39WordsListGetter
getter = Bip39WordsListGetter()
return getter.GetByLanguage(Bip39Languages.ENGLISH) # returns MnemonicWordsList [web:380]
def _prompt_word_count() -> int:
valid_counts = {12, 15, 18, 21, 24}
while True:
raw = input("Word count (12/15/18/21/24): ").strip()
if raw.isdigit() and int(raw) in valid_counts:
return int(raw)
print("❌ Invalid word count. Choose 12/15/18/21/24.")
def interactive_mnemonic_word_by_word() -> str:
from bip_utils import Bip39MnemonicValidator
words_list = _bip39_english_words_list()
n = _prompt_word_count()
words: List[str] = []
i = 1
while i <= n:
w = _prompt_hidden_or_visible(f"{i}) Enter word (hidden): ").strip().lower()
try:
words_list.GetWordIdx(w) # raises ValueError if not found [web:392]
print(f"{i}) ✅")
words.append(w)
i += 1
except ValueError:
print(f"{i}) ❌ Invalid BIP39 English word. Try again.")
mnemonic = " ".join(words)
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Mnemonic checksum/format invalid (words valid but checksum failed).")
return mnemonic
# -----------------------------------------------------------------------------
# Fingerprint
# -----------------------------------------------------------------------------
@@ -213,16 +281,10 @@ def pgp_encrypt_ascii_armored(
payload: Dict[str, Any],
ignore_usage_flags: bool = False,
) -> str:
"""
Encrypt payload into an ASCII-armored PGP message.
PGPy usage matches typical examples: PGPMessage.new(...) then pubkey.encrypt(...). [web:46]
"""
import pgpy
pub_key = pgp_load_pubkey(pubkey_armored)
if ignore_usage_flags:
# PGPy internal knob; keep as explicit opt-in.
pub_key._require_usage_flags = False # noqa: SLF001
msg = pgpy.PGPMessage.new(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -240,7 +302,7 @@ def pgp_fingerprint(armored: str) -> str:
def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]:
req = urllib.request.Request(url, headers={"User-Agent": "pyhdwallet/1.0.4"})
req = urllib.request.Request(url, headers={"User-Agent": "pyhdwallet/1.0.5"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
text = data.decode("utf-8", errors="strict")
@@ -264,7 +326,6 @@ def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int, off_screen: bo
if off_screen:
print("⚠️ Off-screen mode enabled: Temp files used.")
if not out_path:
# Keep handle open so file stays valid for this function duration.
temp_file = tempfile.NamedTemporaryFile(
mode="w",
suffix=".asc",
@@ -352,7 +413,6 @@ def derive_all(
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)
@@ -368,7 +428,6 @@ def derive_all(
if export_private:
out["secrets"]["ethereum"] = secrets
# SOL
if "solana" in chains:
addrs = []
secrets = []
@@ -412,7 +471,6 @@ def derive_all(
if export_private:
out["secrets"]["solana"] = secrets
# BTC (addresses only)
if "bitcoin" in chains:
addrs = []
@@ -497,20 +555,10 @@ def format_addresses_human(addresses: Dict[str, Any]) -> str:
# -----------------------------------------------------------------------------
def write_aes_zip(
zip_path: Path,
inner_name: str,
inner_bytes: bytes,
password: str,
) -> None:
"""
Write a single-file AES-encrypted ZIP using pyzipper (no raw artifacts on disk). [web:102]
"""
def write_aes_zip(zip_path: Path, inner_name: str, inner_bytes: bytes, password: str) -> None:
import pyzipper
ensure_dir(zip_path.parent)
# pyzipper expects password as bytes.
pw_bytes = password.encode("utf-8")
with pyzipper.AESZipFile(
@@ -520,7 +568,6 @@ def write_aes_zip(
encryption=pyzipper.WZ_AES,
) as zf:
zf.setpassword(pw_bytes)
# 256-bit is typical; pyzipper supports strength setting.
zf.setencryption(pyzipper.WZ_AES, nbits=256)
zf.writestr(inner_name, inner_bytes)
@@ -542,7 +589,7 @@ def build_payload_gen(
addresses: Dict[str, Any],
) -> Dict[str, Any]:
return {
"version": "pyhdwallet v1.0.4",
"version": "pyhdwallet v1.0.5",
"purpose": "generated mnemonic backup",
"mnemonic": mnemonic,
"passphrase_used": passphrase_used,
@@ -561,13 +608,12 @@ def build_payload_recover(
passphrase_hint: str,
include_source: bool,
mnemonic: Optional[str],
seed_hex: Optional[str],
export_private: bool,
derived_private: Dict[str, Any],
addresses: Dict[str, Any],
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"version": "pyhdwallet v1.0.4",
"version": "pyhdwallet v1.0.5",
"purpose": "recovery payload",
"master_fingerprint": fp,
"solana_profile": sol_profile,
@@ -575,17 +621,14 @@ def build_payload_recover(
"passphrase_hint": passphrase_hint,
}
if include_source:
if mnemonic:
payload["mnemonic"] = mnemonic
elif seed_hex:
payload["seed_hex"] = seed_hex
if include_source and mnemonic:
payload["mnemonic"] = mnemonic
if export_private:
payload["note"] = "Derived private keys included; passphrase value intentionally omitted."
payload["derived_private_keys"] = derived_private
payload["addresses"] = addresses
payload["addresses"] = addresses
return payload
@@ -611,7 +654,6 @@ def cmd_gen(args) -> None:
if args.off_screen:
print("⚠️ Off-screen mode enabled: Sensitive data will not be printed to stdout.")
else:
# This tool is intended for debug/test comparisons, but still warn loudly.
warn_gen_stdout_banner()
require_tty_or_force(args.force, "mnemonic to stdout")
@@ -643,20 +685,17 @@ def cmd_gen(args) -> None:
result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=False)
# stdout: human-readable (unless off-screen)
if not args.off_screen:
print(f"📍 Generated {args.words}-word BIP39 mnemonic:\n{mnemonic}\n")
print(f"Master Fingerprint: {fp}")
print(format_addresses_human(result["addresses"]))
# --file: write AES-zip only
if args.file:
require_for_zip()
ts = utc_timestamp_compact()
is_pgp = bool(args.pgp_pubkey_file)
# Build payload for file output:
if is_pgp:
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
pub = f.read()
@@ -689,16 +728,13 @@ def cmd_gen(args) -> None:
inner_bytes = inner_text.encode("utf-8")
zip_prefix = "test_wallet"
# Password strategy
if args.zip_password_mode == "prompt":
zip_password = prompt_zip_password_hidden_or_warn()
else:
zip_password = gen_base58_password(args.zip_password_len)
if args.show_generated_password:
# Print to stderr to reduce accidental capture when stdout is redirected.
print(f"ZIP password (auto-generated, base58): {zip_password}", file=sys.stderr)
# Place zip under wallet dir
wallet_dir = Path(args.wallet_location) if args.wallet_location else default_wallet_dir()
ensure_dir(wallet_dir)
@@ -707,11 +743,9 @@ def cmd_gen(args) -> None:
write_aes_zip(zip_path, inner_name, inner_bytes, zip_password)
# Minimal confirmation (avoid leaking anything)
print(f"✅ Wrote AES-encrypted ZIP: {zip_path}")
print(f" Contains: {inner_name}")
# Best-effort memory hygiene
if args.off_screen:
mnemonic = None
seed_bytes = None
@@ -730,45 +764,24 @@ def cmd_recover(args) -> None:
from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
# Validate input methods
input_methods = [bool(args.mnemonic), bool(args.seed), args.interactive]
if sum(input_methods) > 1:
raise ValueError("Provide only one of --mnemonic, --seed, or --interactive")
if sum(input_methods) == 0:
raise ValueError("Missing input (use --mnemonic/--seed/--interactive)")
mnemonic: Optional[str] = None
seed_hex: Optional[str] = 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()
mnemonic = interactive_mnemonic_word_by_word()
elif args.mnemonic_stdin:
mnemonic = _read_stdin_all()
if not mnemonic:
raise ValueError("Empty stdin for --mnemonic-stdin")
else:
seed_hex = args.seed.strip()
raise ValueError("Missing input mode (use --interactive / --mnemonic-stdin)")
passphrase = getpass.getpass("Enter BIP39 passphrase (hidden): ") if args.passphrase else ""
if mnemonic:
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Invalid BIP39 mnemonic")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
else:
b = bytes.fromhex(seed_hex or "")
if len(b) != 64:
raise ValueError("Invalid seed hex (expected 64 bytes / 128 hex chars)")
seed_bytes = b
if not Bip39MnemonicValidator().IsValid(mnemonic):
raise ValueError("Invalid BIP39 mnemonic")
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
fp = get_master_fingerprint(seed_bytes)
if not args.off_screen:
# Addresses are not "as sensitive" as mnemonic, but treat as still sensitive-ish.
print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...")
print(f"Master Fingerprint: {fp}\n")
@@ -777,7 +790,6 @@ def cmd_recover(args) -> None:
if not args.off_screen:
print(format_addresses_human(result["addresses"]))
# --file: write AES-zip only
if args.file:
require_for_zip()
@@ -797,8 +809,7 @@ def cmd_recover(args) -> None:
passphrase_used=bool(passphrase),
passphrase_hint=args.passphrase_hint or "",
include_source=args.include_source,
mnemonic=mnemonic,
seed_hex=seed_hex,
mnemonic=mnemonic if args.include_source else None,
export_private=args.export_private,
derived_private=result.get("secrets", {}),
addresses=result["addresses"],
@@ -808,15 +819,13 @@ def cmd_recover(args) -> None:
inner_bytes = inner_text.encode("utf-8")
zip_prefix = "encrypted_wallet"
else:
# Unencrypted recover payload is JSON (still can include source if requested).
payload = build_payload_recover(
fp=fp,
sol_profile=args.sol_profile,
passphrase_used=bool(passphrase),
passphrase_hint=args.passphrase_hint or "",
include_source=args.include_source,
mnemonic=mnemonic,
seed_hex=seed_hex,
mnemonic=mnemonic if args.include_source else None,
export_private=args.export_private,
derived_private=result.get("secrets", {}),
addresses=result["addresses"],
@@ -841,10 +850,8 @@ def cmd_recover(args) -> None:
print(f"✅ Wrote AES-encrypted ZIP: {zip_path}")
print(f" Contains: {inner_name}")
# Best-effort memory hygiene
if args.off_screen:
mnemonic = None
seed_hex = None
seed_bytes = None
passphrase = None
@@ -861,8 +868,7 @@ def cmd_test(args) -> None:
print("🧪 Running tests...")
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
passphrase = ""
seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase)
seed_bytes = Bip39SeedGenerator(mnemonic).Generate("")
expected_addr = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
path = "m/44'/501'/0'/0'"
@@ -883,37 +889,29 @@ def cmd_test(args) -> None:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="pyhdwallet v1.0.4 - Secure HD Wallet Tool")
parser.add_argument("--version", action="version", version="pyhdwallet v1.0.4")
parser = argparse.ArgumentParser(description="pyhdwallet v1.0.5 - Secure HD Wallet Tool")
parser.add_argument("--version", action="version", version="pyhdwallet v1.0.5")
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)
p_fetch.add_argument("--off-screen", action="store_true", help="Enable off-screen mode: no printing of sensitive data to stdout.")
p_fetch.add_argument(
"--off-screen",
action="store_true",
help="Enable off-screen mode: no printing of sensitive data to stdout.",
)
def add_common(p: argparse.ArgumentParser) -> None:
# general security behavior toggles
p.add_argument(
"--force",
action="store_true",
help="Allow printing sensitive output even when stdout is not a TTY (dangerous).",
)
p.add_argument("--force", action="store_true", help="Allow printing sensitive output even when stdout is not a TTY (dangerous).")
p.add_argument("--off-screen", action="store_true", help="Suppress printing sensitive data to stdout.")
p.add_argument("--passphrase", action="store_true", help="Prompt for BIP39 passphrase interactively")
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("--chains", nargs="+", choices=["ethereum", "solana", "bitcoin"], default=["ethereum", "solana", "bitcoin"])
p.add_argument("--addresses", type=int, default=5)
# encryption options
p.add_argument("--pgp-pubkey-file", default=None)
p.add_argument("--pgp-ignore-usage-flags", action="store_true")
@@ -930,54 +928,36 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument(
"--include-source",
action="store_true",
help="If set with --pgp-pubkey-file, include mnemonic/seed in encrypted payload.",
help="If set, include mnemonic in payload (use with --pgp-pubkey-file and --file for safer storage).",
)
# file output behavior
p.add_argument(
"--file",
action="store_true",
help="Write output to AES-encrypted ZIP in ./.wallet (deterministic name).",
)
p.add_argument(
"--wallet-location",
default="",
help="Override default ./.wallet folder for --file output.",
)
p.add_argument("--file", action="store_true", help="Write output to AES-encrypted ZIP in ./.wallet (deterministic name).")
p.add_argument("--wallet-location", default="", help="Override default ./.wallet folder for --file output.")
# zip password behavior
p.add_argument(
"--zip-password-mode",
choices=["prompt", "auto"],
default="prompt",
help="ZIP password mode: prompt (hidden when possible) or auto-generate base58.",
)
p.add_argument(
"--zip-password-len",
type=int,
default=12,
help="Password length when --zip-password-mode auto is used.",
)
p.add_argument("--zip-password-len", type=int, default=12, help="Password length when --zip-password-mode auto is used.")
p.add_argument(
"--show-generated-password",
action="store_true",
help="When using --zip-password-mode auto, print generated password to stderr.",
)
# 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="")
# recover
p_rec = sub.add_parser("recover", help="Recover addresses from mnemonic/seed (offline)")
p_rec = sub.add_parser("recover", help="Recover addresses from mnemonic (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")
g = p_rec.add_mutually_exclusive_group(required=True)
g.add_argument("--interactive", action="store_true", help="Guided mnemonic entry (English-only, per-word validation)")
g.add_argument("--mnemonic-stdin", dest="mnemonic_stdin", action="store_true", help="Read BIP39 mnemonic from stdin (non-interactive)")
# test
p_test = sub.add_parser("test", help="Run minimal offline tests")
p_test.add_argument("--off-screen", action="store_true", help="Enable off-screen mode: no printing of sensitive data to stdout.")