import sys import os import json import pytest import subprocess from pathlib import Path # Add src to path at the very top TEST_DIR = Path(__file__).parent SRC_DIR = TEST_DIR.parent / "src" sys.path.insert(0, str(SRC_DIR)) import pyhdwallet from bip_utils import Bip39SeedGenerator DATA_DIR = TEST_DIR / "data" VECTORS_FILE = TEST_DIR / "vectors.json" @pytest.fixture def vectors(): if not VECTORS_FILE.exists(): pytest.fail("tests/vectors.json missing. Run tests/bootstrap_vectors.py first.") with open(VECTORS_FILE, "r") as f: return json.load(f) @pytest.fixture def recipient_key_content(): path = DATA_DIR / "recipient.asc" if not path.exists(): pytest.fail("tests/data/recipient.asc missing.") with open(path, "r", encoding="utf-8") as f: return f.read() def test_pgp_fingerprint_calculation(vectors, recipient_key_content): """ Verifies that pgp_fingerprint computes the expected fingerprint for the stored recipient key. """ expected = vectors["pgp"]["expected_fingerprint"] actual = pyhdwallet.pgp_fingerprint(recipient_key_content) assert actual == expected, "Fingerprint calculation logic has changed!" def test_pgp_require_fingerprint_match(vectors): """ Verifies the safety check require_fingerprint_match enforces exact matches. """ expected = vectors["pgp"]["expected_fingerprint"] # Should not raise pyhdwallet.require_fingerprint_match(expected, expected, "test") # Should raise on mismatch wrong = expected.replace("A", "B") if "A" in expected else expected.replace("0", "1") with pytest.raises(ValueError, match="test: PGP fingerprint mismatch"): pyhdwallet.require_fingerprint_match(wrong, expected, "test") def test_bip39_seed_derivation(vectors): """ Verifies that mnemonics convert to seeds exactly as they did at bootstrap time. """ for case in vectors["bip39"]: mnemonic = case["mnemonic"] passphrase = case["passphrase"] expected_hex = case["expected_seed_hex"] # Verify internal Bip39SeedGenerator usage matches expected hex actual_bytes = Bip39SeedGenerator(mnemonic).Generate(passphrase) assert actual_bytes.hex() == expected_hex def test_address_derivation_integrity(vectors): """ Verifies derive_all produces the exact same addresses for supported chains. """ for case in vectors["bip39"]: seed_hex = case["expected_seed_hex"] seed_bytes = bytes.fromhex(seed_hex) for profile, expected_addresses in case["derived_addresses"].items(): # Infer count from ethereum addresses in the expected data # (Ethereum always generates `count` addresses, unlike Solana which depends on profile) count = len(expected_addresses.get("ethereum", [])) result = pyhdwallet.derive_all( seed_bytes, chains=["ethereum", "bitcoin", "solana"], count=count, sol_profile=profile, export_private=False ) assert result["addresses"] == expected_addresses, f"Mismatch for profile {profile}" def test_bip85_derivation(vectors): """ Verifies BIP85 child mnemonic derivation matches expected test vectors. Tests: - Path construction - Entropy64 derivation (HMAC-SHA512) - Entropy truncation - Child mnemonic generation """ from bip_utils import Bip39SeedGenerator for case in vectors["bip85"]: master_mnemonic = case["master_mnemonic"] master_passphrase = case["master_passphrase"] child_words = case["child_words"] index = case["index"] # Generate master seed master_seed = Bip39SeedGenerator(master_mnemonic).Generate(master_passphrase) # Derive child using BIP85 path, child_mnemonic, entropy64, truncated_entropy = pyhdwallet.bip85_derive_child_mnemonic( master_seed, child_words, index ) # Assert path assert path == case["bip85_path"], f"Path mismatch: {path} != {case['bip85_path']}" # Assert entropy64 (full 64-byte HMAC output) assert entropy64.hex() == case["expected_entropy64_hex"], \ f"Entropy64 mismatch for {case['description']}" # Assert truncated entropy assert truncated_entropy.hex() == case["expected_entropy_truncated_hex"], \ f"Truncated entropy mismatch for {case['description']}" # Assert child mnemonic assert child_mnemonic == case["expected_child_mnemonic"], \ f"Child mnemonic mismatch for {case['description']}" def test_cli_gen_child_smoke(tmp_path, vectors): """ CLI smoke test for gen-child command. Verifies: - Command runs without error - ZIP file is created - Off-screen mode works - File output works """ vector = vectors["bip85"][0] # Use first BIP85 test case master_mnemonic = vector["master_mnemonic"] cmd = [ sys.executable, "src/pyhdwallet.py", "gen-child", "--mnemonic-stdin", "--index", "0", "--words", "12", "--off-screen", "--file", "--zip-password-mode", "auto", "--wallet-location", str(tmp_path) ] result = subprocess.run( cmd, input=master_mnemonic.encode("utf-8"), capture_output=True ) assert result.returncode == 0, f"CLI gen-child failed: {result.stderr.decode()}" # Check ZIP file created zips = list(tmp_path.glob("*.zip")) assert len(zips) == 1, f"Expected exactly one zip file, found {len(zips)}" # Check filename pattern zip_name = zips[0].name assert zip_name.startswith("bip85_child_"), f"Unexpected zip name: {zip_name}" def test_cli_recover_smoke(tmp_path, vectors): """ Runs the CLI in a subprocess to verify end-to-end wiring without network (recover mode). """ vector = vectors["bip39"][0] mnemonic = vector["mnemonic"] expected_fp = vectors["pgp"]["expected_fingerprint"] recipient_file = DATA_DIR / "recipient.asc" # 1. Successful Recovery cmd = [ sys.executable, "src/pyhdwallet.py", "recover", "--mnemonic-stdin", "--pgp-pubkey-file", str(recipient_file), "--expected-fingerprint", expected_fp, "--file", "--wallet-location", str(tmp_path), "--off-screen", "--zip-password-mode", "auto" ] result = subprocess.run( cmd, input=mnemonic.encode("utf-8"), capture_output=True ) assert result.returncode == 0, f"CLI failed: {result.stderr.decode()}" zips = list(tmp_path.glob("*.zip")) assert len(zips) == 1, "Expected exactly one zip file output" # 2. Failed Recovery (Wrong Fingerprint) wrong_fp = expected_fp.replace("A", "B") cmd_fail = list(cmd) cmd_fail[cmd_fail.index(expected_fp)] = wrong_fp result_fail = subprocess.run( cmd_fail, input=mnemonic.encode("utf-8"), capture_output=True ) assert result_fail.returncode != 0 assert b"fingerprint mismatch" in result_fail.stderr or b"fingerprint mismatch" in result_fail.stdout