Add tests/test_crypto.py (22 tests) and tests/test_single_wallet.py (46 tests) for previously uncovered modules; extend existing test files with WIF format checks, error/validation cases, BIP39 passphrase, account index, words_num, and redeem script structure tests
157 lines
4.7 KiB
Python
157 lines
4.7 KiB
Python
import sys
|
|
import os
|
|
import base64
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
import pytest
|
|
from src.crypto import pw_encode, pw_decode, _derive_key
|
|
|
|
SECRETSCAN_URL = "https://secretscan.org/Bitcoin?address={}"
|
|
|
|
PASSWORD = "correct_horse_battery_staple"
|
|
PLAINTEXT = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
|
|
|
|
# --- Key derivation ---
|
|
|
|
def test_derive_key_is_32_bytes():
|
|
key = _derive_key(PASSWORD)
|
|
assert len(key) == 32
|
|
|
|
|
|
def test_derive_key_deterministic():
|
|
assert _derive_key(PASSWORD) == _derive_key(PASSWORD)
|
|
|
|
|
|
def test_derive_key_different_passwords_differ():
|
|
assert _derive_key("password1") != _derive_key("password2")
|
|
|
|
|
|
def test_derive_key_returns_bytes():
|
|
assert isinstance(_derive_key(PASSWORD), bytes)
|
|
|
|
|
|
# --- pw_encode output format ---
|
|
|
|
def test_pw_encode_returns_string():
|
|
result = pw_encode(PLAINTEXT, PASSWORD)
|
|
assert isinstance(result, str)
|
|
|
|
|
|
def test_pw_encode_is_valid_base64():
|
|
result = pw_encode(PLAINTEXT, PASSWORD)
|
|
raw = base64.b64decode(result)
|
|
# Must contain at least 16 bytes IV + 16 bytes (one AES block)
|
|
assert len(raw) >= 32
|
|
|
|
|
|
def test_pw_encode_includes_iv():
|
|
# Encoded payload = IV (16 bytes) + ciphertext; base64 of 32+ bytes
|
|
result = pw_encode(PLAINTEXT, PASSWORD)
|
|
raw = base64.b64decode(result)
|
|
assert len(raw) >= 16
|
|
|
|
|
|
# --- Random IV: same inputs produce different ciphertext each call ---
|
|
|
|
def test_pw_encode_random_iv_produces_unique_ciphertext():
|
|
c1 = pw_encode(PLAINTEXT, PASSWORD)
|
|
c2 = pw_encode(PLAINTEXT, PASSWORD)
|
|
assert c1 != c2
|
|
|
|
|
|
def test_pw_encode_random_iv_different_on_three_calls():
|
|
outputs = {pw_encode(PLAINTEXT, PASSWORD) for _ in range(5)}
|
|
assert len(outputs) == 5
|
|
|
|
|
|
# --- Round-trip ---
|
|
|
|
def test_pw_round_trip_basic():
|
|
ciphertext = pw_encode(PLAINTEXT, PASSWORD)
|
|
assert pw_decode(ciphertext, PASSWORD) == PLAINTEXT
|
|
|
|
|
|
def test_pw_round_trip_empty_string():
|
|
ciphertext = pw_encode("", PASSWORD)
|
|
assert pw_decode(ciphertext, PASSWORD) == ""
|
|
|
|
|
|
def test_pw_round_trip_unicode():
|
|
text = "passphrase: résumé café 日本語 🔑"
|
|
ciphertext = pw_encode(text, PASSWORD)
|
|
assert pw_decode(ciphertext, PASSWORD) == text
|
|
|
|
|
|
def test_pw_round_trip_long_text():
|
|
text = "a" * 1000
|
|
ciphertext = pw_encode(text, PASSWORD)
|
|
assert pw_decode(ciphertext, PASSWORD) == text
|
|
|
|
|
|
def test_pw_round_trip_special_characters():
|
|
text = '!@#$%^&*()_+-=[]{}|;\':",./<>?`~\\'
|
|
ciphertext = pw_encode(text, PASSWORD)
|
|
assert pw_decode(ciphertext, PASSWORD) == text
|
|
|
|
|
|
def test_pw_round_trip_private_key_wif():
|
|
# Simulate encrypting a real WIF-looking private key
|
|
wif = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU74NMTptX4"
|
|
ciphertext = pw_encode(wif, PASSWORD)
|
|
assert pw_decode(ciphertext, PASSWORD) == wif
|
|
|
|
|
|
# --- Wrong password / corrupted data ---
|
|
|
|
def test_pw_decode_wrong_password_raises_value_error():
|
|
ciphertext = pw_encode(PLAINTEXT, PASSWORD)
|
|
with pytest.raises(ValueError):
|
|
pw_decode(ciphertext, "wrong_password")
|
|
|
|
|
|
def test_pw_decode_wrong_password_error_message():
|
|
ciphertext = pw_encode(PLAINTEXT, PASSWORD)
|
|
with pytest.raises(ValueError, match="Incorrect password or corrupted data"):
|
|
pw_decode(ciphertext, "wrong_password")
|
|
|
|
|
|
def test_pw_decode_empty_password_still_encrypts():
|
|
# Empty password is technically valid; round-trip must work
|
|
ciphertext = pw_encode(PLAINTEXT, "")
|
|
assert pw_decode(ciphertext, "") == PLAINTEXT
|
|
|
|
|
|
def test_pw_decode_corrupted_not_base64_raises():
|
|
with pytest.raises(ValueError):
|
|
pw_decode("!!!not_valid_base64!!!", PASSWORD)
|
|
|
|
|
|
def test_pw_decode_too_short_data_raises():
|
|
# 15 bytes = less than an IV (16 bytes), cannot contain any ciphertext
|
|
short = base64.b64encode(b"x" * 15).decode()
|
|
with pytest.raises(ValueError):
|
|
pw_decode(short, PASSWORD)
|
|
|
|
|
|
def test_pw_decode_only_iv_no_ciphertext_raises():
|
|
# Exactly 16 bytes: IV with zero ciphertext → unpadding must fail
|
|
iv_only = base64.b64encode(b"A" * 16).decode()
|
|
with pytest.raises(ValueError):
|
|
pw_decode(iv_only, PASSWORD)
|
|
|
|
|
|
# --- SecretScan integration note ---
|
|
|
|
def test_crypto_print_secretscan(capsys):
|
|
"""Document that encrypted wallet seeds can be verified via SecretScan after decryption."""
|
|
ciphertext = pw_encode(PLAINTEXT, PASSWORD)
|
|
decrypted = pw_decode(ciphertext, PASSWORD)
|
|
# Once decrypted, the seed can be used to derive addresses verifiable on SecretScan
|
|
url = SECRETSCAN_URL.format("bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu")
|
|
print(f"\n[Crypto] Encrypted seed round-trip OK")
|
|
print(f"[Crypto] Derived addresses verifiable at: {url}")
|
|
captured = capsys.readouterr()
|
|
assert "secretscan.org" in captured.out
|
|
assert decrypted == PLAINTEXT
|