gabriel / muse public
test_core_slip010.py python
696 lines 25.6 KB
Raw
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