Files
pyhdwallet/recover-stdin-interactive.patch
2026-01-07 01:45:35 +08:00

205 lines
7.8 KiB
Diff

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