diff --git a/.gitignore b/.gitignore index 19691db..431861e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ build/ *.sqlite3 *.db *.asc +.venv/ diff --git a/README.md b/README.md index e5f4ea4..7a02398 100644 --- a/README.md +++ b/README.md @@ -27,5 +27,5 @@ For detailed examples and security tips, see `playbook.md`. ## Security - Operates offline by default. -- Use `--secure-mode` for high-security operations. +- Use `--off-screen` for high-security operations. - Always verify PGP keys and run on trusted systems. diff --git a/requirements.in b/requirements.in index d6db4bd..5c3207a 100644 --- a/requirements.in +++ b/requirements.in @@ -1,2 +1,6 @@ -PGPy -bip-utils \ No newline at end of file +base58==2.1.1 +bip-utils==2.10.0 +pgpy==0.6.0 +pip-chill==1.0.3 +pip-tools==7.5.2 +pyzipper==0.3.6 diff --git a/requirements.txt b/requirements.txt index 3d7db8b..cc7a918 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,14 +4,20 @@ # # pip-compile # +base58==2.1.1 + # via -r requirements.in bip-utils==2.10.0 # via -r requirements.in +build==1.3.0 + # via pip-tools cbor2==5.8.0 # via bip-utils cffi==2.0.0 # via # cryptography # pynacl +click==8.3.1 + # via pip-tools coincurve==21.0.0 # via bip-utils crcmod==1.7 @@ -22,8 +28,14 @@ ecdsa==0.19.1 # via bip-utils ed25519-blake2b==1.4.1 # via bip-utils +packaging==25.0 + # via build pgpy==0.6.0 # via -r requirements.in +pip-chill==1.0.3 + # via -r requirements.in +pip-tools==7.5.2 + # via -r requirements.in py-sr25519-bindings==0.2.3 # via bip-utils pyasn1==0.6.1 @@ -32,7 +44,21 @@ pycparser==2.23 # via cffi pycryptodome==3.23.0 # via bip-utils +pycryptodomex==3.23.0 + # via pyzipper pynacl==1.6.2 # via bip-utils +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pyzipper==0.3.6 + # via -r requirements.in six==1.17.0 # via ecdsa +wheel==0.45.1 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/src/pyhdwallet.py b/src/pyhdwallet.py index 56dbb62..b05c4bc 100644 --- a/src/pyhdwallet.py +++ b/src/pyhdwallet.py @@ -4,46 +4,48 @@ pyhdwallet v1.0.4 (Python 3.11+) Commands: - fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint - - gen (offline): generate BIP39 mnemonic; optionally encrypt payload to PGP pubkey - - recover (offline): derive addresses from mnemonic/seed; optionally encrypt payload to PGP pubkey + - 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 - test (offline): minimal self-test -Usability: - - Run with a subcommand (e.g., 'gen', 'recover'). Use -h for details. +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). + 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. -Security: - - gen/recover/test block network I/O via NetworkGuard. - - Never stores plaintext passphrase inside payload (only passphrase_used + passphrase_hint). - - If encrypting, mnemonic is not printed unless --unsafe-print. - - --secure-mode: Enforces no printing of sensitive data, uses temp files, zeros memory. - -Solana derivation: - - Default: phantom_bip44change => m/44'/501'/{i}'/0' (Phantom-supported grouping). [web:13] - - Also: phantom_bip44 => m/44'/501'/{i}' (Phantom-supported grouping). [web:13] - - Also: solana_bip39_first32 => seed[0:32] shortcut (single address). - - Also: phantom_deprecated => m/501'/{i}'/0/0 (best-effort; some libs only support hardened derivation). - If the exact path fails, falls back to m/501'/{i}'/0'/0' and marks it in output. - -Implementation uses bip_utils SLIP-0010 ed25519 path derivation via Bip32Slip10Ed25519.FromSeedAndPath. [web:219] -PGP encryption uses PGPMessage.new(json.dumps(payload)) style. [web:203] +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 -import sys -import json +import datetime import getpass import hashlib +import json import os +import sys import tempfile import urllib.request -from typing import Dict, List, Any, Optional, Tuple - +import warnings +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple # ----------------------------------------------------------------------------- # Dependency checks # ----------------------------------------------------------------------------- -def _require(mod: str, pkg: str): + +def _require(mod: str, pkg: str) -> None: try: __import__(mod) except ImportError: @@ -52,11 +54,11 @@ def _require(mod: str, pkg: str): sys.exit(1) -def require_for_fetchkey(): +def require_for_fetchkey() -> None: _require("pgpy", "PGPy") -def require_for_offline(chains: List[str]): +def require_for_offline(chains: List[str]) -> None: _require("bip_utils", "bip-utils") if "solana" in chains: _require("nacl", "PyNaCl") @@ -64,11 +66,19 @@ def require_for_offline(chains: List[str]): _require("pgpy", "PGPy") +def require_for_zip() -> None: + # AES ZIP creation not supported by stdlib zipfile; use pyzipper. [web:102] + _require("pyzipper", "pyzipper") + + # ----------------------------------------------------------------------------- # Offline network guard # ----------------------------------------------------------------------------- + class NetworkGuard: + """Blocks urllib.request.urlopen to enforce offline mode in gen/recover/test.""" + def __init__(self, mode_name: str): self.mode_name = mode_name self._orig = None @@ -87,12 +97,97 @@ class NetworkGuard: return False +# ----------------------------------------------------------------------------- +# Utility: time, paths, and safe-ish UX +# ----------------------------------------------------------------------------- + + +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" + + +def ensure_dir(p: Path) -> None: + p.mkdir(parents=True, exist_ok=True) + + +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)()) + + +def require_tty_or_force(force: bool, what: str) -> None: + if is_tty_stdout(): + return + if force: + print(f"⚠️ WARNING: stdout is not a TTY; printing {what} may be captured by logs/pipes.", file=sys.stderr) + return + raise RuntimeError( + f"Refusing to print {what} because stdout is not a TTY. " + f"Run in a terminal or pass --force (dangerous)." + ) + + +def warn_gen_stdout_banner() -> None: + print("=" * 80) + print("WARNING: This tool will print the mnemonic to stdout by default (debug/test mode).") + print("stdout can be captured by terminal scrollback, logging, screen recording, pipes, or CI logs.") + print("For safer workflows:") + print(" - Avoid printing secrets; use --off-screen (suppresses printing).") + print(" - Prefer --pgp-pubkey-file and --file to create encrypted artifacts.") + print("=" * 80) + print("") + + +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] + """ + 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) + pw2 = input("Enter ZIP password (VISIBLE): ") + 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" + + +def gen_base58_password(length: int) -> str: + import secrets + + alphabet = base58_alphabet() + return "".join(secrets.choice(alphabet) for _ in range(length)) + + # ----------------------------------------------------------------------------- # Fingerprint # ----------------------------------------------------------------------------- + def get_master_fingerprint(seed_bytes: bytes) -> str: from bip_utils import Bip32Slip10Secp256k1, Hash160 + master = Bip32Slip10Secp256k1.FromSeed(seed_bytes) pubkey_bytes = master.PublicKey().RawCompressed().ToBytes() h160 = Hash160.QuickDigest(pubkey_bytes) @@ -103,19 +198,33 @@ def get_master_fingerprint(seed_bytes: bytes) -> str: # PGP helpers (PGPy) # ----------------------------------------------------------------------------- + def pgp_load_pubkey(armored: str): import pgpy + key, _ = pgpy.PGPKey.from_blob(armored) if not key.is_public: raise ValueError("Provided key is not a public key") return key -def pgp_encrypt_ascii_armored(pubkey_armored: str, payload: Dict[str, Any], ignore_usage_flags: bool = False) -> str: +def pgp_encrypt_ascii_armored( + pubkey_armored: str, + 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: - pub_key._require_usage_flags = False + # 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)) enc = pub_key.encrypt(msg) return str(enc) @@ -129,8 +238,9 @@ def pgp_fingerprint(armored: str) -> str: # fetchkey (online) # ----------------------------------------------------------------------------- + def fetch_ascii_armored_text(url: str, timeout: int = 15) -> Tuple[str, bytes]: - req = urllib.request.Request(url, headers={"User-Agent": "hdwallet-recovery/1.0"}) + req = urllib.request.Request(url, headers={"User-Agent": "pyhdwallet/1.0.4"}) with urllib.request.urlopen(req, timeout=timeout) as resp: data = resp.read() text = data.decode("utf-8", errors="strict") @@ -143,25 +253,32 @@ def sha256_hex_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int, secure_mode: bool): +def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int, off_screen: bool) -> None: require_for_fetchkey() armored, raw = fetch_ascii_armored_text(url, timeout=timeout) s256 = sha256_hex_bytes(raw) fpr = pgp_fingerprint(armored) - if secure_mode: - print("⚠️ Secure mode enabled: Temp files used.") - # Use temp file for out_path if not specified + temp_file = None + if off_screen: + print("⚠️ Off-screen mode enabled: Temp files used.") if not out_path: - temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.asc', delete=True) + # Keep handle open so file stays valid for this function duration. + temp_file = tempfile.NamedTemporaryFile( + mode="w", + suffix=".asc", + delete=True, + encoding="utf-8", + newline="\n", + ) out_path = temp_file.name print(f"Key saved to temp file: {out_path} (auto-deleted on exit)") if out_path: with open(out_path, "w", encoding="utf-8", newline="\n") as f: f.write(armored) - if not secure_mode: + if not off_screen: os.chmod(out_path, 0o600) print("✅ Downloaded PGP public key") @@ -171,14 +288,19 @@ def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int, secure_mode: b print(f"SHA256: {s256}") print(f"Fingerprint: {fpr}") + if temp_file is not None: + temp_file.close() + # ----------------------------------------------------------------------------- # Solana helpers # ----------------------------------------------------------------------------- + def sol_pubkey_b58_from_seed32(seed32: bytes) -> str: from nacl.signing import SigningKey import base58 + sk = SigningKey(seed32) pub32 = sk.verify_key.encode() return base58.b58encode(pub32).decode("ascii") @@ -187,6 +309,7 @@ def sol_pubkey_b58_from_seed32(seed32: bytes) -> str: def sol_secret64_b58_from_seed32(seed32: bytes) -> str: from nacl.signing import SigningKey import base58 + sk = SigningKey(seed32) pub32 = sk.verify_key.encode() secret64 = seed32 + pub32 @@ -195,6 +318,7 @@ def sol_secret64_b58_from_seed32(seed32: bytes) -> str: def slip10_ed25519_seed32_from_path(seed_bytes: bytes, path: str) -> bytes: from bip_utils import Bip32Slip10Ed25519 + node = Bip32Slip10Ed25519.FromSeedAndPath(seed_bytes, path) seed32 = node.PrivateKey().Raw().ToBytes() if len(seed32) != 32: @@ -206,11 +330,22 @@ def slip10_ed25519_seed32_from_path(seed_bytes: bytes, path: str) -> bytes: # Derivation (addresses-only by default) # ----------------------------------------------------------------------------- -def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: str, export_private: bool) -> Dict[str, Any]: + +def derive_all( + seed_bytes: bytes, + chains: List[str], + count: int, + sol_profile: str, + export_private: bool, +) -> Dict[str, Any]: from bip_utils import ( - Bip44, Bip44Coins, Bip44Changes, - Bip49, Bip49Coins, - Bip84, Bip84Coins, + Bip44, + Bip44Changes, + Bip44Coins, + Bip49, + Bip49Coins, + Bip84, + Bip84Coins, ) out: Dict[str, Any] = {"addresses": {}} @@ -221,8 +356,8 @@ def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: st if "ethereum" in chains: root = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM) ctx = root.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) - addrs = [] - secrets = [] + addrs: List[Dict[str, Any]] = [] + secrets: List[Dict[str, Any]] = [] for i in range(count): node = ctx.AddressIndex(i) path = f"m/44'/60'/0'/0/{i}" @@ -238,11 +373,9 @@ def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: st addrs = [] secrets = [] - # single-address modes - if sol_profile == "solana_bip39_first32": - count = 1 + local_count = 1 if sol_profile == "solana_bip39_first32" else count - for i in range(count): + for i in range(local_count): if sol_profile == "phantom_bip44change": path = f"m/44'/501'/{i}'/0'" seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) @@ -250,7 +383,6 @@ def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: st path = f"m/44'/501'/{i}'" seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) elif sol_profile == "phantom_deprecated": - # Try documented legacy first, then fallback to hardened last levels if lib rejects non-hardened. path_try = f"m/501'/{i}'/0/0" try: seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path_try) @@ -268,7 +400,13 @@ def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: st addrs.append({"index": i, "path": path, "address": addr}) if export_private: - secrets.append({"index": i, "path": path, "phantom_secret_base58": sol_secret64_b58_from_seed32(seed32)}) + secrets.append( + { + "index": i, + "path": path, + "phantom_secret_base58": sol_secret64_b58_from_seed32(seed32), + } + ) out["addresses"]["solana"] = addrs if export_private: @@ -277,6 +415,7 @@ def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: st # BTC (addresses only) if "bitcoin" in chains: addrs = [] + r44 = Bip44.FromSeed(seed_bytes, Bip44Coins.BITCOIN) c44 = r44.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) @@ -287,9 +426,30 @@ def derive_all(seed_bytes: bytes, chains: List[str], count: int, sol_profile: st c84 = r84.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT) for i in range(count): - addrs.append({"index": i, "path": f"m/84'/0'/0'/0/{i}", "address_type": "native_segwit", "address": c84.AddressIndex(i).PublicKey().ToAddress()}) - addrs.append({"index": i, "path": f"m/49'/0'/0'/0/{i}", "address_type": "segwit", "address": c49.AddressIndex(i).PublicKey().ToAddress()}) - addrs.append({"index": i, "path": f"m/44'/0'/0'/0/{i}", "address_type": "legacy", "address": c44.AddressIndex(i).PublicKey().ToAddress()}) + addrs.append( + { + "index": i, + "path": f"m/84'/0'/0'/0/{i}", + "address_type": "native_segwit", + "address": c84.AddressIndex(i).PublicKey().ToAddress(), + } + ) + addrs.append( + { + "index": i, + "path": f"m/49'/0'/0'/0/{i}", + "address_type": "segwit", + "address": c49.AddressIndex(i).PublicKey().ToAddress(), + } + ) + addrs.append( + { + "index": i, + "path": f"m/44'/0'/0'/0/{i}", + "address_type": "legacy", + "address": c44.AddressIndex(i).PublicKey().ToAddress(), + } + ) out["addresses"]["bitcoin"] = addrs @@ -332,19 +492,131 @@ def format_addresses_human(addresses: Dict[str, Any]) -> str: return "\n".join(lines) +# ----------------------------------------------------------------------------- +# AES ZIP output (pyzipper) +# ----------------------------------------------------------------------------- + + +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] + """ + import pyzipper + + ensure_dir(zip_path.parent) + + # pyzipper expects password as bytes. + pw_bytes = password.encode("utf-8") + + with pyzipper.AESZipFile( + zip_path, + mode="w", + compression=pyzipper.ZIP_DEFLATED, + 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) + + os.chmod(zip_path, 0o600) + + +# ----------------------------------------------------------------------------- +# File naming and payload construction +# ----------------------------------------------------------------------------- + + +def build_payload_gen( + mnemonic: str, + passphrase_used: bool, + passphrase_hint: str, + fp: str, + dice_used: bool, + sol_profile: str, + addresses: Dict[str, Any], +) -> Dict[str, Any]: + return { + "version": "pyhdwallet v1.0.4", + "purpose": "generated mnemonic backup", + "mnemonic": mnemonic, + "passphrase_used": passphrase_used, + "passphrase_hint": passphrase_hint, + "master_fingerprint": fp, + "dice_rolls_used": dice_used, + "solana_profile": sol_profile, + "addresses": addresses, + } + + +def build_payload_recover( + fp: str, + sol_profile: str, + passphrase_used: bool, + 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", + "purpose": "recovery payload", + "master_fingerprint": fp, + "solana_profile": sol_profile, + "passphrase_used": passphrase_used, + "passphrase_hint": passphrase_hint, + } + + if include_source: + if mnemonic: + payload["mnemonic"] = mnemonic + elif seed_hex: + payload["seed_hex"] = seed_hex + + if export_private: + payload["note"] = "Derived private keys included; passphrase value intentionally omitted." + payload["derived_private_keys"] = derived_private + payload["addresses"] = addresses + + return payload + + +def deterministic_inner_name(is_encrypted: bool, ts: str) -> str: + if is_encrypted: + return f"encrypted_wallet_{ts}.asc" + return f"test_wallet_{ts}.json" + + +def deterministic_zip_name(prefix: str, ts: str) -> str: + return f"{prefix}_{ts}.zip" + + # ----------------------------------------------------------------------------- # Commands # ----------------------------------------------------------------------------- -def cmd_gen(args): + +def cmd_gen(args) -> None: with NetworkGuard("gen"): require_for_offline(args.chains) - if args.secure_mode: - print("⚠️ Secure mode enabled: Sensitive data will not be printed, temp files used.") + 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") import secrets - from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator + from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39SeedGenerator words_to_entropy = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256} if args.words not in words_to_entropy: @@ -363,92 +635,101 @@ def cmd_gen(args): entropy = secrets.token_bytes(entropy_len) dice_used = False - mnemonic_obj = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(entropy) - mnemonic = str(mnemonic_obj) # Fix JSON serialization (Mnemonic object -> string). [web:212] - seed_bytes = Bip39SeedGenerator(mnemonic).Generate(args.passphrase or "") + mnemonic = str(Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(entropy)) + passphrase = getpass.getpass("Enter BIP39 passphrase (hidden): ") if args.passphrase else "" + seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase) fp = get_master_fingerprint(seed_bytes) result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=False) - if not args.pgp_pubkey_file or args.unsafe_print: - if not args.secure_mode: - print(f"📍 Generated {args.words}-word BIP39 mnemonic:\n{mnemonic}\n") - else: - print(f"📍 Generated {args.words}-word BIP39 mnemonic (not printed; encryption enabled).") + # 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"])) - if args.output == "json": - out_text = json.dumps({ - "master_fingerprint": fp, - "passphrase_used": bool(args.passphrase), - "passphrase_hint": args.passphrase_hint or "", - "dice_rolls_used": dice_used, - "solana_profile": args.sol_profile, - "addresses": result["addresses"], - }, indent=2, ensure_ascii=False) - else: - out_text = f"Master Fingerprint: {fp}\n\n" + format_addresses_human(result["addresses"]) - - if not args.secure_mode: - print(out_text) - - # File output with permissions fix + # --file: write AES-zip only if args.file: - if args.secure_mode: - # Use temp file and auto-delete - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=True) as temp_file: - temp_file.write(out_text) - temp_file.flush() - print("Output written to temporary file (auto-deleted on exit)") + 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() + + payload = build_payload_gen( + mnemonic=mnemonic, + passphrase_used=bool(passphrase), + passphrase_hint=args.passphrase_hint or "", + fp=fp, + dice_used=dice_used, + sol_profile=args.sol_profile, + addresses=result["addresses"], + ) + inner_name = deterministic_inner_name(is_encrypted=True, ts=ts) + inner_text = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) + inner_bytes = inner_text.encode("utf-8") + zip_prefix = "encrypted_wallet" else: - with open(args.file, 'w') as f: - f.write(out_text) - os.chmod(args.file, 0o600) # Fix permissions - print(f"Output written to {args.file}") + payload = build_payload_gen( + mnemonic=mnemonic, + passphrase_used=bool(passphrase), + passphrase_hint=args.passphrase_hint or "", + fp=fp, + dice_used=dice_used, + sol_profile=args.sol_profile, + addresses=result["addresses"], + ) + inner_name = deterministic_inner_name(is_encrypted=False, ts=ts) + inner_text = json.dumps(payload, indent=2, ensure_ascii=False) + inner_bytes = inner_text.encode("utf-8") + zip_prefix = "test_wallet" - if args.pgp_pubkey_file: - with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: - pub = f.read() - - payload = { - "version": "pyhdwallet v1.0.4", - "purpose": "generated mnemonic backup", - "mnemonic": mnemonic, - "passphrase_used": bool(args.passphrase), - "passphrase_hint": args.passphrase_hint or "", - "master_fingerprint": fp, - "dice_rolls_used": dice_used, - "solana_profile": args.sol_profile, - "addresses": result["addresses"], - } - armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) - if not args.secure_mode: - print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") - print(armored) - print("===== END PGP ENCRYPTED PAYLOAD =====\n") + # Password strategy + if args.zip_password_mode == "prompt": + zip_password = prompt_zip_password_hidden_or_warn() else: - print("Encrypted payload generated (not printed in secure mode).") + 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) - # Memory zeroing - if args.secure_mode: + # Place zip under wallet dir + wallet_dir = Path(args.wallet_location) if args.wallet_location else default_wallet_dir() + ensure_dir(wallet_dir) + + zip_name = deterministic_zip_name(zip_prefix, ts) + zip_path = wallet_dir / zip_name + + 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 - del mnemonic seed_bytes = None - del seed_bytes + passphrase = None -def cmd_recover(args): +def cmd_recover(args) -> None: with NetworkGuard("recover"): require_for_offline(args.chains) - if args.secure_mode: - print("⚠️ Secure mode enabled: Sensitive data will not be printed, temp files used.") - - from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator + 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: @@ -456,8 +737,8 @@ def cmd_recover(args): if sum(input_methods) == 0: raise ValueError("Missing input (use --mnemonic/--seed/--interactive)") - mnemonic = None - seed_hex = None + mnemonic: Optional[str] = None + seed_hex: Optional[str] = None if args.interactive: mode = input("Enter 'm' for mnemonic or 's' for seed: ").strip().lower() @@ -469,130 +750,138 @@ def cmd_recover(args): raise ValueError("Invalid choice") elif args.mnemonic: mnemonic = args.mnemonic.strip() - elif args.seed: + else: seed_hex = args.seed.strip() + 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(args.passphrase or "") + seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase) else: - b = bytes.fromhex(seed_hex) + b = bytes.fromhex(seed_hex or "") if len(b) != 64: - raise ValueError("Invalid input") + raise ValueError("Invalid seed hex (expected 64 bytes / 128 hex chars)") seed_bytes = b fp = get_master_fingerprint(seed_bytes) - if not args.secure_mode: + 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") result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=args.export_private) - if args.output == "json": - out_text = json.dumps({ - "master_fingerprint": fp, - "solana_profile": args.sol_profile, - "addresses": result["addresses"], - }, indent=2, ensure_ascii=False) - else: - out_text = format_addresses_human(result["addresses"]) + if not args.off_screen: + print(format_addresses_human(result["addresses"])) - if not args.secure_mode: - print(out_text) - - # File output with permissions fix + # --file: write AES-zip only if args.file: - if args.secure_mode: - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=True) as temp_file: - temp_file.write(out_text) - temp_file.flush() - print("Output written to temporary file (auto-deleted on exit)") + require_for_zip() + + ts = utc_timestamp_compact() + is_pgp = bool(args.pgp_pubkey_file) + + wallet_dir = Path(args.wallet_location) if args.wallet_location else default_wallet_dir() + ensure_dir(wallet_dir) + + if is_pgp: + with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: + pub = f.read() + + 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, + export_private=args.export_private, + derived_private=result.get("secrets", {}), + addresses=result["addresses"], + ) + inner_name = deterministic_inner_name(is_encrypted=True, ts=ts) + inner_text = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) + inner_bytes = inner_text.encode("utf-8") + zip_prefix = "encrypted_wallet" else: - with open(args.file, 'w') as f: - f.write(out_text) - os.chmod(args.file, 0o600) - print(f"Output written to {args.file}") + # 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, + export_private=args.export_private, + derived_private=result.get("secrets", {}), + addresses=result["addresses"], + ) + inner_name = deterministic_inner_name(is_encrypted=False, ts=ts) + inner_text = json.dumps(payload, indent=2, ensure_ascii=False) + inner_bytes = inner_text.encode("utf-8") + zip_prefix = "test_wallet" - if args.pgp_pubkey_file: - with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: - pub = f.read() - - payload: Dict[str, Any] = { - "version": "pyhdwallet v1.0.4", - "purpose": "recovery payload", - "master_fingerprint": fp, - "solana_profile": args.sol_profile, - "passphrase_used": bool(args.passphrase), - "passphrase_hint": args.passphrase_hint or "", - } - - # Include source only if you explicitly want it: - if args.include_source: - if mnemonic: - payload["mnemonic"] = mnemonic - else: - payload["seed_hex"] = seed_hex - - if args.export_private: - payload["note"] = "Derived private keys included; passphrase value intentionally omitted." - payload["derived_private_keys"] = result.get("secrets", {}) - payload["addresses"] = result["addresses"] - - armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) - if not args.secure_mode: - print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") - print(armored) - print("===== END PGP ENCRYPTED PAYLOAD =====\n") + if args.zip_password_mode == "prompt": + zip_password = prompt_zip_password_hidden_or_warn() else: - print("Encrypted payload generated (not printed in secure mode).") + zip_password = gen_base58_password(args.zip_password_len) + if args.show_generated_password: + print(f"ZIP password (auto-generated, base58): {zip_password}", file=sys.stderr) - # Memory zeroing - if args.secure_mode: - if mnemonic: - mnemonic = None - if seed_hex: - seed_hex = None + zip_name = deterministic_zip_name(zip_prefix, ts) + zip_path = wallet_dir / zip_name + + write_aes_zip(zip_path, inner_name, inner_bytes, zip_password) + + 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 - del seed_bytes + passphrase = None -def cmd_test(args): +def cmd_test(args) -> None: with NetworkGuard("test"): require_for_offline(["ethereum", "solana"]) - if args.secure_mode: - print("⚠️ Secure mode enabled (no effect on test).") + if args.off_screen: + print("⚠️ Off-screen mode enabled (no effect on test).") from bip_utils import Bip39SeedGenerator print("🧪 Running tests...") - # --- Existing vector (yours) --- mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - passphrase = "" # empty + passphrase = "" seed_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase) - # --- NEW: Solana path->address test --- expected_addr = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" path = "m/44'/501'/0'/0'" - seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) # or slip10_ed25519_seed32_by_path(...) + seed32 = slip10_ed25519_seed32_from_path(seed_bytes, path) got_addr = sol_pubkey_b58_from_seed32(seed32) if got_addr != expected_addr: - raise RuntimeError("Invalid input") + raise RuntimeError(f"Solana test failed: expected {expected_addr}, got {got_addr}") print(f"✅ Solana OK: {path} => {got_addr}") print("✅ All tests passed") - # ----------------------------------------------------------------------------- # CLI # ----------------------------------------------------------------------------- + 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") @@ -603,35 +892,83 @@ def build_parser() -> argparse.ArgumentParser: 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('--secure-mode', action='store_true', help='Enable secure mode: no printing of sensitive data, temp files, memory zeroing.') + p_fetch.add_argument("--off-screen", action="store_true", help="Enable off-screen mode: no printing of sensitive data to stdout.") - # shared for gen/recover and also top-level (so recover works without explicit subcommand) - def add_common(p: argparse.ArgumentParser): - p.add_argument("--passphrase", default="", help="BIP39 passphrase (optional; avoid CLI for real usage)") + 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("--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) - p.add_argument("--output", choices=["text", "json"], default="text") - p.add_argument("--file", default=None) + + # encryption options p.add_argument("--pgp-pubkey-file", default=None) p.add_argument("--pgp-ignore-usage-flags", action="store_true") - p.add_argument("--sol-profile", - choices=["phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"], - default="phantom_bip44change") - p.add_argument("--export-private", action="store_true", - help="If set, derive and include private keys in encrypted payload (never printed)") - p.add_argument("--include-source", action="store_true", - help="If set with --pgp-pubkey-file, include mnemonic/seed in encrypted payload") - p.add_argument('--secure-mode', action='store_true', help='Enable secure mode: no printing of sensitive data, temp files, memory zeroing.') + + p.add_argument( + "--sol-profile", + choices=["phantom_bip44change", "phantom_bip44", "phantom_deprecated", "solana_bip39_first32"], + default="phantom_bip44change", + ) + p.add_argument( + "--export-private", + action="store_true", + help="If set, derive and include private keys in encrypted payload (never printed). Requires --pgp-pubkey-file.", + ) + p.add_argument( + "--include-source", + action="store_true", + help="If set with --pgp-pubkey-file, include mnemonic/seed in encrypted payload.", + ) + + # 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.", + ) + + # 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( + "--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="") - p_gen.add_argument("--unsafe-print", action="store_true", - help="Print mnemonic even when encrypting (not recommended)") # recover p_rec = sub.add_parser("recover", help="Recover addresses from mnemonic/seed (offline)") @@ -642,25 +979,18 @@ def build_parser() -> argparse.ArgumentParser: # test p_test = sub.add_parser("test", help="Run minimal offline tests") - p_test.add_argument('--secure-mode', action='store_true', help='Enable secure mode: no printing of sensitive data, temp files, memory zeroing.') - # no other args - - # Also allow recover without subcommand: - add_common(parser) - parser.add_argument("--mnemonic", default="") - parser.add_argument("--seed", default="") - parser.add_argument("--interactive", action="store_true") + p_test.add_argument("--off-screen", action="store_true", help="Enable off-screen mode: no printing of sensitive data to stdout.") return parser -def main(): +def main() -> None: parser = build_parser() args = parser.parse_args() try: if args.cmd == "fetchkey": - cmd_fetchkey(args.url, args.out, args.timeout, args.secure_mode) + cmd_fetchkey(args.url, args.out, args.timeout, args.off_screen) return if args.cmd == "gen": cmd_gen(args) @@ -672,7 +1002,6 @@ def main(): cmd_test(args) return - # No subcommand: Always show help and exit parser.print_help() sys.exit(2)