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
|
#!/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,8 +371,16 @@ 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:
|
||||||
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:
|
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"])
|
||||||
|
|
||||||
print(out_text)
|
if not args.secure_mode:
|
||||||
|
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:
|
||||||
f.write(out_text)
|
# 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:
|
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)
|
||||||
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
|
if not args.secure_mode:
|
||||||
print(armored)
|
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
|
||||||
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
|
print(armored)
|
||||||
|
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)
|
||||||
|
|
||||||
print(f"📍 Recovering {len(args.chains)} chain(s); deriving {args.addresses} per chain/profile...")
|
if not args.secure_mode:
|
||||||
print(f"Master Fingerprint: {fp}\n")
|
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)
|
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"])
|
||||||
|
|
||||||
print(out_text)
|
if not args.secure_mode:
|
||||||
|
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:
|
||||||
f.write(out_text)
|
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:
|
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)
|
||||||
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
|
if not args.secure_mode:
|
||||||
print(armored)
|
print("\n===== BEGIN PGP ENCRYPTED PAYLOAD =====")
|
||||||
print("===== END PGP ENCRYPTED PAYLOAD =====\n")
|
print(armored)
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user