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
|
||||
@@ -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.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user