gabriel / muse public
test_core_hdkeys.py python
674 lines 28.2 KB
Raw
sha256:2a703f78341332ef0beb9856d2267de6aec89b3883c31519b6900b667d026e62 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor ⚠ breaking 5 days ago
1 """Tests for muse.core.hdkeys — six-level domain-first HD key derivation.
2
3 Test categories
4 ---------------
5 - Unit: constants, path construction, argument validation, return types
6 - Integration: full pipeline from mnemonic → seed → key → signature
7 - Stress: uniqueness across all dimensions (domain, entity, role, index)
8 - Security: domain isolation, agent sub-seed independence, least privilege
9 - Data integrity: consistency between hdkeys API and direct slip010 derivation
10 """
11
12 from __future__ import annotations
13
14 import pytest
15
16 from muse.core.bip39 import mnemonic_to_seed
17 from muse.core.hdkeys import (
18 DOMAIN_BLOCKCHAIN,
19 DOMAIN_CODE,
20 DOMAIN_GENERIC,
21 DOMAIN_IDENTITY,
22 DOMAIN_MIDI,
23 DOMAIN_MUSIC,
24 DOMAIN_PAYMENTS,
25 ENTITY_AGENT,
26 ENTITY_HUMAN,
27 ENTITY_ORG,
28 HdKeyError,
29 ROLE_ATTEST,
30 ROLE_DELEGATE,
31 ROLE_PROVISION,
32 ROLE_RECEIVE,
33 ROLE_SIGN,
34 derive_agent_sub_seed,
35 derive_domain_key,
36 derive_identity_key,
37 derive_key,
38 dk_to_ed25519,
39 domain_index,
40 muse_path,
41 public_bytes_from_seed,
42 )
43 from muse.core.slip010 import (
44 MUSE_PURPOSE,
45 DerivedKey,
46 child_key,
47 derive_path,
48 hardened,
49 master_key,
50 )
51
52 _TEST_MNEMONIC = (
53 "abandon abandon abandon abandon abandon abandon "
54 "abandon abandon abandon abandon abandon about"
55 )
56
57
58 @pytest.fixture
59 def seed() -> bytes:
60 return mnemonic_to_seed(_TEST_MNEMONIC)
61
62
63 @pytest.fixture
64 def raw_seed() -> bytes:
65 return bytes(range(64))
66
67
68 # ---------------------------------------------------------------------------
69 # Unit — constants
70 # ---------------------------------------------------------------------------
71
72
73 class TestConstants:
74 def test_domain_values(self) -> None:
75 # Hash-derived: sha256(name)[:4] & 0x7FFFFFFF — same pattern as MUSE_PURPOSE.
76 # These are snapshot assertions; a change here means key derivation paths changed.
77 assert DOMAIN_IDENTITY == domain_index("muse/identity")
78 assert DOMAIN_PAYMENTS == domain_index("muse/payments")
79 assert DOMAIN_CODE == domain_index("muse/code")
80 assert DOMAIN_MUSIC == domain_index("muse/music")
81 assert DOMAIN_MIDI == domain_index("muse/midi")
82 assert DOMAIN_BLOCKCHAIN == domain_index("muse/blockchain")
83 assert DOMAIN_GENERIC == domain_index("muse/generic")
84
85 def test_DOMAIN_IDENTITY_is_hash_derived_not_legacy_zero(self) -> None:
86 # Guards against regression to the legacy auto-increment value (0).
87 # The staging gabriel/muse key was generated with the legacy 0 — this
88 # test makes future regressions visible immediately.
89 assert DOMAIN_IDENTITY != 0
90 assert DOMAIN_IDENTITY == 1_660_078_172
91
92 def test_identity_hd_path_uses_hash_derived_segment(self) -> None:
93 # Belt-and-suspenders: pin the exact canonical path string.
94 # If DOMAIN_IDENTITY ever regresses to the legacy 0, this fails immediately.
95 path = muse_path(DOMAIN_IDENTITY)
96 assert path == "m/1075233755'/1660078172'/0'/0'/0'/0'", (
97 f"DOMAIN_IDENTITY path uses wrong segment (legacy 0?): {path!r}"
98 )
99
100 def test_domain_index_values_are_stable(self) -> None:
101 # Concrete values to catch any accidental algorithm change.
102 assert domain_index("muse/identity") == 1_660_078_172
103 assert domain_index("muse/payments") == 284_229_149
104 assert domain_index("muse/code") == 678_195_575
105 assert domain_index("muse/music") == 1_755_707_987
106 assert domain_index("muse/midi") == 1_444_628_350
107 assert domain_index("muse/blockchain") == 1_556_829_714
108 assert domain_index("muse/generic") == 2_023_564_266
109
110 def test_domain_index_fits_in_31_bits(self) -> None:
111 for name in ["muse/identity", "muse/payments", "muse/code",
112 "muse/music", "muse/midi", "muse/blockchain", "muse/generic"]:
113 idx = domain_index(name)
114 assert 0 <= idx <= 0x7FFF_FFFF, f"domain_index({name!r}) = {idx} out of range"
115
116 def test_entity_values(self) -> None:
117 assert ENTITY_HUMAN == 0
118 assert ENTITY_AGENT == 1
119 assert ENTITY_ORG == 2
120
121 def test_role_values(self) -> None:
122 assert ROLE_SIGN == 0
123 assert ROLE_RECEIVE == 1
124 assert ROLE_PROVISION == 2
125 assert ROLE_ATTEST == 3
126 assert ROLE_DELEGATE == 4
127
128 def test_all_domains_distinct(self) -> None:
129 domains = [DOMAIN_IDENTITY, DOMAIN_PAYMENTS, DOMAIN_CODE,
130 DOMAIN_MUSIC, DOMAIN_MIDI, DOMAIN_BLOCKCHAIN, DOMAIN_GENERIC]
131 assert len(set(domains)) == 7
132
133 def test_all_entities_distinct(self) -> None:
134 entities = [ENTITY_HUMAN, ENTITY_AGENT, ENTITY_ORG]
135 assert len(set(entities)) == 3
136
137 def test_all_roles_distinct(self) -> None:
138 roles = [ROLE_SIGN, ROLE_RECEIVE, ROLE_PROVISION, ROLE_ATTEST, ROLE_DELEGATE]
139 assert len(set(roles)) == 5
140
141
142 # ---------------------------------------------------------------------------
143 # Unit — muse_path()
144 # ---------------------------------------------------------------------------
145
146
147 class TestMusePath:
148 def test_default_identity_path(self) -> None:
149 expected = f"m/{MUSE_PURPOSE}'/{DOMAIN_IDENTITY}'/0'/0'/0'/0'"
150 assert muse_path(DOMAIN_IDENTITY) == expected
151
152 def test_music_agent_path(self) -> None:
153 expected = f"m/{MUSE_PURPOSE}'/{DOMAIN_MUSIC}'/1'/2'/0'/0'"
154 assert muse_path(DOMAIN_MUSIC, entity_type=ENTITY_AGENT, entity_id=2) == expected
155
156 def test_code_domain_rotation(self) -> None:
157 expected = f"m/{MUSE_PURPOSE}'/{DOMAIN_CODE}'/0'/0'/0'/1'"
158 assert muse_path(DOMAIN_CODE, index=1) == expected
159
160 def test_payments_receive_role(self) -> None:
161 expected = f"m/{MUSE_PURPOSE}'/{DOMAIN_PAYMENTS}'/0'/0'/1'/0'"
162 assert muse_path(DOMAIN_PAYMENTS, role=ROLE_RECEIVE) == expected
163
164 def test_all_six_levels_present(self) -> None:
165 path = muse_path(DOMAIN_IDENTITY)
166 parts = path.split("/")
167 assert parts[0] == "m"
168 assert len(parts) == 7 # m + 6 levels
169
170 def test_all_levels_hardened(self) -> None:
171 path = muse_path(DOMAIN_IDENTITY)
172 for part in path.split("/")[1:]:
173 assert part.endswith("'"), f"Level {part!r} is not hardened"
174
175 def test_negative_domain_raises(self) -> None:
176 with pytest.raises(HdKeyError, match="domain"):
177 muse_path(-1)
178
179 def test_negative_entity_id_raises(self) -> None:
180 with pytest.raises(HdKeyError, match="entity_id"):
181 muse_path(DOMAIN_IDENTITY, entity_id=-1)
182
183 def test_negative_index_raises(self) -> None:
184 with pytest.raises(HdKeyError, match="index"):
185 muse_path(DOMAIN_IDENTITY, index=-1)
186
187 def test_invalid_entity_type_raises(self) -> None:
188 with pytest.raises(HdKeyError, match="entity_type"):
189 muse_path(DOMAIN_IDENTITY, entity_type=4)
190
191 def test_invalid_role_raises(self) -> None:
192 with pytest.raises(HdKeyError, match="role"):
193 muse_path(DOMAIN_IDENTITY, role=5)
194
195 def test_returns_string(self) -> None:
196 assert isinstance(muse_path(DOMAIN_IDENTITY), str)
197
198 def test_purpose_embedded_in_path(self) -> None:
199 assert str(MUSE_PURPOSE) in muse_path(DOMAIN_IDENTITY)
200
201
202 # ---------------------------------------------------------------------------
203 # Unit — derive_key()
204 # ---------------------------------------------------------------------------
205
206
207 class TestDeriveKey:
208 def test_returns_derived_key(self, raw_seed: bytes) -> None:
209 assert isinstance(derive_key(raw_seed, DOMAIN_IDENTITY), DerivedKey)
210
211 def test_private_bytes_32(self, raw_seed: bytes) -> None:
212 assert len(derive_key(raw_seed, DOMAIN_IDENTITY).private_bytes) == 32
213
214 def test_chain_code_32(self, raw_seed: bytes) -> None:
215 assert len(derive_key(raw_seed, DOMAIN_IDENTITY).chain_code) == 32
216
217 def test_matches_direct_slip010_derivation(self, raw_seed: bytes) -> None:
218 path = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN, 0, ROLE_SIGN, 0)
219 dk_direct = derive_path(raw_seed, path)
220 dk_api = derive_key(raw_seed, DOMAIN_IDENTITY)
221 assert dk_api == dk_direct
222
223 def test_negative_domain_raises(self, raw_seed: bytes) -> None:
224 with pytest.raises(HdKeyError):
225 derive_key(raw_seed, -1)
226
227 def test_invalid_role_raises(self, raw_seed: bytes) -> None:
228 with pytest.raises(HdKeyError):
229 derive_key(raw_seed, DOMAIN_IDENTITY, role=9)
230
231 def test_deterministic(self, raw_seed: bytes) -> None:
232 dk1 = derive_key(raw_seed, DOMAIN_MUSIC)
233 dk2 = derive_key(raw_seed, DOMAIN_MUSIC)
234 assert dk1 == dk2
235
236
237 # ---------------------------------------------------------------------------
238 # Unit — derive_identity_key()
239 # ---------------------------------------------------------------------------
240
241
242 class TestDeriveIdentityKey:
243 def test_defaults_to_domain_identity(self, raw_seed: bytes) -> None:
244 dk = derive_identity_key(raw_seed)
245 expected = derive_path(raw_seed, muse_path(DOMAIN_IDENTITY))
246 assert dk == expected
247
248 def test_entity_type_agent(self, raw_seed: bytes) -> None:
249 dk = derive_identity_key(raw_seed, entity_type=ENTITY_AGENT, entity_id=0)
250 expected = derive_path(raw_seed, muse_path(DOMAIN_IDENTITY, ENTITY_AGENT, 0))
251 assert dk == expected
252
253 def test_rotation_index(self, raw_seed: bytes) -> None:
254 current = derive_identity_key(raw_seed, index=0)
255 rotated = derive_identity_key(raw_seed, index=1)
256 assert current != rotated
257
258 def test_negative_entity_id_raises(self, raw_seed: bytes) -> None:
259 with pytest.raises(HdKeyError):
260 derive_identity_key(raw_seed, entity_id=-1)
261
262
263 # ---------------------------------------------------------------------------
264 # Unit — derive_domain_key()
265 # ---------------------------------------------------------------------------
266
267
268 class TestDeriveDomainKey:
269 @pytest.mark.parametrize("domain", [
270 DOMAIN_IDENTITY, DOMAIN_PAYMENTS, DOMAIN_CODE,
271 DOMAIN_MUSIC, DOMAIN_MIDI, DOMAIN_BLOCKCHAIN,
272 ])
273 def test_all_named_domains(self, domain: int, raw_seed: bytes) -> None:
274 dk = derive_domain_key(raw_seed, domain)
275 assert isinstance(dk, DerivedKey)
276
277 def test_code_differs_from_music(self, raw_seed: bytes) -> None:
278 assert derive_domain_key(raw_seed, DOMAIN_CODE) != derive_domain_key(raw_seed, DOMAIN_MUSIC)
279
280 def test_role_receive_differs_from_sign(self, raw_seed: bytes) -> None:
281 sign = derive_domain_key(raw_seed, DOMAIN_PAYMENTS, role=ROLE_SIGN)
282 recv = derive_domain_key(raw_seed, DOMAIN_PAYMENTS, role=ROLE_RECEIVE)
283 assert sign != recv
284
285
286 # ---------------------------------------------------------------------------
287 # Unit — derive_agent_sub_seed()
288 # ---------------------------------------------------------------------------
289
290
291 class TestDeriveAgentSubSeed:
292 def test_returns_64_bytes(self, raw_seed: bytes) -> None:
293 sub = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
294 assert isinstance(sub, (bytes, bytearray)) and len(sub) == 64
295
296 def test_different_domains_produce_different_sub_seeds(self, raw_seed: bytes) -> None:
297 sub_music = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
298 sub_code = derive_agent_sub_seed(raw_seed, DOMAIN_CODE, agent_id=0)
299 assert sub_music != sub_code
300
301 def test_different_agent_ids_produce_different_sub_seeds(self, raw_seed: bytes) -> None:
302 sub0 = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
303 sub1 = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=1)
304 assert sub0 != sub1
305
306 def test_deterministic(self, raw_seed: bytes) -> None:
307 sub1 = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
308 sub2 = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
309 assert sub1 == sub2
310
311 def test_differs_from_master_seed(self, raw_seed: bytes) -> None:
312 sub = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
313 assert sub != raw_seed
314
315 def test_sub_seed_composition(self, raw_seed: bytes) -> None:
316 """Sub-seed = private_bytes + chain_code at m/purpose'/domain'/AGENT'/agent_id'."""
317 dk = master_key(raw_seed)
318 dk = child_key(dk, hardened(MUSE_PURPOSE))
319 dk = child_key(dk, hardened(DOMAIN_MUSIC))
320 dk = child_key(dk, hardened(ENTITY_AGENT))
321 dk = child_key(dk, hardened(0))
322 expected = dk.private_bytes + dk.chain_code
323 assert derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0) == expected
324
325 def test_agent_can_derive_identity_key_from_sub_seed(self, raw_seed: bytes) -> None:
326 auth_seed = derive_agent_sub_seed(raw_seed, DOMAIN_IDENTITY, agent_id=0)
327 dk = derive_identity_key(auth_seed)
328 assert isinstance(dk, DerivedKey)
329
330 def test_negative_domain_raises(self, raw_seed: bytes) -> None:
331 with pytest.raises(HdKeyError):
332 derive_agent_sub_seed(raw_seed, -1, agent_id=0)
333
334 def test_negative_agent_id_raises(self, raw_seed: bytes) -> None:
335 with pytest.raises(HdKeyError):
336 derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=-1)
337
338
339 # ---------------------------------------------------------------------------
340 # Unit — dk_to_ed25519() and public_bytes_from_seed()
341 # ---------------------------------------------------------------------------
342
343
344 class TestKeyMaterialisation:
345 def test_dk_to_ed25519_returns_signing_key(self, raw_seed: bytes) -> None:
346 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
347 dk = derive_identity_key(raw_seed)
348 assert isinstance(dk_to_ed25519(dk), Ed25519PrivateKey)
349
350 def test_sign_and_verify(self, raw_seed: bytes) -> None:
351 dk = derive_identity_key(raw_seed)
352 priv = dk_to_ed25519(dk)
353 msg = b"muse hd path design"
354 priv.public_key().verify(priv.sign(msg), msg)
355
356 def test_public_bytes_32(self, raw_seed: bytes) -> None:
357 assert len(public_bytes_from_seed(raw_seed)) == 32
358
359 def test_public_bytes_deterministic(self, raw_seed: bytes) -> None:
360 assert public_bytes_from_seed(raw_seed) == public_bytes_from_seed(raw_seed)
361
362 def test_public_bytes_defaults_to_identity_domain(self, raw_seed: bytes) -> None:
363 pub = public_bytes_from_seed(raw_seed)
364 expected = dk_to_ed25519(derive_identity_key(raw_seed)).public_key().public_bytes_raw()
365 assert pub == expected
366
367 def test_different_domains_different_public_keys(self, raw_seed: bytes) -> None:
368 pub_id = public_bytes_from_seed(raw_seed, domain=DOMAIN_IDENTITY)
369 pub_code = public_bytes_from_seed(raw_seed, domain=DOMAIN_CODE)
370 assert pub_id != pub_code
371
372
373 # ---------------------------------------------------------------------------
374 # Integration — full pipeline
375 # ---------------------------------------------------------------------------
376
377
378 class TestFullPipeline:
379 def test_mnemonic_to_signature(self, seed: bytes) -> None:
380 dk = derive_identity_key(seed)
381 priv = dk_to_ed25519(dk)
382 msg = b"muse authentication"
383 priv.public_key().verify(priv.sign(msg), msg)
384
385 def test_wrong_key_cannot_verify(self, seed: bytes) -> None:
386 from cryptography.exceptions import InvalidSignature
387 current = derive_identity_key(seed, index=0)
388 rotated = derive_identity_key(seed, index=1)
389 msg = b"signed with current key"
390 sig = dk_to_ed25519(current).sign(msg)
391 with pytest.raises(InvalidSignature):
392 dk_to_ed25519(rotated).public_key().verify(sig, msg)
393
394 def test_human_and_agent_identity_keys_differ(self, seed: bytes) -> None:
395 auth_seed = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, agent_id=0)
396 pub_human = public_bytes_from_seed(seed, domain=DOMAIN_IDENTITY)
397 pub_agent = dk_to_ed25519(derive_identity_key(auth_seed)).public_key().public_bytes_raw()
398 assert pub_human != pub_agent
399
400 def test_five_agents_distinct_identity_keys(self, seed: bytes) -> None:
401 pub_keys: set[bytes] = set()
402 for agent_id in range(5):
403 auth_seed = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, agent_id=agent_id)
404 pub = dk_to_ed25519(derive_identity_key(auth_seed)).public_key().public_bytes_raw()
405 assert pub not in pub_keys, f"Duplicate at agent_id={agent_id}"
406 pub_keys.add(pub)
407
408
409 # ---------------------------------------------------------------------------
410 # Security — domain isolation and least privilege
411 # ---------------------------------------------------------------------------
412
413
414 class TestSecurity:
415 def test_identity_key_differs_from_all_domain_keys(self, raw_seed: bytes) -> None:
416 identity_pub = public_bytes_from_seed(raw_seed, domain=DOMAIN_IDENTITY)
417 for domain in (DOMAIN_PAYMENTS, DOMAIN_CODE, DOMAIN_MUSIC,
418 DOMAIN_MIDI, DOMAIN_BLOCKCHAIN):
419 other_pub = public_bytes_from_seed(raw_seed, domain=domain)
420 assert identity_pub != other_pub, f"Identity key leaked into domain={domain}"
421
422 def test_music_agent_cannot_derive_code_domain_key(self, raw_seed: bytes) -> None:
423 """A music agent's sub-seed produces a different code key than the operator's."""
424 music_agent_seed = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
425 operator_code_pub = public_bytes_from_seed(raw_seed, domain=DOMAIN_CODE)
426 agent_code_pub = public_bytes_from_seed(music_agent_seed, domain=DOMAIN_CODE)
427 assert operator_code_pub != agent_code_pub
428
429 def test_domain_scoped_sub_seeds_are_independent(self, raw_seed: bytes) -> None:
430 domains = [DOMAIN_IDENTITY, DOMAIN_PAYMENTS, DOMAIN_CODE,
431 DOMAIN_MUSIC, DOMAIN_MIDI]
432 sub_seeds = [bytes(derive_agent_sub_seed(raw_seed, d, agent_id=0)) for d in domains]
433 assert len(set(sub_seeds)) == len(domains), "Domain sub-seeds are not independent"
434
435 def test_all_roles_produce_distinct_keys(self, raw_seed: bytes) -> None:
436 keys: set[bytes] = set()
437 for role in (ROLE_SIGN, ROLE_RECEIVE, ROLE_PROVISION, ROLE_ATTEST, ROLE_DELEGATE):
438 pub = public_bytes_from_seed(raw_seed, domain=DOMAIN_CODE, role=role)
439 assert pub not in keys, f"Duplicate key for role={role}"
440 keys.add(pub)
441
442 def test_sub_seed_does_not_expose_master_seed(self, raw_seed: bytes) -> None:
443 sub = derive_agent_sub_seed(raw_seed, DOMAIN_MUSIC, agent_id=0)
444 assert sub != raw_seed
445 # First 32 bytes of sub-seed are derived private bytes — must not equal
446 # anything trivially derivable from master seed
447 assert sub[:32] != raw_seed[:32]
448
449
450 # ---------------------------------------------------------------------------
451 # Stress — uniqueness across all six dimensions
452 # ---------------------------------------------------------------------------
453
454
455 class TestStress:
456 def test_100_domain_keys_all_unique(self, raw_seed: bytes) -> None:
457 # Extend domain namespace — open by design
458 pubs: set[bytes] = set()
459 for domain in range(100):
460 pub = public_bytes_from_seed(raw_seed, domain=domain)
461 assert pub not in pubs, f"Duplicate at domain={domain}"
462 pubs.add(pub)
463
464 def test_100_entity_ids_all_unique(self, raw_seed: bytes) -> None:
465 pubs: set[bytes] = set()
466 for entity_id in range(100):
467 pub = public_bytes_from_seed(raw_seed, domain=DOMAIN_IDENTITY, entity_id=entity_id)
468 assert pub not in pubs, f"Duplicate at entity_id={entity_id}"
469 pubs.add(pub)
470
471 def test_100_rotation_indices_all_unique(self, raw_seed: bytes) -> None:
472 pubs: set[bytes] = set()
473 for index in range(100):
474 pub = public_bytes_from_seed(raw_seed, domain=DOMAIN_IDENTITY, index=index)
475 assert pub not in pubs, f"Duplicate at index={index}"
476 pubs.add(pub)
477
478 def test_50_agent_sub_seeds_all_unique_per_domain(self, raw_seed: bytes) -> None:
479 for domain in (DOMAIN_IDENTITY, DOMAIN_MUSIC, DOMAIN_CODE):
480 subs: set[bytes] = set()
481 for agent_id in range(50):
482 sub = bytes(derive_agent_sub_seed(raw_seed, domain, agent_id=agent_id))
483 assert sub not in subs, f"Duplicate at domain={domain} agent_id={agent_id}"
484 subs.add(sub)
485
486 def test_all_7_domains_x_3_entities_x_5_roles_unique(self, raw_seed: bytes) -> None:
487 domains = [DOMAIN_IDENTITY, DOMAIN_PAYMENTS, DOMAIN_CODE, DOMAIN_MUSIC,
488 DOMAIN_MIDI, DOMAIN_BLOCKCHAIN, DOMAIN_GENERIC]
489 entities = [ENTITY_HUMAN, ENTITY_AGENT, ENTITY_ORG]
490 roles = [ROLE_SIGN, ROLE_RECEIVE, ROLE_PROVISION, ROLE_ATTEST, ROLE_DELEGATE]
491 pubs: set[bytes] = set()
492 total = 0
493 for d in domains:
494 for e in entities:
495 for r in roles:
496 pub = public_bytes_from_seed(raw_seed, domain=d, entity_type=e, role=r)
497 assert pub not in pubs, f"Duplicate at domain={d} entity={e} role={r}"
498 pubs.add(pub)
499 total += 1
500 assert total == 7 * 3 * 5 # 105 unique keys
501
502
503 # ---------------------------------------------------------------------------
504 # End-to-end
505 # ---------------------------------------------------------------------------
506
507
508 class TestEndToEnd:
509 """Full workflow scenarios from mnemonic to network-ready public key.
510
511 These tests simulate real caller sequences: the path a MuseHub
512 registration, an agent spawn, and a key rotation take through this stack.
513 """
514
515 def test_musehub_registration_flow(self) -> None:
516 """Operator generates mnemonic, derives identity key, gets public key for registration."""
517 from muse.core.bip39 import generate_mnemonic, mnemonic_to_seed
518 mnemonic = generate_mnemonic()
519 seed = mnemonic_to_seed(mnemonic)
520 pub = public_bytes_from_seed(seed, domain=DOMAIN_IDENTITY)
521 assert len(pub) == 32
522 fingerprint = pub.hex()
523 assert len(fingerprint) == 64 # 32 bytes → 64 hex chars
524
525 def test_agent_spawn_flow(self) -> None:
526 """Operator spawns a music agent with domain-scoped sub-seed."""
527 from muse.core.bip39 import generate_mnemonic, mnemonic_to_seed
528 seed = mnemonic_to_seed(generate_mnemonic())
529
530 # Operator derives domain-scoped sub-seeds for the agent
531 music_seed = derive_agent_sub_seed(seed, DOMAIN_MUSIC, agent_id=0)
532 auth_seed = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, agent_id=0)
533
534 # Agent uses each seed for its respective domain
535 agent_identity_pub = dk_to_ed25519(derive_identity_key(auth_seed)).public_key().public_bytes_raw()
536 agent_music_pub = dk_to_ed25519(derive_domain_key(music_seed, DOMAIN_MUSIC)).public_key().public_bytes_raw()
537
538 assert len(agent_identity_pub) == 32
539 assert len(agent_music_pub) == 32
540 # Agent's identity pub differs from operator's
541 operator_pub = public_bytes_from_seed(seed, domain=DOMAIN_IDENTITY)
542 assert agent_identity_pub != operator_pub
543
544 def test_key_rotation_flow(self) -> None:
545 """Operator pre-registers next key then rotates."""
546 from muse.core.bip39 import generate_mnemonic, mnemonic_to_seed
547 seed = mnemonic_to_seed(generate_mnemonic())
548
549 # Current key in use
550 current_pub = public_bytes_from_seed(seed, domain=DOMAIN_IDENTITY, index=0)
551 # Pre-register next key before rotation
552 next_pub = public_bytes_from_seed(seed, domain=DOMAIN_IDENTITY, index=1)
553
554 assert current_pub != next_pub
555 # After rotation, index 1 becomes the active key
556 # Both remain independently derivable from the same seed
557 assert len(current_pub) == len(next_pub) == 32
558
559 def test_commit_signing_flow(self) -> None:
560 """Operator signs a commit hash with their code domain key."""
561 from muse.core.bip39 import generate_mnemonic, mnemonic_to_seed
562 import hashlib
563 seed = mnemonic_to_seed(generate_mnemonic())
564 dk = derive_domain_key(seed, domain=DOMAIN_CODE)
565 priv = dk_to_ed25519(dk)
566 # Simulate a commit hash
567 commit_bytes = hashlib.sha256(b"tree abc123\nparent def456\nauthor gabriel").digest()
568 sig = priv.sign(commit_bytes)
569 # Any verifier with the public key can verify
570 priv.public_key().verify(sig, commit_bytes)
571
572 def test_music_project_signing_flow(self) -> None:
573 """Operator signs a Stori project snapshot with their music domain key."""
574 from muse.core.bip39 import generate_mnemonic, mnemonic_to_seed
575 seed = mnemonic_to_seed(generate_mnemonic())
576 dk = derive_domain_key(seed, domain=DOMAIN_MUSIC)
577 priv = dk_to_ed25519(dk)
578 project_manifest = b"track: neon_sunrise.wav\nbpm: 128\nkey: Am"
579 sig = priv.sign(project_manifest)
580 priv.public_key().verify(sig, project_manifest)
581
582
583 # ---------------------------------------------------------------------------
584 # Performance
585 # ---------------------------------------------------------------------------
586
587
588 class TestPerformance:
589 """Timing budgets for hdkeys high-level operations.
590
591 Key derivation is dominated by HMAC-SHA512 (fast). The only slow
592 operation is mnemonic_to_seed (PBKDF2) which lives in bip39 — once
593 the seed is in hand, all hdkeys operations must be fast.
594 """
595
596 def test_derive_identity_key_under_5ms(self) -> None:
597 import time
598 seed = bytes(range(64))
599 start = time.perf_counter()
600 for _ in range(100):
601 derive_identity_key(seed)
602 elapsed = (time.perf_counter() - start) / 100
603 assert elapsed < 0.005, f"derive_identity_key averaged {elapsed*1000:.2f}ms — too slow"
604
605 def test_derive_domain_key_under_5ms(self) -> None:
606 import time
607 seed = bytes(range(64))
608 start = time.perf_counter()
609 for _ in range(100):
610 derive_domain_key(seed, DOMAIN_MUSIC)
611 elapsed = (time.perf_counter() - start) / 100
612 assert elapsed < 0.005, f"derive_domain_key averaged {elapsed*1000:.2f}ms — too slow"
613
614 def test_derive_agent_sub_seed_under_5ms(self) -> None:
615 import time
616 seed = bytes(range(64))
617 start = time.perf_counter()
618 for _ in range(100):
619 derive_agent_sub_seed(seed, DOMAIN_MUSIC, agent_id=0)
620 elapsed = (time.perf_counter() - start) / 100
621 assert elapsed < 0.005, f"derive_agent_sub_seed averaged {elapsed*1000:.2f}ms — too slow"
622
623 def test_public_bytes_from_seed_under_5ms(self) -> None:
624 import time
625 seed = bytes(range(64))
626 start = time.perf_counter()
627 for _ in range(100):
628 public_bytes_from_seed(seed)
629 elapsed = (time.perf_counter() - start) / 100
630 assert elapsed < 0.005, f"public_bytes_from_seed averaged {elapsed*1000:.2f}ms — too slow"
631
632 def test_105_key_matrix_under_100ms(self) -> None:
633 """6 domains × 3 entity types × 5 roles = 90 derivations must complete in < 100ms."""
634 import time
635 seed = bytes(range(64))
636 domains = [DOMAIN_IDENTITY, DOMAIN_PAYMENTS, DOMAIN_CODE, DOMAIN_MUSIC,
637 DOMAIN_MIDI, DOMAIN_BLOCKCHAIN]
638 entities = [ENTITY_HUMAN, ENTITY_AGENT, ENTITY_ORG]
639 roles = [ROLE_SIGN, ROLE_RECEIVE, ROLE_PROVISION, ROLE_ATTEST, ROLE_DELEGATE]
640 start = time.perf_counter()
641 for d in domains:
642 for e in entities:
643 for r in roles:
644 derive_key(seed, domain=d, entity_type=e, role=r)
645 elapsed = time.perf_counter() - start
646 assert elapsed < 0.1, f"105-key matrix took {elapsed*1000:.1f}ms — too slow"
647
648
649 # ---------------------------------------------------------------------------
650 # Docstrings
651 # ---------------------------------------------------------------------------
652
653
654 class TestDocstrings:
655 """Every public symbol in muse.core.hdkeys must have a docstring."""
656
657 def test_module_has_docstring(self) -> None:
658 import muse.core.hdkeys as mod
659 assert mod.__doc__, "muse.core.hdkeys module has no docstring"
660
661 @pytest.mark.parametrize("name", [
662 "HdKeyError",
663 "muse_path",
664 "derive_key",
665 "derive_identity_key",
666 "derive_domain_key",
667 "derive_agent_sub_seed",
668 "dk_to_ed25519",
669 "public_bytes_from_seed",
670 ])
671 def test_public_symbol_has_docstring(self, name: str) -> None:
672 import muse.core.hdkeys as mod
673 obj = getattr(mod, name)
674 assert obj.__doc__, f"muse.core.hdkeys.{name} has no docstring"
File History 1 commit
sha256:2a703f78341332ef0beb9856d2267de6aec89b3883c31519b6900b667d026e62 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor 5 days ago