diff --git a/src/pyhdwallet.py b/src/pyhdwallet.py index 76ec7ba..59df260 100644 --- a/src/pyhdwallet.py +++ b/src/pyhdwallet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -pyhdwallet v1.0.1 (Python 3.11+) +pyhdwallet v1.0.2 (Python 3.11+) Commands: - fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint @@ -15,6 +15,7 @@ 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] @@ -32,6 +33,8 @@ import sys import json import getpass import hashlib +import os +import tempfile import urllib.request from typing import Dict, List, Any, Optional, Tuple @@ -140,16 +143,26 @@ def sha256_hex_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int): +def cmd_fetchkey(url: str, out_path: Optional[str], timeout: int, secure_mode: bool): 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 + if not out_path: + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.asc', delete=True) + 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: + os.chmod(out_path, 0o600) print("✅ Downloaded PGP public key") print(f"URL: {url}") @@ -327,6 +340,9 @@ def cmd_gen(args): 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, memory zeroed.") + import secrets from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator @@ -355,8 +371,16 @@ def cmd_gen(args): result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=False) + # Memory zeroing + if args.secure_mode: + mnemonic = None + del mnemonic + seed_bytes = None + del seed_bytes + if not args.pgp_pubkey_file or args.unsafe_print: - print(f"📍 Generated {args.words}-word BIP39 mnemonic:\n{mnemonic}\n") + 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).") @@ -372,18 +396,29 @@ def cmd_gen(args): else: out_text = f"Master Fingerprint: {fp}\n\n" + format_addresses_human(result["addresses"]) - print(out_text) + if not args.secure_mode: + print(out_text) + # File output with permissions fix if args.file: - with open(args.file, "w", encoding="utf-8") as f: - f.write(out_text) + 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(f"Output written to temp file: {temp_file.name} (auto-deleted on exit)") + 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}") if args.pgp_pubkey_file: with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: pub = f.read() payload = { - "version": "v5", + "version": "pyhdwallet v1.0.2", "purpose": "generated mnemonic backup", "mnemonic": mnemonic, "passphrase_used": bool(args.passphrase), @@ -394,15 +429,21 @@ def cmd_gen(args): "addresses": result["addresses"], } armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) - print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") - print(armored) - print("===== END PGP ENCRYPTED PAYLOAD =====\n") + if not args.secure_mode: + print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") + print(armored) + print("===== END PGP ENCRYPTED PAYLOAD =====\n") + else: + print("Encrypted payload generated (not printed in secure mode).") def cmd_recover(args): 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, memory zeroed.") + from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator if args.export_private and not args.pgp_pubkey_file: @@ -433,16 +474,26 @@ def cmd_recover(args): else: b = bytes.fromhex(seed_hex) if len(b) != 64: - raise ValueError(f"Seed must be 64 bytes (128 hex chars), got {len(b)} bytes") + raise ValueError("Invalid input") seed_bytes = b fp = get_master_fingerprint(seed_bytes) - print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...") - print(f"Master Fingerprint: {fp}\n") + if not args.secure_mode: + 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) + # Memory zeroing + if args.secure_mode: + if mnemonic: + mnemonic = None + if seed_hex: + seed_hex = None + seed_bytes = None + del seed_bytes + if args.output == "json": out_text = json.dumps({ "master_fingerprint": fp, @@ -452,18 +503,28 @@ def cmd_recover(args): else: out_text = format_addresses_human(result["addresses"]) - print(out_text) + if not args.secure_mode: + print(out_text) + # File output with permissions fix if args.file: - with open(args.file, "w", encoding="utf-8") as f: - f.write(out_text) + 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(f"Output written to temp file: {temp_file.name} (auto-deleted on exit)") + else: + with open(args.file, 'w') as f: + f.write(out_text) + os.chmod(args.file, 0o600) + print(f"Output written to {args.file}") 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": "v5", + "version": "pyhdwallet v1.0.2", "purpose": "recovery payload", "master_fingerprint": fp, "solana_profile": args.sol_profile, @@ -484,15 +545,21 @@ def cmd_recover(args): payload["addresses"] = result["addresses"] armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) - print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") - print(armored) - print("===== END PGP ENCRYPTED PAYLOAD =====\n") + if not args.secure_mode: + print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====") + print(armored) + print("===== END PGP ENCRYPTED PAYLOAD =====\n") + else: + print("Encrypted payload generated (not printed in secure mode).") def cmd_test(args): with NetworkGuard("test"): require_for_offline(["ethereum", "solana"]) + if args.secure_mode: + print("⚠️ Secure mode enabled (no effect on test).") + from bip_utils import Bip39SeedGenerator print("🧪 Running tests...") @@ -510,7 +577,7 @@ def cmd_test(args): got_addr = sol_pubkey_b58_from_seed32(seed32) if got_addr != expected_addr: - raise RuntimeError(f"Solana address mismatch for {path}: got {got_addr}, expected {expected_addr}") + raise RuntimeError("Invalid input") print(f"✅ Solana OK: {path} => {got_addr}") print("✅ All tests passed") @@ -522,8 +589,8 @@ def cmd_test(args): # ----------------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="HD wallet gen/recover (offline) + fetchkey (online)") - parser.add_argument("--version", action="version", version="pyhdwallet v1.0.1") + parser = argparse.ArgumentParser(description="pyhdwallet v1.0.2 - Secure HD Wallet Tool") + parser.add_argument("--version", action="version", version="pyhdwallet v1.0.2") sub = parser.add_subparsers(dest="cmd") # fetchkey @@ -531,6 +598,7 @@ 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.') # shared for gen/recover and also top-level (so recover works without explicit subcommand) def add_common(p: argparse.ArgumentParser): @@ -550,6 +618,7 @@ def build_parser() -> argparse.ArgumentParser: 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.') # gen p_gen = sub.add_parser("gen", help="Generate BIP39 mnemonic (offline)") @@ -568,7 +637,8 @@ def build_parser() -> argparse.ArgumentParser: # test p_test = sub.add_parser("test", help="Run minimal offline tests") - # no args + 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) @@ -585,7 +655,7 @@ def main(): try: if args.cmd == "fetchkey": - cmd_fetchkey(args.url, args.out, args.timeout) + cmd_fetchkey(args.url, args.out, args.timeout, args.secure_mode) return if args.cmd == "gen": cmd_gen(args)