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