205 lines
7.8 KiB
Diff
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
|