test_core_slip010.py
python
sha256:d11a87833d5fad6059b7662844bf5448a8911a17cce7a51811f71ad394f248eb
bump to v0.2.0rc13
Human
patch
6 days ago
| 1 | """Tests for muse.core.slip010 — SLIP-0010 Ed25519 hierarchical deterministic key derivation. |
| 2 | |
| 3 | Test categories |
| 4 | --------------- |
| 5 | - Unit: individual function contracts, argument validation, return types |
| 6 | - Data integrity: official SLIP-0010 test vectors (Ed25519) |
| 7 | - Integration: full path derivation pipelines |
| 8 | - Stress: repeated derivation, deep paths, large indices |
| 9 | - Security: hardened-only enforcement, key material redaction, independence |
| 10 | |
| 11 | Official test vectors |
| 12 | --------------------- |
| 13 | SLIP-0010 specifies test vectors for Ed25519 at: |
| 14 | https://github.com/satoshilabs/slips/blob/master/slip-0010.md#test-vector-2-for-ed25519 |
| 15 | |
| 16 | Two test seeds are defined (Seed1, Seed2). Each has multiple path steps with |
| 17 | known private key bytes and chain code bytes. We verify against both. |
| 18 | """ |
| 19 | |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import hmac |
| 23 | import hashlib |
| 24 | from dataclasses import FrozenInstanceError |
| 25 | from typing import NamedTuple |
| 26 | |
| 27 | import pytest |
| 28 | |
| 29 | from muse.core.slip010 import ( |
| 30 | HARDENED_OFFSET, |
| 31 | MUSE_PURPOSE, |
| 32 | DerivedKey, |
| 33 | Slip010Error, |
| 34 | child_key, |
| 35 | derive_path, |
| 36 | hardened, |
| 37 | master_key, |
| 38 | parse_path, |
| 39 | to_ed25519_private_key, |
| 40 | ) |
| 41 | |
| 42 | |
| 43 | # --------------------------------------------------------------------------- |
| 44 | # Official SLIP-0010 test vectors — Ed25519 |
| 45 | # --------------------------------------------------------------------------- |
| 46 | # Source: https://github.com/satoshilabs/slips/blob/master/slip-0010.md |
| 47 | # |
| 48 | # Format: (seed_hex, path, expected_chain_hex, expected_private_hex) |
| 49 | # |
| 50 | # All test paths use only hardened indices (SLIP-0010 Ed25519 restriction). |
| 51 | |
| 52 | class _SlipVector(NamedTuple): |
| 53 | seed_hex: str |
| 54 | path: str |
| 55 | expected_chain_hex: str |
| 56 | expected_private_hex: str |
| 57 | |
| 58 | |
| 59 | # Test vector 1 (Seed1 = 000102030405060708090a0b0c0d0e0f) |
| 60 | _SEED1 = "000102030405060708090a0b0c0d0e0f" |
| 61 | _SEED2 = ( |
| 62 | "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c" |
| 63 | "999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542" |
| 64 | ) |
| 65 | |
| 66 | _SLIP010_VECTORS: list[_SlipVector] = [ |
| 67 | # ── Test Vector 1 (Seed1 = 000102030405060708090a0b0c0d0e0f) ────────── |
| 68 | # Source: https://github.com/satoshilabs/slips/blob/master/slip-0010.md |
| 69 | _SlipVector( |
| 70 | seed_hex=_SEED1, |
| 71 | path="m/0'", |
| 72 | expected_chain_hex="8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69", |
| 73 | expected_private_hex="68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3", |
| 74 | ), |
| 75 | _SlipVector( |
| 76 | seed_hex=_SEED1, |
| 77 | path="m/0'/1'", |
| 78 | expected_chain_hex="a320425f77d1b5c2505a6b1b27382b37368ee640e3557c315416801243552f14", |
| 79 | expected_private_hex="b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2", |
| 80 | ), |
| 81 | _SlipVector( |
| 82 | seed_hex=_SEED1, |
| 83 | path="m/0'/1'/2'", |
| 84 | expected_chain_hex="2e69929e00b5ab250f49c3fb1c12f252de4fed2c1db88387094a0f8c4c9ccd6c", |
| 85 | expected_private_hex="92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9", |
| 86 | ), |
| 87 | _SlipVector( |
| 88 | seed_hex=_SEED1, |
| 89 | path="m/0'/1'/2'/2'", |
| 90 | expected_chain_hex="8f6d87f93d750e0efccda017d662a1b31a266e4a6f5993b15f5c1f07f74dd5cc", |
| 91 | expected_private_hex="30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662", |
| 92 | ), |
| 93 | _SlipVector( |
| 94 | seed_hex=_SEED1, |
| 95 | path="m/0'/1'/2'/2'/1000000000'", |
| 96 | expected_chain_hex="68789923a0cac2cd5a29172a475fe9e0fb14cd6adb5ad98a3fa70333e7afa230", |
| 97 | expected_private_hex="8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793", |
| 98 | ), |
| 99 | # ── Test Vector 2 (Seed2 = fffcf9f6…4542) ──────────────────────────── |
| 100 | _SlipVector( |
| 101 | seed_hex=_SEED2, |
| 102 | path="m/0'", |
| 103 | expected_chain_hex="0b78a3226f915c082bf118f83618a618ab6dec793752624cbeb622acb562862d", |
| 104 | expected_private_hex="1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635", |
| 105 | ), |
| 106 | _SlipVector( |
| 107 | seed_hex=_SEED2, |
| 108 | path="m/0'/2147483647'", |
| 109 | expected_chain_hex="138f0b2551bcafeca6ff2aa88ba8ed0ed8de070841f0c4ef0165df8181eaad7f", |
| 110 | expected_private_hex="ea4f5bfe8694d8bb74b7b59404632fd5968b774ed545e810de9c32a4fb4192f4", |
| 111 | ), |
| 112 | _SlipVector( |
| 113 | seed_hex=_SEED2, |
| 114 | path="m/0'/2147483647'/1'", |
| 115 | expected_chain_hex="73bd9fff1cfbde33a1b846c27085f711c0fe2d66fd32e139d3ebc28e5a4a6b90", |
| 116 | expected_private_hex="3757c7577170179c7868353ada796c839135b3d30554bbb74a4b1e4a5a58505c", |
| 117 | ), |
| 118 | _SlipVector( |
| 119 | seed_hex=_SEED2, |
| 120 | path="m/0'/2147483647'/1'/2147483646'", |
| 121 | expected_chain_hex="0902fe8a29f9140480a00ef244bd183e8a13288e4412d8389d140aac1794825a", |
| 122 | expected_private_hex="5837736c89570de861ebc173b1086da4f505d4adb387c6a1b1342d5e4ac9ec72", |
| 123 | ), |
| 124 | _SlipVector( |
| 125 | seed_hex=_SEED2, |
| 126 | path="m/0'/2147483647'/1'/2147483646'/2'", |
| 127 | expected_chain_hex="5d70af781f3a37b829f0d060924d5e960bdc02e85423494afc0b1a41bbe196d4", |
| 128 | expected_private_hex="551d333177df541ad876a60ea71f00447931c0a9da16f227c11ea080d7391b8d", |
| 129 | ), |
| 130 | ] |
| 131 | |
| 132 | |
| 133 | # --------------------------------------------------------------------------- |
| 134 | # Helpers |
| 135 | # --------------------------------------------------------------------------- |
| 136 | |
| 137 | |
| 138 | def _seed_from_hex(hex_str: str) -> bytes: |
| 139 | return bytes.fromhex(hex_str.replace(" ", "")) |
| 140 | |
| 141 | |
| 142 | def _derive_master_private_key(seed_hex: str) -> tuple[str, str]: |
| 143 | """Return (chain_hex, private_hex) for the master key from a hex seed.""" |
| 144 | seed = _seed_from_hex(seed_hex) |
| 145 | I = hmac.new(b"ed25519 seed", seed, hashlib.sha512).digest() |
| 146 | return I[32:].hex(), I[:32].hex() |
| 147 | |
| 148 | |
| 149 | # --------------------------------------------------------------------------- |
| 150 | # Unit — DerivedKey |
| 151 | # --------------------------------------------------------------------------- |
| 152 | |
| 153 | |
| 154 | class TestDerivedKey: |
| 155 | def test_construction(self) -> None: |
| 156 | dk = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) |
| 157 | assert dk.private_bytes == b"\x01" * 32 |
| 158 | assert dk.chain_code == b"\x02" * 32 |
| 159 | |
| 160 | def test_repr_redacts_key_material(self) -> None: |
| 161 | dk = DerivedKey(private_bytes=b"\xde\xad" * 16, chain_code=b"\xbe\xef" * 16) |
| 162 | r = repr(dk) |
| 163 | assert "deaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddeaddead" not in r |
| 164 | assert "redacted" in r |
| 165 | |
| 166 | def test_repr_does_not_leak_private_bytes(self) -> None: |
| 167 | secret = b"\xaa" * 32 |
| 168 | dk = DerivedKey(private_bytes=secret, chain_code=b"\x00" * 32) |
| 169 | assert secret.hex() not in repr(dk) |
| 170 | assert "aa" * 32 not in repr(dk) |
| 171 | |
| 172 | def test_equality(self) -> None: |
| 173 | dk1 = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) |
| 174 | dk2 = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) |
| 175 | assert dk1 == dk2 |
| 176 | |
| 177 | def test_inequality(self) -> None: |
| 178 | dk1 = DerivedKey(private_bytes=b"\x01" * 32, chain_code=b"\x02" * 32) |
| 179 | dk2 = DerivedKey(private_bytes=b"\x03" * 32, chain_code=b"\x02" * 32) |
| 180 | assert dk1 != dk2 |
| 181 | |
| 182 | |
| 183 | # --------------------------------------------------------------------------- |
| 184 | # Unit — hardened() |
| 185 | # --------------------------------------------------------------------------- |
| 186 | |
| 187 | |
| 188 | class TestHardened: |
| 189 | def test_zero(self) -> None: |
| 190 | assert hardened(0) == HARDENED_OFFSET |
| 191 | |
| 192 | def test_703(self) -> None: |
| 193 | assert hardened(703) == 703 + HARDENED_OFFSET |
| 194 | |
| 195 | def test_max_valid(self) -> None: |
| 196 | assert hardened(HARDENED_OFFSET - 1) == (HARDENED_OFFSET - 1) + HARDENED_OFFSET |
| 197 | |
| 198 | def test_negative_raises(self) -> None: |
| 199 | with pytest.raises(Slip010Error, match="out of range"): |
| 200 | hardened(-1) |
| 201 | |
| 202 | def test_already_hardened_raises(self) -> None: |
| 203 | with pytest.raises(Slip010Error, match="out of range"): |
| 204 | hardened(HARDENED_OFFSET) |
| 205 | |
| 206 | def test_returns_int(self) -> None: |
| 207 | assert isinstance(hardened(0), int) |
| 208 | |
| 209 | |
| 210 | # --------------------------------------------------------------------------- |
| 211 | # Unit — parse_path() |
| 212 | # --------------------------------------------------------------------------- |
| 213 | |
| 214 | |
| 215 | class TestParsePath: |
| 216 | def test_single_component(self) -> None: |
| 217 | result = parse_path("m/0'") |
| 218 | assert result == [HARDENED_OFFSET] |
| 219 | |
| 220 | def test_four_component_muse_path(self) -> None: |
| 221 | result = parse_path("m/703'/0'/0'/0'") |
| 222 | assert result == [ |
| 223 | 703 + HARDENED_OFFSET, |
| 224 | HARDENED_OFFSET, |
| 225 | HARDENED_OFFSET, |
| 226 | HARDENED_OFFSET, |
| 227 | ] |
| 228 | |
| 229 | def test_large_index(self) -> None: |
| 230 | result = parse_path("m/1000000000'") |
| 231 | assert result == [1_000_000_000 + HARDENED_OFFSET] |
| 232 | |
| 233 | def test_strips_whitespace(self) -> None: |
| 234 | result = parse_path(" m/703'/0'/0'/0' ") |
| 235 | assert result == [703 + HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET] |
| 236 | |
| 237 | def test_unhardened_component_raises(self) -> None: |
| 238 | with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): |
| 239 | parse_path("m/0/1") |
| 240 | |
| 241 | def test_missing_m_prefix_raises(self) -> None: |
| 242 | with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): |
| 243 | parse_path("0'/1'") |
| 244 | |
| 245 | def test_empty_string_raises(self) -> None: |
| 246 | with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): |
| 247 | parse_path("") |
| 248 | |
| 249 | def test_just_m_raises(self) -> None: |
| 250 | with pytest.raises(Slip010Error, match="Invalid SLIP-0010 path"): |
| 251 | parse_path("m") |
| 252 | |
| 253 | def test_returns_list_of_ints(self) -> None: |
| 254 | result = parse_path("m/703'/0'/0'/0'") |
| 255 | assert all(isinstance(i, int) for i in result) |
| 256 | |
| 257 | |
| 258 | # --------------------------------------------------------------------------- |
| 259 | # Unit — master_key() |
| 260 | # --------------------------------------------------------------------------- |
| 261 | |
| 262 | |
| 263 | class TestMasterKey: |
| 264 | def test_returns_derived_key(self) -> None: |
| 265 | seed = bytes(64) # all zeros — not BIP39 but valid for derivation |
| 266 | dk = master_key(seed) |
| 267 | assert isinstance(dk, DerivedKey) |
| 268 | |
| 269 | def test_private_bytes_length(self) -> None: |
| 270 | dk = master_key(bytes(64)) |
| 271 | assert len(dk.private_bytes) == 32 |
| 272 | |
| 273 | def test_chain_code_length(self) -> None: |
| 274 | dk = master_key(bytes(64)) |
| 275 | assert len(dk.chain_code) == 32 |
| 276 | |
| 277 | def test_deterministic(self) -> None: |
| 278 | seed = bytes(range(64)) |
| 279 | assert master_key(seed) == master_key(seed) |
| 280 | |
| 281 | def test_short_seed_raises(self) -> None: |
| 282 | with pytest.raises(Slip010Error, match="at least 16 bytes"): |
| 283 | master_key(b"\x00" * 15) |
| 284 | |
| 285 | def test_15_byte_seed_raises(self) -> None: |
| 286 | with pytest.raises(Slip010Error): |
| 287 | master_key(b"\xff" * 15) |
| 288 | |
| 289 | def test_16_byte_seed_accepted(self) -> None: |
| 290 | dk = master_key(b"\x00" * 16) |
| 291 | assert isinstance(dk, DerivedKey) |
| 292 | |
| 293 | def test_empty_seed_raises(self) -> None: |
| 294 | with pytest.raises(Slip010Error): |
| 295 | master_key(b"") |
| 296 | |
| 297 | def test_different_seeds_produce_different_keys(self) -> None: |
| 298 | dk1 = master_key(bytes(64)) |
| 299 | dk2 = master_key(bytes([1] * 64)) |
| 300 | assert dk1 != dk2 |
| 301 | |
| 302 | |
| 303 | # --------------------------------------------------------------------------- |
| 304 | # Unit — child_key() |
| 305 | # --------------------------------------------------------------------------- |
| 306 | |
| 307 | |
| 308 | class TestChildKey: |
| 309 | @pytest.fixture |
| 310 | def parent(self) -> DerivedKey: |
| 311 | return master_key(bytes(64)) |
| 312 | |
| 313 | def test_hardened_index_succeeds(self, parent: DerivedKey) -> None: |
| 314 | child = child_key(parent, HARDENED_OFFSET) |
| 315 | assert isinstance(child, DerivedKey) |
| 316 | |
| 317 | def test_unhardened_index_raises(self, parent: DerivedKey) -> None: |
| 318 | with pytest.raises(Slip010Error, match="hardened"): |
| 319 | child_key(parent, 0) |
| 320 | |
| 321 | def test_unhardened_index_raises_for_all_under_offset(self, parent: DerivedKey) -> None: |
| 322 | with pytest.raises(Slip010Error): |
| 323 | child_key(parent, HARDENED_OFFSET - 1) |
| 324 | |
| 325 | def test_returns_different_key_than_parent(self, parent: DerivedKey) -> None: |
| 326 | child = child_key(parent, hardened(0)) |
| 327 | assert child != parent |
| 328 | |
| 329 | def test_different_indices_produce_different_children(self, parent: DerivedKey) -> None: |
| 330 | c0 = child_key(parent, hardened(0)) |
| 331 | c1 = child_key(parent, hardened(1)) |
| 332 | assert c0 != c1 |
| 333 | |
| 334 | def test_child_private_bytes_length(self, parent: DerivedKey) -> None: |
| 335 | c = child_key(parent, hardened(0)) |
| 336 | assert len(c.private_bytes) == 32 |
| 337 | |
| 338 | def test_child_chain_code_length(self, parent: DerivedKey) -> None: |
| 339 | c = child_key(parent, hardened(0)) |
| 340 | assert len(c.chain_code) == 32 |
| 341 | |
| 342 | def test_deterministic(self, parent: DerivedKey) -> None: |
| 343 | c1 = child_key(parent, hardened(703)) |
| 344 | c2 = child_key(parent, hardened(703)) |
| 345 | assert c1 == c2 |
| 346 | |
| 347 | |
| 348 | # --------------------------------------------------------------------------- |
| 349 | # Unit — derive_path() |
| 350 | # --------------------------------------------------------------------------- |
| 351 | |
| 352 | |
| 353 | class TestDerivePath: |
| 354 | def test_single_component_path(self) -> None: |
| 355 | seed = bytes(64) |
| 356 | dk = derive_path(seed, "m/0'") |
| 357 | expected = child_key(master_key(seed), hardened(0)) |
| 358 | assert dk == expected |
| 359 | |
| 360 | def test_four_component_path(self) -> None: |
| 361 | seed = bytes(64) |
| 362 | # Manual derivation |
| 363 | dk = master_key(seed) |
| 364 | dk = child_key(dk, hardened(703)) |
| 365 | dk = child_key(dk, hardened(0)) |
| 366 | dk = child_key(dk, hardened(0)) |
| 367 | dk = child_key(dk, hardened(0)) |
| 368 | assert derive_path(seed, "m/703'/0'/0'/0'") == dk |
| 369 | |
| 370 | def test_unhardened_path_raises(self) -> None: |
| 371 | with pytest.raises(Slip010Error): |
| 372 | derive_path(bytes(64), "m/0/1") |
| 373 | |
| 374 | def test_invalid_path_raises(self) -> None: |
| 375 | with pytest.raises(Slip010Error): |
| 376 | derive_path(bytes(64), "not-a-path") |
| 377 | |
| 378 | def test_short_seed_raises(self) -> None: |
| 379 | with pytest.raises(Slip010Error): |
| 380 | derive_path(b"\x00" * 10, "m/703'/0'/0'/0'") |
| 381 | |
| 382 | |
| 383 | # --------------------------------------------------------------------------- |
| 384 | # Data integrity — official SLIP-0010 test vectors |
| 385 | # --------------------------------------------------------------------------- |
| 386 | |
| 387 | |
| 388 | class TestSlip010OfficialVectors: |
| 389 | """Verify against the official SLIP-0010 Ed25519 test vectors. |
| 390 | |
| 391 | Each vector specifies a seed, a derivation path, expected chain code, |
| 392 | and expected private key bytes. A mismatch here indicates a bug in the |
| 393 | HMAC-SHA512 derivation or the index encoding. |
| 394 | """ |
| 395 | |
| 396 | @pytest.mark.parametrize("v", _SLIP010_VECTORS, ids=[v.path for v in _SLIP010_VECTORS]) |
| 397 | def test_vector_matches(self, v: _SlipVector) -> None: |
| 398 | seed = _seed_from_hex(v.seed_hex) |
| 399 | dk = derive_path(seed, v.path) |
| 400 | |
| 401 | expected_chain = bytes.fromhex(v.expected_chain_hex) |
| 402 | expected_priv = bytes.fromhex(v.expected_private_hex) |
| 403 | |
| 404 | assert dk.chain_code == expected_chain, ( |
| 405 | f"Chain code mismatch at {v.path}: " |
| 406 | f"got {dk.chain_code.hex()!r}, expected {v.expected_chain_hex!r}" |
| 407 | ) |
| 408 | assert dk.private_bytes == expected_priv, ( |
| 409 | f"Private key mismatch at {v.path}: " |
| 410 | f"got {dk.private_bytes.hex()!r}, expected {v.expected_private_hex!r}" |
| 411 | ) |
| 412 | |
| 413 | def test_all_vectors_produce_32_byte_fields(self) -> None: |
| 414 | for v in _SLIP010_VECTORS: |
| 415 | seed = _seed_from_hex(v.seed_hex) |
| 416 | dk = derive_path(seed, v.path) |
| 417 | assert len(dk.private_bytes) == 32 |
| 418 | assert len(dk.chain_code) == 32 |
| 419 | |
| 420 | |
| 421 | # --------------------------------------------------------------------------- |
| 422 | # Unit — to_ed25519_private_key() |
| 423 | # --------------------------------------------------------------------------- |
| 424 | |
| 425 | |
| 426 | class TestToEd25519PrivateKey: |
| 427 | def test_returns_signing_key(self) -> None: |
| 428 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 429 | |
| 430 | seed = bytes(range(64)) |
| 431 | dk = master_key(seed) |
| 432 | priv = to_ed25519_private_key(dk) |
| 433 | assert isinstance(priv, Ed25519PrivateKey) |
| 434 | |
| 435 | def test_sign_and_verify(self) -> None: |
| 436 | seed = bytes(range(64)) |
| 437 | dk = derive_path(seed, "m/703'/0'/0'/0'") |
| 438 | priv = to_ed25519_private_key(dk) |
| 439 | message = b"hello muse" |
| 440 | sig = priv.sign(message) |
| 441 | # verify does not raise on valid signature |
| 442 | priv.public_key().verify(sig, message) |
| 443 | |
| 444 | def test_public_key_is_32_bytes(self) -> None: |
| 445 | seed = bytes(range(64)) |
| 446 | dk = master_key(seed) |
| 447 | priv = to_ed25519_private_key(dk) |
| 448 | pub_bytes = priv.public_key().public_bytes_raw() |
| 449 | assert len(pub_bytes) == 32 |
| 450 | |
| 451 | def test_deterministic_public_key(self) -> None: |
| 452 | seed = bytes(range(64)) |
| 453 | dk = master_key(seed) |
| 454 | pub1 = to_ed25519_private_key(dk).public_key().public_bytes_raw() |
| 455 | pub2 = to_ed25519_private_key(dk).public_key().public_bytes_raw() |
| 456 | assert pub1 == pub2 |
| 457 | |
| 458 | def test_different_paths_different_public_keys(self) -> None: |
| 459 | seed = bytes(range(64)) |
| 460 | dk0 = derive_path(seed, "m/703'/0'/0'/0'") |
| 461 | dk1 = derive_path(seed, "m/703'/0'/0'/1'") |
| 462 | pub0 = to_ed25519_private_key(dk0).public_key().public_bytes_raw() |
| 463 | pub1 = to_ed25519_private_key(dk1).public_key().public_bytes_raw() |
| 464 | assert pub0 != pub1 |
| 465 | |
| 466 | |
| 467 | # --------------------------------------------------------------------------- |
| 468 | # Integration — full Muse path pipeline |
| 469 | # --------------------------------------------------------------------------- |
| 470 | |
| 471 | |
| 472 | class TestMusePathPipeline: |
| 473 | def test_human_operator_msign_key(self) -> None: |
| 474 | from muse.core.bip39 import mnemonic_to_seed |
| 475 | seed = mnemonic_to_seed( |
| 476 | "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" |
| 477 | ) |
| 478 | dk = derive_path(seed, "m/703'/0'/0'/0'") |
| 479 | priv = to_ed25519_private_key(dk) |
| 480 | pub = priv.public_key().public_bytes_raw() |
| 481 | assert len(pub) == 32 |
| 482 | |
| 483 | def test_agent_slot_1_msign_key_differs_from_slot_0(self) -> None: |
| 484 | from muse.core.bip39 import mnemonic_to_seed |
| 485 | seed = mnemonic_to_seed( |
| 486 | "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" |
| 487 | ) |
| 488 | dk0 = derive_path(seed, "m/703'/0'/0'/0'") |
| 489 | dk1 = derive_path(seed, "m/703'/1'/0'/0'") |
| 490 | assert dk0 != dk1 |
| 491 | |
| 492 | def test_rotation_index_produces_different_key(self) -> None: |
| 493 | from muse.core.bip39 import mnemonic_to_seed |
| 494 | seed = mnemonic_to_seed( |
| 495 | "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" |
| 496 | ) |
| 497 | dk_current = derive_path(seed, "m/703'/0'/0'/0'") |
| 498 | dk_next = derive_path(seed, "m/703'/0'/0'/1'") |
| 499 | assert dk_current != dk_next |
| 500 | |
| 501 | |
| 502 | # --------------------------------------------------------------------------- |
| 503 | # Security |
| 504 | # --------------------------------------------------------------------------- |
| 505 | |
| 506 | |
| 507 | class TestSecurity: |
| 508 | def test_hardened_only_enforced_for_index_1(self) -> None: |
| 509 | """Index 1 (unhardened) must be rejected.""" |
| 510 | seed = bytes(64) |
| 511 | dk = master_key(seed) |
| 512 | with pytest.raises(Slip010Error, match="hardened"): |
| 513 | child_key(dk, 1) |
| 514 | |
| 515 | def test_hardened_only_enforced_for_max_unhardened(self) -> None: |
| 516 | seed = bytes(64) |
| 517 | dk = master_key(seed) |
| 518 | with pytest.raises(Slip010Error): |
| 519 | child_key(dk, HARDENED_OFFSET - 1) |
| 520 | |
| 521 | def test_child_key_independence(self) -> None: |
| 522 | """Two sibling child keys share a parent but must be uncorrelated.""" |
| 523 | seed = bytes(64) |
| 524 | parent = master_key(seed) |
| 525 | child_a = child_key(parent, hardened(0)) |
| 526 | child_b = child_key(parent, hardened(1)) |
| 527 | # Private bytes should differ in many positions |
| 528 | diff = sum(a != b for a, b in zip(child_a.private_bytes, child_b.private_bytes)) |
| 529 | assert diff >= 10, f"Child keys are suspiciously similar: only {diff} bytes differ" |
| 530 | |
| 531 | def test_parent_key_not_derivable_from_child(self) -> None: |
| 532 | """Hardened derivation: child cannot reveal parent (structural check). |
| 533 | |
| 534 | We cannot formally prove this in a unit test, but we verify that the |
| 535 | child's private_bytes are not equal to, a substring of, or an XOR of |
| 536 | the parent's private_bytes — catching trivially broken implementations. |
| 537 | """ |
| 538 | seed = bytes(range(64)) |
| 539 | parent = master_key(seed) |
| 540 | child = child_key(parent, hardened(0)) |
| 541 | |
| 542 | assert child.private_bytes != parent.private_bytes |
| 543 | # Child bytes should not appear verbatim inside parent material |
| 544 | parent_material = parent.private_bytes + parent.chain_code |
| 545 | assert child.private_bytes not in parent_material |
| 546 | |
| 547 | def test_repr_never_logs_hex_key_material(self) -> None: |
| 548 | seed = bytes(range(64)) |
| 549 | dk = master_key(seed) |
| 550 | r = repr(dk) |
| 551 | assert dk.private_bytes.hex() not in r |
| 552 | assert dk.chain_code.hex() not in r |
| 553 | |
| 554 | def test_muse_purpose_constant(self) -> None: |
| 555 | """MUSE_PURPOSE = sha256(b"muse")[:4] & 0x7FFFFFFF = 1_075_233_755.""" |
| 556 | import hashlib |
| 557 | expected = int.from_bytes(hashlib.sha256(b"muse").digest()[:4], "big") & 0x7FFFFFFF |
| 558 | assert MUSE_PURPOSE == expected |
| 559 | assert MUSE_PURPOSE == 1_075_233_755 |
| 560 | |
| 561 | def test_hardened_offset_constant(self) -> None: |
| 562 | assert HARDENED_OFFSET == 0x80000000 |
| 563 | |
| 564 | |
| 565 | # --------------------------------------------------------------------------- |
| 566 | # Stress |
| 567 | # --------------------------------------------------------------------------- |
| 568 | |
| 569 | |
| 570 | class TestStress: |
| 571 | def test_derive_path_deep_five_levels(self) -> None: |
| 572 | seed = bytes(range(64)) |
| 573 | dk = derive_path(seed, f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'") |
| 574 | assert isinstance(dk, DerivedKey) |
| 575 | assert len(dk.private_bytes) == 32 |
| 576 | |
| 577 | def test_large_hardened_index(self) -> None: |
| 578 | seed = bytes(64) |
| 579 | parent = master_key(seed) |
| 580 | # Maximum valid hardened index: 2^32 - 1 |
| 581 | max_index = 0xFFFFFFFF |
| 582 | child = child_key(parent, max_index) |
| 583 | assert isinstance(child, DerivedKey) |
| 584 | |
| 585 | def test_100_sequential_children_all_unique(self) -> None: |
| 586 | seed = bytes(64) |
| 587 | parent = master_key(seed) |
| 588 | seen: set[bytes] = set() |
| 589 | for i in range(100): |
| 590 | c = child_key(parent, hardened(i)) |
| 591 | key = bytes(c.private_bytes) |
| 592 | assert key not in seen, f"Duplicate child key at index {i}" |
| 593 | seen.add(key) |
| 594 | |
| 595 | def test_repeated_derivation_is_stable(self) -> None: |
| 596 | """Same inputs must always produce same output — no randomness in derivation.""" |
| 597 | seed = bytes(range(64)) |
| 598 | path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'" |
| 599 | dk_a = derive_path(seed, path) |
| 600 | dk_b = derive_path(seed, path) |
| 601 | dk_c = derive_path(seed, path) |
| 602 | assert dk_a == dk_b == dk_c |
| 603 | |
| 604 | |
| 605 | # --------------------------------------------------------------------------- |
| 606 | # Performance |
| 607 | # --------------------------------------------------------------------------- |
| 608 | |
| 609 | |
| 610 | class TestPerformance: |
| 611 | """Timing budgets for SLIP-0010 Ed25519 operations. |
| 612 | |
| 613 | HMAC-SHA512 is fast. A single derivation step must stay under 1 ms. |
| 614 | A full six-level Muse path must complete in under 5 ms. Signing and |
| 615 | public-key extraction must complete in under 2 ms. |
| 616 | """ |
| 617 | |
| 618 | def test_master_key_under_1ms(self) -> None: |
| 619 | import time |
| 620 | seed = bytes(range(64)) |
| 621 | start = time.perf_counter() |
| 622 | for _ in range(200): |
| 623 | master_key(seed) |
| 624 | elapsed = (time.perf_counter() - start) / 200 |
| 625 | assert elapsed < 0.001, f"master_key averaged {elapsed*1000:.2f}ms — too slow" |
| 626 | |
| 627 | def test_child_key_single_step_under_1ms(self) -> None: |
| 628 | import time |
| 629 | seed = bytes(range(64)) |
| 630 | parent = master_key(seed) |
| 631 | start = time.perf_counter() |
| 632 | for _ in range(200): |
| 633 | child_key(parent, hardened(0)) |
| 634 | elapsed = (time.perf_counter() - start) / 200 |
| 635 | assert elapsed < 0.001, f"child_key averaged {elapsed*1000:.2f}ms — too slow" |
| 636 | |
| 637 | def test_six_level_path_under_5ms(self) -> None: |
| 638 | import time |
| 639 | seed = bytes(range(64)) |
| 640 | path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'" |
| 641 | start = time.perf_counter() |
| 642 | for _ in range(100): |
| 643 | derive_path(seed, path) |
| 644 | elapsed = (time.perf_counter() - start) / 100 |
| 645 | assert elapsed < 0.005, f"derive_path(6 levels) averaged {elapsed*1000:.2f}ms — too slow" |
| 646 | |
| 647 | def test_to_ed25519_private_key_under_2ms(self) -> None: |
| 648 | import time |
| 649 | seed = bytes(range(64)) |
| 650 | dk = master_key(seed) |
| 651 | start = time.perf_counter() |
| 652 | for _ in range(200): |
| 653 | to_ed25519_private_key(dk) |
| 654 | elapsed = (time.perf_counter() - start) / 200 |
| 655 | assert elapsed < 0.002, f"to_ed25519_private_key averaged {elapsed*1000:.2f}ms — too slow" |
| 656 | |
| 657 | def test_sign_and_verify_under_5ms(self) -> None: |
| 658 | import time |
| 659 | seed = bytes(range(64)) |
| 660 | dk = derive_path(seed, f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'") |
| 661 | priv = to_ed25519_private_key(dk) |
| 662 | msg = b"muse performance test" |
| 663 | start = time.perf_counter() |
| 664 | for _ in range(100): |
| 665 | sig = priv.sign(msg) |
| 666 | priv.public_key().verify(sig, msg) |
| 667 | elapsed = (time.perf_counter() - start) / 100 |
| 668 | assert elapsed < 0.005, f"sign+verify averaged {elapsed*1000:.2f}ms — too slow" |
| 669 | |
| 670 | |
| 671 | # --------------------------------------------------------------------------- |
| 672 | # Docstrings |
| 673 | # --------------------------------------------------------------------------- |
| 674 | |
| 675 | |
| 676 | class TestDocstrings: |
| 677 | """Every public symbol in muse.core.slip010 must have a docstring.""" |
| 678 | |
| 679 | def test_module_has_docstring(self) -> None: |
| 680 | import muse.core.slip010 as mod |
| 681 | assert mod.__doc__, "muse.core.slip010 module has no docstring" |
| 682 | |
| 683 | @pytest.mark.parametrize("name", [ |
| 684 | "Slip010Error", |
| 685 | "DerivedKey", |
| 686 | "master_key", |
| 687 | "child_key", |
| 688 | "derive_path", |
| 689 | "parse_path", |
| 690 | "to_ed25519_private_key", |
| 691 | "hardened", |
| 692 | ]) |
| 693 | def test_public_symbol_has_docstring(self, name: str) -> None: |
| 694 | import muse.core.slip010 as mod |
| 695 | obj = getattr(mod, name) |
| 696 | assert obj.__doc__, f"muse.core.slip010.{name} has no docstring" |
File History
1 commit
sha256:d11a87833d5fad6059b7662844bf5448a8911a17cce7a51811f71ad394f248eb
bump to v0.2.0rc13
Human
patch
6 days ago