From 875fa17d6c70725ca058e64e8ad5ac4961868309 Mon Sep 17 00:00:00 2001 From: LC mac Date: Wed, 7 Jan 2026 01:45:35 +0800 Subject: [PATCH] patch the recover logic with interactive mode --- recover-stdin-interactive.patch | 204 ++++++++++++++++++++++++ src/pyhdwallet.py | 266 +++++++++++++++----------------- 2 files changed, 327 insertions(+), 143 deletions(-) create mode 100644 recover-stdin-interactive.patch diff --git a/recover-stdin-interactive.patch b/recover-stdin-interactive.patch new file mode 100644 index 0000000..c7bea64 --- /dev/null +++ b/recover-stdin-interactive.patch @@ -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 diff --git a/src/pyhdwallet.py b/src/pyhdwallet.py index b05c4bc..1926c73 100644 --- a/src/pyhdwallet.py +++ b/src/pyhdwallet.py @@ -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.")