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