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