Release v1.0.2: Security patches - add --secure-mode, memory zeroing, file permission fixes, auto-deletion in secure mode, sanitized errors

This commit is contained in:
LC
2026-01-05 06:25:27 +00:00
parent 2124b96446
commit 73840d33ba

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
pyhdwallet v1.0.1 (Python 3.11+) pyhdwallet v1.0.2 (Python 3.11+)
Commands: Commands:
- fetchkey (online): download ASCII-armored PGP public key and print SHA256 + fingerprint - 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. - gen/recover/test block network I/O via NetworkGuard.
- Never stores plaintext passphrase inside payload (only passphrase_used + passphrase_hint). - Never stores plaintext passphrase inside payload (only passphrase_used + passphrase_hint).
- If encrypting, mnemonic is not printed unless --unsafe-print. - If encrypting, mnemonic is not printed unless --unsafe-print.
- --secure-mode: Enforces no printing of sensitive data, uses temp files, zeros memory.
Solana derivation: Solana derivation:
- Default: phantom_bip44change => m/44'/501'/{i}'/0' (Phantom-supported grouping). [web:13] - Default: phantom_bip44change => m/44'/501'/{i}'/0' (Phantom-supported grouping). [web:13]
@@ -32,6 +33,8 @@ import sys
import json import json
import getpass import getpass
import hashlib import hashlib
import os
import tempfile
import urllib.request import urllib.request
from typing import Dict, List, Any, Optional, Tuple from typing import Dict, List, Any, Optional, Tuple
@@ -140,16 +143,26 @@ def sha256_hex_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest() 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() require_for_fetchkey()
armored, raw = fetch_ascii_armored_text(url, timeout=timeout) armored, raw = fetch_ascii_armored_text(url, timeout=timeout)
s256 = sha256_hex_bytes(raw) s256 = sha256_hex_bytes(raw)
fpr = pgp_fingerprint(armored) 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: if out_path:
with open(out_path, "w", encoding="utf-8", newline="\n") as f: with open(out_path, "w", encoding="utf-8", newline="\n") as f:
f.write(armored) f.write(armored)
if not secure_mode:
os.chmod(out_path, 0o600)
print("✅ Downloaded PGP public key") print("✅ Downloaded PGP public key")
print(f"URL: {url}") print(f"URL: {url}")
@@ -327,6 +340,9 @@ def cmd_gen(args):
with NetworkGuard("gen"): with NetworkGuard("gen"):
require_for_offline(args.chains) 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 import secrets
from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator from bip_utils import Bip39MnemonicGenerator, Bip39Languages, Bip39SeedGenerator
@@ -355,7 +371,15 @@ def cmd_gen(args):
result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=False) 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: 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") print(f"📍 Generated {args.words}-word BIP39 mnemonic:\n{mnemonic}\n")
else: else:
print(f"📍 Generated {args.words}-word BIP39 mnemonic (not printed; encryption enabled).") print(f"📍 Generated {args.words}-word BIP39 mnemonic (not printed; encryption enabled).")
@@ -372,18 +396,29 @@ def cmd_gen(args):
else: else:
out_text = f"Master Fingerprint: {fp}\n\n" + format_addresses_human(result["addresses"]) out_text = f"Master Fingerprint: {fp}\n\n" + format_addresses_human(result["addresses"])
if not args.secure_mode:
print(out_text) print(out_text)
# File output with permissions fix
if args.file: if args.file:
with open(args.file, "w", encoding="utf-8") as f: 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) f.write(out_text)
os.chmod(args.file, 0o600) # Fix permissions
print(f"Output written to {args.file}")
if args.pgp_pubkey_file: if args.pgp_pubkey_file:
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
pub = f.read() pub = f.read()
payload = { payload = {
"version": "v5", "version": "pyhdwallet v1.0.2",
"purpose": "generated mnemonic backup", "purpose": "generated mnemonic backup",
"mnemonic": mnemonic, "mnemonic": mnemonic,
"passphrase_used": bool(args.passphrase), "passphrase_used": bool(args.passphrase),
@@ -394,15 +429,21 @@ def cmd_gen(args):
"addresses": result["addresses"], "addresses": result["addresses"],
} }
armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) 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("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
print(armored) print(armored)
print("===== END PGP ENCRYPTED PAYLOAD =====\n") print("===== END PGP ENCRYPTED PAYLOAD =====\n")
else:
print("Encrypted payload generated (not printed in secure mode).")
def cmd_recover(args): def cmd_recover(args):
with NetworkGuard("recover"): with NetworkGuard("recover"):
require_for_offline(args.chains) 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 from bip_utils import Bip39MnemonicValidator, Bip39SeedGenerator
if args.export_private and not args.pgp_pubkey_file: if args.export_private and not args.pgp_pubkey_file:
@@ -433,16 +474,26 @@ def cmd_recover(args):
else: else:
b = bytes.fromhex(seed_hex) b = bytes.fromhex(seed_hex)
if len(b) != 64: 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 seed_bytes = b
fp = get_master_fingerprint(seed_bytes) fp = get_master_fingerprint(seed_bytes)
if not args.secure_mode:
print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...") print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...")
print(f"Master Fingerprint: {fp}\n") print(f"Master Fingerprint: {fp}\n")
result = derive_all(seed_bytes, args.chains, args.addresses, args.sol_profile, export_private=args.export_private) 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": if args.output == "json":
out_text = json.dumps({ out_text = json.dumps({
"master_fingerprint": fp, "master_fingerprint": fp,
@@ -452,18 +503,28 @@ def cmd_recover(args):
else: else:
out_text = format_addresses_human(result["addresses"]) out_text = format_addresses_human(result["addresses"])
if not args.secure_mode:
print(out_text) print(out_text)
# File output with permissions fix
if args.file: if args.file:
with open(args.file, "w", encoding="utf-8") as f: 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) f.write(out_text)
os.chmod(args.file, 0o600)
print(f"Output written to {args.file}")
if args.pgp_pubkey_file: if args.pgp_pubkey_file:
with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f: with open(args.pgp_pubkey_file, "r", encoding="utf-8") as f:
pub = f.read() pub = f.read()
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"version": "v5", "version": "pyhdwallet v1.0.2",
"purpose": "recovery payload", "purpose": "recovery payload",
"master_fingerprint": fp, "master_fingerprint": fp,
"solana_profile": args.sol_profile, "solana_profile": args.sol_profile,
@@ -484,15 +545,21 @@ def cmd_recover(args):
payload["addresses"] = result["addresses"] payload["addresses"] = result["addresses"]
armored = pgp_encrypt_ascii_armored(pub, payload, ignore_usage_flags=args.pgp_ignore_usage_flags) 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("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
print(armored) print(armored)
print("===== END PGP ENCRYPTED PAYLOAD =====\n") print("===== END PGP ENCRYPTED PAYLOAD =====\n")
else:
print("Encrypted payload generated (not printed in secure mode).")
def cmd_test(args): def cmd_test(args):
with NetworkGuard("test"): with NetworkGuard("test"):
require_for_offline(["ethereum", "solana"]) require_for_offline(["ethereum", "solana"])
if args.secure_mode:
print("⚠️ Secure mode enabled (no effect on test).")
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
print("🧪 Running tests...") print("🧪 Running tests...")
@@ -510,7 +577,7 @@ def cmd_test(args):
got_addr = sol_pubkey_b58_from_seed32(seed32) got_addr = sol_pubkey_b58_from_seed32(seed32)
if got_addr != expected_addr: 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(f"✅ Solana OK: {path} => {got_addr}")
print("✅ All tests passed") print("✅ All tests passed")
@@ -522,8 +589,8 @@ def cmd_test(args):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="HD wallet gen/recover (offline) + fetchkey (online)") parser = argparse.ArgumentParser(description="pyhdwallet v1.0.2 - Secure HD Wallet Tool")
parser.add_argument("--version", action="version", version="pyhdwallet v1.0.1") parser.add_argument("--version", action="version", version="pyhdwallet v1.0.2")
sub = parser.add_subparsers(dest="cmd") sub = parser.add_subparsers(dest="cmd")
# fetchkey # fetchkey
@@ -531,6 +598,7 @@ def build_parser() -> argparse.ArgumentParser:
p_fetch.add_argument("url") p_fetch.add_argument("url")
p_fetch.add_argument("--out", default=None) p_fetch.add_argument("--out", default=None)
p_fetch.add_argument("--timeout", type=int, default=15) 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) # shared for gen/recover and also top-level (so recover works without explicit subcommand)
def add_common(p: argparse.ArgumentParser): 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)") help="If set, derive and include private keys in encrypted payload (never printed)")
p.add_argument("--include-source", action="store_true", p.add_argument("--include-source", action="store_true",
help="If set with --pgp-pubkey-file, include mnemonic/seed in encrypted payload") 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 # gen
p_gen = sub.add_parser("gen", help="Generate BIP39 mnemonic (offline)") p_gen = sub.add_parser("gen", help="Generate BIP39 mnemonic (offline)")
@@ -568,7 +637,8 @@ def build_parser() -> argparse.ArgumentParser:
# test # test
p_test = sub.add_parser("test", help="Run minimal offline tests") 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: # Also allow recover without subcommand:
add_common(parser) add_common(parser)
@@ -585,7 +655,7 @@ def main():
try: try:
if args.cmd == "fetchkey": if args.cmd == "fetchkey":
cmd_fetchkey(args.url, args.out, args.timeout) cmd_fetchkey(args.url, args.out, args.timeout, args.secure_mode)
return return
if args.cmd == "gen": if args.cmd == "gen":
cmd_gen(args) cmd_gen(args)