patch the recover logic with interactive mode
This commit is contained in:
204
recover-stdin-interactive.patch
Normal file
204
recover-stdin-interactive.patch
Normal 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
|
||||
Reference in New Issue
Block a user