feat: add Electrum-compatible wallet encryption

- Add src/crypto.py with AES-256-CBC pw_encode/pw_decode
  using double SHA256 key derivation (Electrum standard)
- Add encrypt_wallet() and decrypt_wallet() to src/hd_wallet.py
- Prompt for encryption password when saving HD wallet to file
- Add 13 encryption/decryption tests to tests/test_hd_wallet.py
- Add pycryptodome to requirements.txt
This commit is contained in:
2026-03-09 13:35:33 +01:00
parent 44e60383cf
commit 54d282a36b
4 changed files with 200 additions and 3 deletions

View File

@@ -2,7 +2,8 @@ import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from src.hd_wallet import generate_hd_wallet
from src.hd_wallet import generate_hd_wallet, encrypt_wallet, decrypt_wallet
from src.crypto import pw_decode
SECRETSCAN_URL = "https://secretscan.org/Bitcoin?address={}"
@@ -222,6 +223,103 @@ def test_hd_generates_mnemonic_when_none():
assert len(result['keystore']['seed'].split()) == 12
# --- Encryption ---
TEST_PASSWORD = "correct_horse_battery_staple"
def test_hd_no_encryption_by_default():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
assert result['use_encryption'] is False
def test_hd_encryption_sets_flag():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
assert encrypted['use_encryption'] is True
def test_hd_encryption_encrypts_seed():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
assert encrypted['keystore']['seed'] != TEST_MNEMONIC
def test_hd_encryption_encrypts_xprv():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
original_xprv = result['keystore']['xprv']
encrypted = encrypt_wallet(result, TEST_PASSWORD)
assert encrypted['keystore']['xprv'] != original_xprv
def test_hd_encryption_leaves_xpub_plaintext():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
assert encrypted['keystore']['xpub'] == result['keystore']['xpub']
def test_hd_encryption_leaves_addresses_plaintext():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
assert encrypted['addresses']['receiving'][0]['address'] == result['addresses']['receiving'][0]['address']
assert encrypted['addresses']['receiving'][0]['public_key'] == result['addresses']['receiving'][0]['public_key']
def test_hd_encryption_encrypts_private_keys():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=2)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
for i in range(2):
orig = result['addresses']['receiving'][i]
enc = encrypted['addresses']['receiving'][i]
assert enc['private_key_hex'] != orig['private_key_hex']
assert enc['private_key_wif'] != orig['private_key_wif']
def test_hd_encryption_round_trip_seed():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
decrypted = decrypt_wallet(encrypted, TEST_PASSWORD)
assert decrypted['keystore']['seed'] == TEST_MNEMONIC
def test_hd_encryption_round_trip_xprv():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
decrypted = decrypt_wallet(encrypted, TEST_PASSWORD)
assert decrypted['keystore']['xprv'] == result['keystore']['xprv']
def test_hd_encryption_round_trip_private_keys():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=3)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
decrypted = decrypt_wallet(encrypted, TEST_PASSWORD)
for i in range(3):
assert decrypted['addresses']['receiving'][i]['private_key_hex'] == result['addresses']['receiving'][i]['private_key_hex']
assert decrypted['addresses']['receiving'][i]['private_key_wif'] == result['addresses']['receiving'][i]['private_key_wif']
def test_hd_decrypt_wrong_password():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
encrypted = encrypt_wallet(result, TEST_PASSWORD)
try:
decrypt_wallet(encrypted, "wrong_password")
assert False, "Should have raised ValueError"
except ValueError:
pass
def test_hd_encrypt_does_not_mutate_original():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
original_seed = result['keystore']['seed']
encrypt_wallet(result, TEST_PASSWORD)
assert result['keystore']['seed'] == original_seed
def test_hd_decrypt_unencrypted_is_noop():
result = generate_hd_wallet('mainnet', 'bip84', TEST_MNEMONIC, num_addresses=1)
decrypted = decrypt_wallet(result, TEST_PASSWORD)
assert decrypted['keystore']['seed'] == result['keystore']['seed']
# --- SecretScan ---
def test_hd_print_secretscan(capsys):