"""Tests for muse.core.slip010 — SLIP-0010 Ed25519 hierarchical deterministic key derivation. Test categories --------------- - Unit: individual function contracts, argument validation, return types - Data integrity: official SLIP-0010 test vectors (Ed25519) - Integration: full path derivation pipelines - Stress: repeated derivation, deep paths, large indices - Security: hardened-only enforcement, key material redaction, independence Official test vectors --------------------- SLIP-0010 specifies test vectors for Ed25519 at: https://github.com/satoshilabs/slips/blob/master/slip-0010.md#test-vector-2-for-ed25519 Two test seeds are defined (Seed1, Seed2). Each has multiple path steps with known private key bytes and chain code bytes. We verify against both. """ from __future__ import annotations import hmac import hashlib from dataclasses import FrozenInstanceError from typing import NamedTuple import pytest from muse.core.slip010 import ( HARDENED_OFFSET, MUSE_PURPOSE, DerivedKey, Slip010Error, child_key, derive_path, hardened, master_key, parse_path, to_ed25519_private_key, ) # --------------------------------------------------------------------------- # Official SLIP-0010 test vectors — Ed25519 # --------------------------------------------------------------------------- # Source: https://github.com/satoshilabs/slips/blob/master/slip-0010.md # # Format: (seed_hex, path, expected_chain_hex, expected_private_hex) # # All test paths use only hardened indices (SLIP-0010 Ed25519 restriction). class _SlipVector(NamedTuple): seed_hex: str path: str expected_chain_hex: str expected_private_hex: str # Test vector 1 (Seed1 = 000102030405060708090a0b0c0d0e0f) _SEED1 = "000102030405060708090a0b0c0d0e0f" _SEED2 = ( "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c" "999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542" ) _SLIP010_VECTORS: list[_SlipVector] = [ # ── Test Vector 1 (Seed1 = 000102030405060708090a0b0c0d0e0f) ────────── # Source: https://github.com/satoshilabs/slips/blob/master/slip-0010.md _SlipVector( seed_hex=_SEED1, path="m/0'", expected_chain_hex="8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69", expected_private_hex="68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3", ), _SlipVector( seed_hex=_SEED1, path="m/0'/1'", expected_chain_hex="a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14", expected_private_hex="b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2", ), _SlipVector( seed_hex=_SEED1, path="m/0'/1'/2'", expected_chain_hex="2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c", expected_private_hex="92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9", ), _SlipVector( seed_hex=_SEED1, path="m/0'/1'/2'/2'", expected_chain_hex="8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc", expected_private_hex="30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662", ), _SlipVector( seed_hex=_SEED1, path="m/0'/1'/2'/2'/1000000000'", expected_chain_hex="68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230", expected_private_hex="8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793", ), # ── Test Vector 2 (Seed2 = fffcf9f6…4542) ──────────────────────────── _SlipVector( seed_hex=_SEED2, path="m/0'", expected_chain_hex="0b78a3226f915c082bf118f83618a618ab6dec793752624cbeb622acb562862d", expected_private_hex="1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635", ), _SlipVector( seed_hex=_SEED2, path="m/0'/2147483647'", expected_chain_hex="138f0b2551bcafeca6ff2aa88ba8ed0ed8de070841f0c4ef0165df8181eaad7f", expected_private_hex="ea4f5bfe8694d8bb74b7b59404632fd5968b774ed545e810de9c32a4fb4192f4", ), _SlipVector( seed_hex=_SEED2, path="m/0'/2147483647'/1'", expected_chain_hex="73bd9fff1cfbde33a1b846c27085f711c0fe2d66fd32e139d3ebc28e5a4a6b90", expected_private_hex="3757c7577170179c7868353ada796c839135b3d30554bbb74a4b1e4a5a58505c", ), _SlipVector( seed_hex=_SEED2, path="m/0'/2147483647'/1'/2147483646'", expected_chain_hex="0902fe8a29f9140480a00ef244bd183e8a13288e4412d8389d140aac1794825a", expected_private_hex="5837736c89570de861ebc173b1086da4f505d4adb387c6a1b1342d5e4ac9ec72", ), _SlipVector( seed_hex=_SEED2, path="m/0'/2147483647'/1'/2147483646'/2'", expected_chain_hex="5d70af781f3a37b829f0d060924d5e960bdc02e85423494afc0b1a41bbe196d4", expected_private_hex="551d333177df541ad876a60ea71f00447931c0a9da16f227c11ea080d7391b8d", ), ] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _seed_from_hex(hex_str: str) -> bytes: return bytes.fromhex(hex_str.replace(" ", "")) def _derive_master_private_key(seed_hex: str) -> tuple[str, str]: """Return (chain_hex, private_hex) for the master key from a hex seed.""" seed = _seed_from_hex(seed_hex) I = hmac.new(b"ed25519 seed", seed, hashlib.sha512).digest() return I[32:].hex(), I[:32].hex() # --------------------------------------------------------------------------- # Unit — DerivedKey # --------------------------------------------------------------------------- class TestDerivedKey: def test_construction(self) -> None: dk = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) assert dk.private_bytes == b"\x01" * 32 assert dk.chain_code == b"\x02" * 32 def test_repr_redacts_key_material(self) -> None: dk = DerivedKey(private_bytes=b"\xde\xad" * 16, chain_code=b"\xbe\xef" * 16) r = repr(dk) assert "deaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddead" not in r assert "redacted" in r def test_repr_does_not_leak_private_bytes(self) -> None: secret = b"\xaa" * 32 dk = DerivedKey(private_bytes=secret, chain_code=b"\x00" * 32) assert secret.hex() not in repr(dk) assert "aa" * 32 not in repr(dk) def test_equality(self) -> None: dk1 = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) dk2 = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) assert dk1 == dk2 def test_inequality(self) -> None: dk1 = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) dk2 = DerivedKey(private_bytes=b"\x03" * 32, chain_code=b"\x02" * 32) assert dk1 != dk2 # --------------------------------------------------------------------------- # Unit — hardened() # --------------------------------------------------------------------------- class TestHardened: def test_zero(self) -> None: assert hardened(0) == HARDENED_OFFSET def test_703(self) -> None: assert hardened(703) == 703 + HARDENED_OFFSET def test_max_valid(self) -> None: assert hardened(HARDENED_OFFSET - 1) == (HARDENED_OFFSET - 1) + HARDENED_OFFSET def test_negative_raises(self) -> None: with pytest.raises(Slip010Error, match="out of range"): hardened(-1) def test_already_hardened_raises(self) -> None: with pytest.raises(Slip010Error, match="out of range"): hardened(HARDENED_OFFSET) def test_returns_int(self) -> None: assert isinstance(hardened(0), int) # --------------------------------------------------------------------------- # Unit — parse_path() # --------------------------------------------------------------------------- class TestParsePath: def test_single_component(self) -> None: result = parse_path("m/0'") assert result == [HARDENED_OFFSET] def test_four_component_muse_path(self) -> None: result = parse_path("m/703'/0'/0'/0'") assert result == [ 703 + HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET, ] def test_large_index(self) -> None: result = parse_path("m/1000000000'") assert result == [1_000_000_000 + HARDENED_OFFSET] def test_strips_whitespace(self) -> None: result = parse_path(" m/703'/0'/0'/0' ") assert result == [703 + HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET] def test_unhardened_component_raises(self) -> None: with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): parse_path("m/0/1") def test_missing_m_prefix_raises(self) -> None: with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): parse_path("0'/1'") def test_empty_string_raises(self) -> None: with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): parse_path("") def test_just_m_raises(self) -> None: with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): parse_path("m") def test_returns_list_of_ints(self) -> None: result = parse_path("m/703'/0'/0'/0'") assert all(isinstance(i, int) for i in result) # --------------------------------------------------------------------------- # Unit — master_key() # --------------------------------------------------------------------------- class TestMasterKey: def test_returns_derived_key(self) -> None: seed = bytes(64) # all zeros — not BIP39 but valid for derivation dk = master_key(seed) assert isinstance(dk, DerivedKey) def test_private_bytes_length(self) -> None: dk = master_key(bytes(64)) assert len(dk.private_bytes) == 32 def test_chain_code_length(self) -> None: dk = master_key(bytes(64)) assert len(dk.chain_code) == 32 def test_deterministic(self) -> None: seed = bytes(range(64)) assert master_key(seed) == master_key(seed) def test_short_seed_raises(self) -> None: with pytest.raises(Slip010Error, match="at least 16 bytes"): master_key(b"\x00" * 15) def test_15_byte_seed_raises(self) -> None: with pytest.raises(Slip010Error): master_key(b"\xff" * 15) def test_16_byte_seed_accepted(self) -> None: dk = master_key(b"\x00" * 16) assert isinstance(dk, DerivedKey) def test_empty_seed_raises(self) -> None: with pytest.raises(Slip010Error): master_key(b"") def test_different_seeds_produce_different_keys(self) -> None: dk1 = master_key(bytes(64)) dk2 = master_key(bytes([1] * 64)) assert dk1 != dk2 # --------------------------------------------------------------------------- # Unit — child_key() # --------------------------------------------------------------------------- class TestChildKey: @pytest.fixture def parent(self) -> DerivedKey: return master_key(bytes(64)) def test_hardened_index_succeeds(self, parent: DerivedKey) -> None: child = child_key(parent, HARDENED_OFFSET) assert isinstance(child, DerivedKey) def test_unhardened_index_raises(self, parent: DerivedKey) -> None: with pytest.raises(Slip010Error, match="hardened"): child_key(parent, 0) def test_unhardened_index_raises_for_all_under_offset(self, parent: DerivedKey) -> None: with pytest.raises(Slip010Error): child_key(parent, HARDENED_OFFSET - 1) def test_returns_different_key_than_parent(self, parent: DerivedKey) -> None: child = child_key(parent, hardened(0)) assert child != parent def test_different_indices_produce_different_children(self, parent: DerivedKey) -> None: c0 = child_key(parent, hardened(0)) c1 = child_key(parent, hardened(1)) assert c0 != c1 def test_child_private_bytes_length(self, parent: DerivedKey) -> None: c = child_key(parent, hardened(0)) assert len(c.private_bytes) == 32 def test_child_chain_code_length(self, parent: DerivedKey) -> None: c = child_key(parent, hardened(0)) assert len(c.chain_code) == 32 def test_deterministic(self, parent: DerivedKey) -> None: c1 = child_key(parent, hardened(703)) c2 = child_key(parent, hardened(703)) assert c1 == c2 # --------------------------------------------------------------------------- # Unit — derive_path() # --------------------------------------------------------------------------- class TestDerivePath: def test_single_component_path(self) -> None: seed = bytes(64) dk = derive_path(seed, "m/0'") expected = child_key(master_key(seed), hardened(0)) assert dk == expected def test_four_component_path(self) -> None: seed = bytes(64) # Manual derivation dk = master_key(seed) dk = child_key(dk, hardened(703)) dk = child_key(dk, hardened(0)) dk = child_key(dk, hardened(0)) dk = child_key(dk, hardened(0)) assert derive_path(seed, "m/703'/0'/0'/0'") == dk def test_unhardened_path_raises(self) -> None: with pytest.raises(Slip010Error): derive_path(bytes(64), "m/0/1") def test_invalid_path_raises(self) -> None: with pytest.raises(Slip010Error): derive_path(bytes(64), "not-a-path") def test_short_seed_raises(self) -> None: with pytest.raises(Slip010Error): derive_path(b"\x00" * 10, "m/703'/0'/0'/0'") # --------------------------------------------------------------------------- # Data integrity — official SLIP-0010 test vectors # --------------------------------------------------------------------------- class TestSlip010OfficialVectors: """Verify against the official SLIP-0010 Ed25519 test vectors. Each vector specifies a seed, a derivation path, expected chain code, and expected private key bytes. A mismatch here indicates a bug in the HMAC-SHA512 derivation or the index encoding. """ @pytest.mark.parametrize("v", _SLIP010_VECTORS, ids=[v.path for v in _SLIP010_VECTORS]) def test_vector_matches(self, v: _SlipVector) -> None: seed = _seed_from_hex(v.seed_hex) dk = derive_path(seed, v.path) expected_chain = bytes.fromhex(v.expected_chain_hex) expected_priv = bytes.fromhex(v.expected_private_hex) assert dk.chain_code == expected_chain, ( f"Chain code mismatch at {v.path}: " f"got {dk.chain_code.hex()!r}, expected {v.expected_chain_hex!r}" ) assert dk.private_bytes == expected_priv, ( f"Private key mismatch at {v.path}: " f"got {dk.private_bytes.hex()!r}, expected {v.expected_private_hex!r}" ) def test_all_vectors_produce_32_byte_fields(self) -> None: for v in _SLIP010_VECTORS: seed = _seed_from_hex(v.seed_hex) dk = derive_path(seed, v.path) assert len(dk.private_bytes) == 32 assert len(dk.chain_code) == 32 # --------------------------------------------------------------------------- # Unit — to_ed25519_private_key() # --------------------------------------------------------------------------- class TestToEd25519PrivateKey: def test_returns_signing_key(self) -> None: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey seed = bytes(range(64)) dk = master_key(seed) priv = to_ed25519_private_key(dk) assert isinstance(priv, Ed25519PrivateKey) def test_sign_and_verify(self) -> None: seed = bytes(range(64)) dk = derive_path(seed, "m/703'/0'/0'/0'") priv = to_ed25519_private_key(dk) message = b"hello muse" sig = priv.sign(message) # verify does not raise on valid signature priv.public_key().verify(sig, message) def test_public_key_is_32_bytes(self) -> None: seed = bytes(range(64)) dk = master_key(seed) priv = to_ed25519_private_key(dk) pub_bytes = priv.public_key().public_bytes_raw() assert len(pub_bytes) == 32 def test_deterministic_public_key(self) -> None: seed = bytes(range(64)) dk = master_key(seed) pub1 = to_ed25519_private_key(dk).public_key().public_bytes_raw() pub2 = to_ed25519_private_key(dk).public_key().public_bytes_raw() assert pub1 == pub2 def test_different_paths_different_public_keys(self) -> None: seed = bytes(range(64)) dk0 = derive_path(seed, "m/703'/0'/0'/0'") dk1 = derive_path(seed, "m/703'/0'/0'/1'") pub0 = to_ed25519_private_key(dk0).public_key().public_bytes_raw() pub1 = to_ed25519_private_key(dk1).public_key().public_bytes_raw() assert pub0 != pub1 # --------------------------------------------------------------------------- # Integration — full Muse path pipeline # --------------------------------------------------------------------------- class TestMusePathPipeline: def test_human_operator_msign_key(self) -> None: from muse.core.bip39 import mnemonic_to_seed seed = mnemonic_to_seed( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" ) dk = derive_path(seed, "m/703'/0'/0'/0'") priv = to_ed25519_private_key(dk) pub = priv.public_key().public_bytes_raw() assert len(pub) == 32 def test_agent_slot_1_msign_key_differs_from_slot_0(self) -> None: from muse.core.bip39 import mnemonic_to_seed seed = mnemonic_to_seed( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" ) dk0 = derive_path(seed, "m/703'/0'/0'/0'") dk1 = derive_path(seed, "m/703'/1'/0'/0'") assert dk0 != dk1 def test_rotation_index_produces_different_key(self) -> None: from muse.core.bip39 import mnemonic_to_seed seed = mnemonic_to_seed( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" ) dk_current = derive_path(seed, "m/703'/0'/0'/0'") dk_next = derive_path(seed, "m/703'/0'/0'/1'") assert dk_current != dk_next # --------------------------------------------------------------------------- # Security # --------------------------------------------------------------------------- class TestSecurity: def test_hardened_only_enforced_for_index_1(self) -> None: """Index 1 (unhardened) must be rejected.""" seed = bytes(64) dk = master_key(seed) with pytest.raises(Slip010Error, match="hardened"): child_key(dk, 1) def test_hardened_only_enforced_for_max_unhardened(self) -> None: seed = bytes(64) dk = master_key(seed) with pytest.raises(Slip010Error): child_key(dk, HARDENED_OFFSET - 1) def test_child_key_independence(self) -> None: """Two sibling child keys share a parent but must be uncorrelated.""" seed = bytes(64) parent = master_key(seed) child_a = child_key(parent, hardened(0)) child_b = child_key(parent, hardened(1)) # Private bytes should differ in many positions diff = sum(a != b for a, b in zip(child_a.private_bytes, child_b.private_bytes)) assert diff >= 10, f"Child keys are suspiciously similar: only {diff} bytes differ" def test_parent_key_not_derivable_from_child(self) -> None: """Hardened derivation: child cannot reveal parent (structural check). We cannot formally prove this in a unit test, but we verify that the child's private_bytes are not equal to, a substring of, or an XOR of the parent's private_bytes — catching trivially broken implementations. """ seed = bytes(range(64)) parent = master_key(seed) child = child_key(parent, hardened(0)) assert child.private_bytes != parent.private_bytes # Child bytes should not appear verbatim inside parent material parent_material = parent.private_bytes + parent.chain_code assert child.private_bytes not in parent_material def test_repr_never_logs_hex_key_material(self) -> None: seed = bytes(range(64)) dk = master_key(seed) r = repr(dk) assert dk.private_bytes.hex() not in r assert dk.chain_code.hex() not in r def test_muse_purpose_constant(self) -> None: """MUSE_PURPOSE = sha256(b"muse")[:4] & 0x7FFFFFFF = 1_075_233_755.""" import hashlib expected = int.from_bytes(hashlib.sha256(b"muse").digest()[:4], "big") & 0x7FFFFFFF assert MUSE_PURPOSE == expected assert MUSE_PURPOSE == 1_075_233_755 def test_hardened_offset_constant(self) -> None: assert HARDENED_OFFSET == 0x80000000 # --------------------------------------------------------------------------- # Stress # --------------------------------------------------------------------------- class TestStress: def test_derive_path_deep_five_levels(self) -> None: seed = bytes(range(64)) dk = derive_path(seed, f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'") assert isinstance(dk, DerivedKey) assert len(dk.private_bytes) == 32 def test_large_hardened_index(self) -> None: seed = bytes(64) parent = master_key(seed) # Maximum valid hardened index: 2^32 - 1 max_index = 0xFFFFFFFF child = child_key(parent, max_index) assert isinstance(child, DerivedKey) def test_100_sequential_children_all_unique(self) -> None: seed = bytes(64) parent = master_key(seed) seen: set[bytes] = set() for i in range(100): c = child_key(parent, hardened(i)) key = bytes(c.private_bytes) assert key not in seen, f"Duplicate child key at index {i}" seen.add(key) def test_repeated_derivation_is_stable(self) -> None: """Same inputs must always produce same output — no randomness in derivation.""" seed = bytes(range(64)) path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'" dk_a = derive_path(seed, path) dk_b = derive_path(seed, path) dk_c = derive_path(seed, path) assert dk_a == dk_b == dk_c # --------------------------------------------------------------------------- # Performance # --------------------------------------------------------------------------- class TestPerformance: """Timing budgets for SLIP-0010 Ed25519 operations. HMAC-SHA512 is fast. A single derivation step must stay under 1 ms. A full six-level Muse path must complete in under 5 ms. Signing and public-key extraction must complete in under 2 ms. """ def test_master_key_under_1ms(self) -> None: import time seed = bytes(range(64)) start = time.perf_counter() for _ in range(200): master_key(seed) elapsed = (time.perf_counter() - start) / 200 assert elapsed < 0.001, f"master_key averaged {elapsed*1000:.2f}ms — too slow" def test_child_key_single_step_under_1ms(self) -> None: import time seed = bytes(range(64)) parent = master_key(seed) start = time.perf_counter() for _ in range(200): child_key(parent, hardened(0)) elapsed = (time.perf_counter() - start) / 200 assert elapsed < 0.001, f"child_key averaged {elapsed*1000:.2f}ms — too slow" def test_six_level_path_under_5ms(self) -> None: import time seed = bytes(range(64)) path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'" start = time.perf_counter() for _ in range(100): derive_path(seed, path) elapsed = (time.perf_counter() - start) / 100 assert elapsed < 0.005, f"derive_path(6 levels) averaged {elapsed*1000:.2f}ms — too slow" def test_to_ed25519_private_key_under_2ms(self) -> None: import time seed = bytes(range(64)) dk = master_key(seed) start = time.perf_counter() for _ in range(200): to_ed25519_private_key(dk) elapsed = (time.perf_counter() - start) / 200 assert elapsed < 0.002, f"to_ed25519_private_key averaged {elapsed*1000:.2f}ms — too slow" def test_sign_and_verify_under_5ms(self) -> None: import time seed = bytes(range(64)) dk = derive_path(seed, f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'") priv = to_ed25519_private_key(dk) msg = b"muse performance test" start = time.perf_counter() for _ in range(100): sig = priv.sign(msg) priv.public_key().verify(sig, msg) elapsed = (time.perf_counter() - start) / 100 assert elapsed < 0.005, f"sign+verify averaged {elapsed*1000:.2f}ms — too slow" # --------------------------------------------------------------------------- # Docstrings # --------------------------------------------------------------------------- class TestDocstrings: """Every public symbol in muse.core.slip010 must have a docstring.""" def test_module_has_docstring(self) -> None: import muse.core.slip010 as mod assert mod.__doc__, "muse.core.slip010 module has no docstring" @pytest.mark.parametrize("name", [ "Slip010Error", "DerivedKey", "master_key", "child_key", "derive_path", "parse_path", "to_ed25519_private_key", "hardened", ]) def test_public_symbol_has_docstring(self, name: str) -> None: import muse.core.slip010 as mod obj = getattr(mod, name) assert obj.__doc__, f"muse.core.slip010.{name} has no docstring"