gabriel / muse public
hdkeys.py python
924 lines 33.1 KB
Raw
sha256:94f494a8f59e4b708ebb89e304209737ce34324a33aae83840a6f6b06d7b8d9d docs: add domain-extensibility.md — the two-axis breadth/de… Sonnet 5 2 days ago
1 """muse.core.hdkeys — Muse HD key derivation: seven-level domain-first path.
2
3 Muse derives all cryptographic keys from a single BIP39 mnemonic via SLIP-0010
4 Ed25519 hierarchical deterministic derivation. The path structure is designed
5 to be a **semantic coordinate system** — every level answers exactly one
6 question, and every key's purpose is readable from its path alone.
7
8 Path structure
9 --------------
10 ::
11
12 m / purpose' / domain' / entity_type' / entity_id' / role' / hub' / index'
13 │ │ │ │ │ │ │ │
14 │ │ │ │ │ │ │ └── Which rotation? 0'=current, 1'=pre-rotated, …
15 │ │ │ │ │ │ └────────── Which hub? hash("musehub.ai") — isolates keys per hub server
16 │ │ │ │ │ └───────────────── What does it do? 0'=sign, 1'=receive, 2'=provision, 3'=attest, 4'=delegate
17 │ │ │ │ └──────────────────────────── Which specific? 0', 1', 2', …
18 │ │ │ └─────────────────────────────────────────── What class? 0'=human, 1'=agent, 2'=org
19 │ │ └─────────────────────────────────────────────────────── What universe? 0'=identity, 1'=payments, 2'=code, 3'=music, 4'=midi, 5'=blockchain, …
20 │ └───────────────────────────────────────────────────────────────── What app? 1_075_233_755' (sha256(b"muse")[:4] & 0x7FFFFFFF)
21 └──────────────────────────────────────────────────────────────────────── HD wallet marker
22
23 Purpose
24 -------
25 ``1_075_233_755 = int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF``
26
27 Reproducible by anyone — not an arbitrary number. The high bit is masked to
28 keep it in the valid unhardened range before the hardened offset is applied.
29
30 Domains
31 -------
32 Domains are **first-class entities** in the Muse key namespace. A key
33 belongs to a domain before it belongs to an entity — the domain scopes the
34 entire sub-tree beneath it.
35
36 Domain indices are **hash-derived** — ``sha256(name)[:4] & 0x7FFFFFFF`` —
37 using the same pattern as ``MUSE_PURPOSE``. This makes the namespace open:
38 any third party can register a domain without a central committee.
39
40 .. list-table::
41 :widths: 25 65
42 :header-rows: 1
43
44 * - Constant
45 - Meaning
46 * - :data:`DOMAIN_IDENTITY` (``domain_index("muse/identity")``)
47 - Cross-domain auth. The key that answers "who are you?" on the Muse
48 network. Used for MSign HTTP signing and MuseHub registration.
49 One per human or agent — it is their passport, not their work credential.
50 * - :data:`DOMAIN_PAYMENTS` (``domain_index("muse/payments")``)
51 - MPay claims, financial settlement.
52 * - :data:`DOMAIN_CODE` (``domain_index("muse/code")``)
53 - Software VCS — commit provenance, code-review attestations.
54 * - :data:`DOMAIN_MUSIC` (``domain_index("muse/music")``)
55 - Stori audio production — project signing, master ownership.
56 * - :data:`DOMAIN_MIDI` (``domain_index("muse/midi")``)
57 - Maestro symbolic music — NL→MIDI content signing.
58 * - :data:`DOMAIN_BLOCKCHAIN` (``domain_index("muse/blockchain")``)
59 - On-chain operations (ERC-8004 identity, ERC-721, AVAX).
60 secp256k1 keys for this domain use the ``b"Bitcoin seed"``
61 SLIP-0010 HMAC root — same path grammar, different curve.
62 * - :data:`DOMAIN_GENERIC` (``domain_index("muse/generic")``)
63 - Repos and entities with no registered domain plugin.
64 First-class explicit value — never an empty string or None.
65
66 Entity types
67 ------------
68 .. list-table::
69 :widths: 10 25 65
70 :header-rows: 1
71
72 * - Value
73 - Constant
74 - Meaning
75 * - 0
76 - :data:`ENTITY_HUMAN`
77 - Human operator. Account 0 is always the primary identity.
78 * - 1
79 - :data:`ENTITY_AGENT`
80 - AI agent. Each agent slot receives a domain-scoped sub-seed
81 from the operator — it cannot derive keys outside its granted domains.
82 * - 2
83 - :data:`ENTITY_ORG`
84 - Organisation or DAO. Governance and membership live above the key layer;
85 the key tree records only that this principal is a collective.
86
87 Roles
88 -----
89 .. list-table::
90 :widths: 10 25 65
91 :header-rows: 1
92
93 * - Value
94 - Constant
95 - Meaning
96 * - 0
97 - :data:`ROLE_SIGN`
98 - Primary signing key for this domain (default).
99 * - 1
100 - :data:`ROLE_RECEIVE`
101 - Receiving / payment address key.
102 * - 2
103 - :data:`ROLE_PROVISION`
104 - Provisioning key — used during entity bootstrapping.
105 * - 3
106 - :data:`ROLE_ATTEST`
107 - Third-party attestation key (distinct from self-signing).
108 * - 4
109 - :data:`ROLE_DELEGATE`
110 - Scoped authority delegation (future).
111
112 Hub scoping
113 -----------
114 ``hub'`` is a hardened value computed as
115 ``int.from_bytes(sha256(canonical_hostname.encode())[:4], "big") & 0x7FFFFFFF``
116 — the exact same pattern already used for :func:`domain_index` and
117 :func:`agent_id_to_slot`. *canonical_hostname* is the normalised
118 ``host[:port]`` string produced by
119 :func:`muse.core.identity.hostname_from_url` (e.g. ``"musehub.ai"``,
120 ``"staging.musehub.ai"``, ``"localhost:1337"``).
121
122 Without this level, the identity key at index 0 is **identical across every
123 hub** an entity registers with — there is nothing in a bare
124 ``purpose/domain/entity_type/entity_id/role/index`` path that varies per
125 server. ``hub'`` closes that gap: compromising the key presented to one hub
126 does not expose the key presented to any other hub, the same structural
127 guarantee :data:`DOMAIN_*` already gives across domains.
128
129 .. warning::
130 This mapping is **permanent**, following the same rule as
131 :func:`domain_index`. Changing the hashing algorithm or the hostname
132 normalisation invalidates every hub-scoped key ever derived. See
133 ``muse migrate hub-scoping`` for moving existing pre-Phase-2 identities
134 (registered before this level existed) onto hub-scoped paths.
135
136 Domain-scoped agent delegation
137 -------------------------------
138 An agent's sub-seed is derived from the parent's key tree at the domain level.
139 This means an agent's capability is bounded by cryptography, not policy:
140
141 ::
142
143 # Operator grants a music agent only music-domain keys
144 music_agent_seed = derive_agent_sub_seed(master_seed, domain=DOMAIN_MUSIC, agent_id=0)
145
146 # A separate identity grant is needed for MuseHub auth
147 auth_agent_seed = derive_agent_sub_seed(master_seed, domain=DOMAIN_IDENTITY, agent_id=0)
148
149 # Agent uses each sub-seed independently — two separate key roots
150 agent_identity_key = derive_identity_key(auth_agent_seed, hub=hub_index("musehub.ai"))
151 agent_music_key = derive_domain_key(music_agent_seed, domain=DOMAIN_MUSIC)
152
153 Compromising a music agent's seed cannot reveal the operator's identity key
154 or any other domain's keys — SLIP-0010 hardened derivation guarantees this.
155
156 Sub-seed composition
157 --------------------
158 ``agent_sub_seed = dk.private_bytes + dk.chain_code`` (64 bytes)
159
160 Both halves are required: the chain code enables further child derivation from
161 the sub-seed root. The sub-seed is treated identically to a BIP39 master seed
162 by all functions in this module.
163
164 Two-tree architecture for blockchain
165 -------------------------------------
166 Ed25519 keys (domains 0–5) and secp256k1 keys (domain 6) are derived from
167 separate SLIP-0010 roots that share the same BIP39 mnemonic::
168
169 seed → HMAC("ed25519 seed", seed) → Ed25519 master (identity, payments, code, music, …)
170 seed → HMAC("Bitcoin seed", seed) → secp256k1 master (blockchain / EVM / AVAX)
171
172 Both trees use the same six-level path grammar. Blockchain-specific callers
173 use the secp256k1 master key directly; this module handles only Ed25519.
174
175 Examples
176 --------
177 ::
178
179 from muse.core.bip39 import generate_mnemonic, mnemonic_to_seed
180 from muse.core.hdkeys import (
181 derive_identity_key, derive_domain_key, derive_agent_sub_seed, hub_index,
182 dk_to_ed25519, public_bytes_from_seed,
183 DOMAIN_IDENTITY, DOMAIN_MUSIC, ENTITY_HUMAN, ENTITY_AGENT, ROLE_SIGN,
184 )
185
186 mnemonic = generate_mnemonic()
187 seed = mnemonic_to_seed(mnemonic)
188
189 # Human operator's MuseHub identity (auth) key, scoped to one hub
190 dk = derive_identity_key(seed, hub=hub_index("musehub.ai"))
191 priv = dk_to_ed25519(dk)
192 pub_bytes = priv.public_key().public_bytes_raw() # 32 bytes → register with MuseHub
193
194 # Human operator's music signing key (Stori project provenance)
195 music_dk = derive_domain_key(seed, domain=DOMAIN_MUSIC)
196
197 # Spawn a music agent with a domain-scoped sub-seed
198 agent_seed = derive_agent_sub_seed(seed, domain=DOMAIN_MUSIC, agent_id=0)
199 agent_dk = derive_identity_key(agent_seed, hub=hub_index("musehub.ai")) # agent's own MuseHub identity
200 """
201
202 import hashlib
203 from typing import TYPE_CHECKING
204
205 from muse.core.slip010 import (
206 MUSE_PURPOSE,
207 DerivedKey,
208 SecretByteArray,
209 Slip010Error,
210 child_key,
211 derive_path,
212 hardened,
213 master_key,
214 to_ed25519_private_key,
215 )
216
217 if TYPE_CHECKING:
218 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
219
220 __all__ = [
221 # Errors
222 "HdKeyError",
223 # Domain index function
224 "domain_index",
225 # Hub index function
226 "hub_index",
227 # Domain constants
228 "DOMAIN_IDENTITY",
229 "DOMAIN_PAYMENTS",
230 "DOMAIN_CODE",
231 "DOMAIN_MUSIC",
232 "DOMAIN_MIDI",
233 "DOMAIN_BLOCKCHAIN",
234 "DOMAIN_GENERIC",
235 # Entity type constants
236 "ENTITY_HUMAN",
237 "ENTITY_AGENT",
238 "ENTITY_ORG",
239 # Role constants
240 "ROLE_SIGN",
241 "ROLE_RECEIVE",
242 "ROLE_PROVISION",
243 "ROLE_ATTEST",
244 "ROLE_DELEGATE",
245 # Agent slot mapping
246 "agent_id_to_slot",
247 # Path helper
248 "muse_path",
249 # Core derivation
250 "derive_key",
251 "derive_identity_key",
252 "derive_domain_key",
253 "derive_agent_sub_seed",
254 # Key materialisation
255 "dk_to_ed25519",
256 "public_bytes_from_seed",
257 ]
258
259 # ---------------------------------------------------------------------------
260 # Domain index function
261 # ---------------------------------------------------------------------------
262
263 def domain_index(name: str) -> int:
264 """Return the canonical BIP32-compatible domain index for a named domain.
265
266 Uses the first 4 bytes of ``sha256(name.encode("utf-8"))`` interpreted as a
267 big-endian ``uint32`` masked to ``[0, 2^31 - 1]`` — the same pattern used
268 to derive ``MUSE_PURPOSE`` from ``b"muse"``.
269
270 This makes the domain namespace **open and decentralised**: any third-party
271 domain can compute its own index without a central registry. The birthday
272 bound for a 31-bit space is ~65k domains before a collision becomes likely —
273 far beyond any realistic deployment.
274
275 .. warning::
276 This mapping is **permanent**. Changing the algorithm or the canonical
277 name string for an existing domain invalidates every key ever derived
278 for that domain. Canonical name strings use the ``"muse/<slug>"``
279 convention for first-party domains.
280
281 Parameters
282 ----------
283 name:
284 Canonical domain name string (e.g. ``"muse/identity"``, ``"muse/code"``).
285
286 Returns
287 -------
288 int
289 Domain index in ``[0, 2^31 - 1]``.
290 """
291 digest = hashlib.sha256(name.encode("utf-8")).digest()
292 return int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF
293
294 # ---------------------------------------------------------------------------
295 # Hub index function
296 # ---------------------------------------------------------------------------
297
298 def hub_index(canonical_hostname: str) -> int:
299 """Return the canonical BIP32-compatible hub index for a hostname.
300
301 Uses the first 4 bytes of ``sha256(canonical_hostname.encode("utf-8"))``
302 interpreted as a big-endian ``uint32`` masked to ``[0, 2^31 - 1]`` — the
303 same pattern used for :func:`domain_index` and :func:`agent_id_to_slot`.
304
305 This isolates the HD key an entity presents to one hub from the key it
306 presents to any other hub — see musehub#221. Without this level folded
307 into the derivation path, the identity key at index 0 is bit-for-bit
308 identical regardless of which hub it's registered with.
309
310 .. warning::
311 This mapping is **permanent** (same rule as :func:`domain_index`).
312 Changing the algorithm invalidates every hub-scoped key ever derived.
313 *canonical_hostname* must already be normalised — callers pass the
314 output of :func:`muse.core.identity.hostname_from_url`, never a raw
315 URL, so that scheme/case/trailing-slash variations of the same host
316 can never resolve to different hub indices.
317
318 Parameters
319 ----------
320 canonical_hostname:
321 Normalised ``host[:port]`` string, e.g. ``"musehub.ai"``,
322 ``"staging.musehub.ai"``, ``"localhost:1337"``.
323
324 Returns
325 -------
326 int
327 Hub index in ``[0, 2^31 - 1]``.
328 """
329 digest = hashlib.sha256(canonical_hostname.encode("utf-8")).digest()
330 return int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF
331
332 # ---------------------------------------------------------------------------
333 # Domain constants
334 # ---------------------------------------------------------------------------
335
336 #: Cross-domain authentication. The "passport" key — used for MSign HTTP
337 #: signing and MuseHub registration. One per human or agent.
338 DOMAIN_IDENTITY: int = domain_index("muse/identity")
339
340 #: MPay claims and financial settlement.
341 DOMAIN_PAYMENTS: int = domain_index("muse/payments")
342
343 #: Software VCS — commit provenance, code-review attestations.
344 DOMAIN_CODE: int = domain_index("muse/code")
345
346 #: Stori audio production — project signing, master ownership.
347 DOMAIN_MUSIC: int = domain_index("muse/music")
348
349 #: Maestro symbolic music — NL→MIDI content signing.
350 DOMAIN_MIDI: int = domain_index("muse/midi")
351
352 #: On-chain operations — ERC-8004, ERC-721, ERC-1155, AVAX.
353 #: secp256k1 keys for this domain use a separate SLIP-0010 root
354 #: (``b"Bitcoin seed"`` HMAC key) — same path grammar, different curve.
355 DOMAIN_BLOCKCHAIN: int = domain_index("muse/blockchain")
356
357 #: Repos and entities with no registered domain plugin.
358 #: First-class explicit value — never an empty string or None.
359 DOMAIN_GENERIC: int = domain_index("muse/generic")
360
361 # ---------------------------------------------------------------------------
362 # Entity type constants
363 # ---------------------------------------------------------------------------
364
365 #: Human operator.
366 ENTITY_HUMAN: int = 0
367
368 #: AI agent. Each agent slot receives a domain-scoped sub-seed.
369 ENTITY_AGENT: int = 1
370
371 #: Organisation or DAO.
372 ENTITY_ORG: int = 2
373
374 # ---------------------------------------------------------------------------
375 # Role constants
376 # ---------------------------------------------------------------------------
377
378 #: Primary signing key for a domain (default).
379 ROLE_SIGN: int = 0
380
381 #: Receiving / payment address key.
382 ROLE_RECEIVE: int = 1
383
384 #: Provisioning key — entity bootstrapping.
385 ROLE_PROVISION: int = 2
386
387 #: Third-party attestation key (distinct from self-signing).
388 ROLE_ATTEST: int = 3
389
390 #: Scoped authority delegation (reserved).
391 ROLE_DELEGATE: int = 4
392
393 # ---------------------------------------------------------------------------
394 # Errors
395 # ---------------------------------------------------------------------------
396
397 class HdKeyError(ValueError):
398 """Raised when an HD key derivation request is invalid.
399
400 Common causes:
401
402 - Negative domain, entity_id, or index value.
403 - Entity type or role outside the defined range.
404 - Agent sub-seed requested for the human entity (account 0 is the
405 human operator; sub-seeds are for agents only, ``entity_id >= 0``
406 under ``ENTITY_AGENT``).
407 - Seed shorter than 16 bytes (propagated from
408 :class:`~muse.core.slip010.Slip010Error`).
409
410 Subclasses :class:`ValueError` for broad compatibility. Use
411 ``except HdKeyError`` for precise handling.
412
413 Examples
414 --------
415 ::
416
417 try:
418 derive_key(seed, domain=-1)
419 except HdKeyError as exc:
420 print(f"key error: {exc}")
421 """
422
423 # ---------------------------------------------------------------------------
424 # Agent slot mapping
425 # ---------------------------------------------------------------------------
426
427 def agent_id_to_slot(agent_id: str) -> int:
428 """Map an agent handle string to a stable BIP32-compatible slot index.
429
430 Uses the first 4 bytes of ``sha256(agent_id.encode())`` interpreted as a
431 big-endian ``uint32`` masked to ``[0, 2^31 - 1]`` (the valid range before
432 the hardened offset is applied by SLIP-0010 callers).
433
434 Properties
435 ----------
436 - **Deterministic**: same handle always maps to the same slot.
437 - **Collision-resistant**: birthday bound at ~65k handles for a 2^32
438 pre-image space — sufficient for any realistic swarm size.
439 - **Platform-independent**: SHA-256 output is identical on all platforms.
440
441 .. warning::
442 This mapping is **permanent**. Changing the algorithm invalidates
443 every agent key ever derived. Never alter it after keys are in
444 production.
445
446 Parameters
447 ----------
448 agent_id:
449 Agent handle string (e.g. ``"claude-worker-01"``). The empty string
450 is accepted (maps to a deterministic slot) but not recommended.
451
452 Returns
453 -------
454 int
455 Slot index in ``[0, 2^31 - 1]``. Pass directly to
456 :func:`derive_agent_sub_seed` as the ``agent_id`` parameter.
457 """
458 digest = hashlib.sha256(agent_id.encode()).digest()
459 return int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF
460
461 # ---------------------------------------------------------------------------
462 # Path helper
463 # ---------------------------------------------------------------------------
464
465 def muse_path(
466 domain: int,
467 entity_type: int = ENTITY_HUMAN,
468 entity_id: int = 0,
469 role: int = ROLE_SIGN,
470 index: int = 0,
471 hub: int | None = None,
472 ) -> str:
473 """Return the canonical Muse derivation path string for the given coordinates.
474
475 The returned string is suitable for passing directly to
476 :func:`~muse.core.slip010.derive_path`.
477
478 Parameters
479 ----------
480 domain:
481 Domain index (one of the ``DOMAIN_*`` constants). Must be >= 0.
482 entity_type:
483 Entity class (one of the ``ENTITY_*`` constants). Must be 0–3.
484 entity_id:
485 Entity slot within its type (0 = first, 1 = second, …). Must be >= 0.
486 role:
487 Key role within the domain (one of the ``ROLE_*`` constants). Must be 0–4.
488 index:
489 Key rotation index (0 = current, 1 = pre-rotated, …). Must be >= 0.
490 hub:
491 Hub index from :func:`hub_index`, or ``None`` to omit the level
492 entirely (musehub#221). ``None`` reproduces the original six-level
493 path byte-for-byte — every non-identity caller (domain/music/code/…)
494 is unaffected. Pass a real hub index only where a key must be
495 scoped to a specific hub server; today that's
496 :func:`derive_identity_key` only.
497
498 Returns
499 -------
500 str
501 Six-level path such as ``"m/1075233755'/0'/0'/0'/0'/0'"`` when
502 ``hub`` is ``None``, or the seven-level
503 ``".../role'/hub'/index'"`` form when a hub index is given.
504
505 Raises
506 ------
507 HdKeyError
508 If any argument is out of range.
509
510 Examples
511 --------
512 ::
513
514 assert muse_path(DOMAIN_IDENTITY) == "m/1075233755'/1660078172'/0'/0'/0'/0'"
515 assert muse_path(DOMAIN_MUSIC, entity_type=ENTITY_AGENT, entity_id=1) \
516 == "m/1075233755'/1755707987'/1'/1'/0'/0'"
517 assert muse_path(DOMAIN_IDENTITY, hub=hub_index("musehub.ai")) \
518 == "m/1075233755'/1660078172'/0'/0'/0'/1264457193'/0'"
519 """
520 _validate_non_negative(domain, "domain")
521 _validate_entity_type(entity_type)
522 _validate_non_negative(entity_id, "entity_id")
523 _validate_role(role)
524 _validate_non_negative(index, "index")
525 hub_segment = ""
526 if hub is not None:
527 _validate_non_negative(hub, "hub")
528 hub_segment = f"/{hub}'"
529 return (
530 f"m/{MUSE_PURPOSE}'"
531 f"/{domain}'"
532 f"/{entity_type}'"
533 f"/{entity_id}'"
534 f"/{role}'"
535 f"{hub_segment}"
536 f"/{index}'"
537 )
538
539 # ---------------------------------------------------------------------------
540 # Core derivation
541 # ---------------------------------------------------------------------------
542
543 def derive_key(
544 seed: bytes,
545 domain: int,
546 entity_type: int = ENTITY_HUMAN,
547 entity_id: int = 0,
548 role: int = ROLE_SIGN,
549 index: int = 0,
550 hub: int | None = None,
551 ) -> DerivedKey:
552 """Derive an Ed25519 key at the given Muse coordinates.
553
554 This is the general-purpose derivation primitive. For common cases prefer
555 :func:`derive_identity_key` or :func:`derive_domain_key` — they call this
556 internally with clearer call sites.
557
558 Path derived::
559
560 m / purpose' / domain' / entity_type' / entity_id' / role' / [hub' /] index'
561
562 Parameters
563 ----------
564 seed:
565 64-byte BIP39 seed from :func:`muse.core.bip39.mnemonic_to_seed`,
566 or a 64-byte agent sub-seed from :func:`derive_agent_sub_seed`.
567 domain:
568 Domain index (``DOMAIN_*`` constant).
569 entity_type:
570 Entity class (``ENTITY_*`` constant). Default: :data:`ENTITY_HUMAN`.
571 entity_id:
572 Entity slot within its type. Default: ``0`` (first).
573 role:
574 Key role within the domain (``ROLE_*`` constant). Default: :data:`ROLE_SIGN`.
575 index:
576 Key rotation index. 0 = current, 1 = pre-rotated. Default: ``0``.
577 hub:
578 Hub index from :func:`hub_index`, or ``None`` (default) to omit the
579 level — see :func:`muse_path`. Only :func:`derive_identity_key`
580 passes a real value today.
581
582 Returns
583 -------
584 DerivedKey
585 SLIP-0010 private key and chain code at the requested coordinates.
586
587 Raises
588 ------
589 HdKeyError
590 If any argument is out of range.
591 Slip010Error
592 If *seed* is shorter than 16 bytes.
593
594 Examples
595 --------
596 ::
597
598 # Human operator's music signing key
599 dk = derive_key(seed, domain=DOMAIN_MUSIC)
600
601 # Agent slot 2's identity key
602 dk = derive_key(seed, domain=DOMAIN_IDENTITY, entity_type=ENTITY_AGENT, entity_id=2)
603 """
604 path = muse_path(domain, entity_type, entity_id, role, index, hub=hub)
605 return derive_path(seed, path)
606
607 def derive_identity_key(
608 seed: bytes,
609 *,
610 hub: int,
611 entity_type: int = ENTITY_HUMAN,
612 entity_id: int = 0,
613 index: int = 0,
614 ) -> DerivedKey:
615 """Derive the cross-domain identity (MSign auth) key, scoped to one hub.
616
617 The identity key is the entity's **passport** on the Muse network. It is
618 used for:
619
620 - Ed25519 HTTP request signing (``X-Muse-Signature`` header)
621 - MuseHub authentication and handle registration
622 - Agent identity in agentception
623
624 This key belongs to :data:`DOMAIN_IDENTITY` and uses :data:`ROLE_SIGN`.
625
626 .. warning::
627 ``hub`` (from :func:`hub_index`) is **required, keyword-only, with
628 no default** (musehub#221). This function's entire purpose is
629 producing a key that gets registered with a specific hub server —
630 there is no legitimate case where a caller doesn't know which hub.
631 Before this was enforced, it was possible to accidentally omit hub
632 scoping, silently producing the exact same key for every hub. If
633 you're deriving a key that has nothing to do with a hub (e.g. a
634 domain-specific signing key), use :func:`derive_key` or
635 :func:`derive_domain_key` directly instead of this function.
636 Pre-Phase-2 identities have no hub segment in their stored
637 ``hd_path`` at all — see ``muse migrate hub-scoping`` to move them
638 onto a hub-scoped path. There is deliberately no in-code fallback
639 that re-derives the old shape; migrate once, don't carry a legacy
640 branch forever.
641
642 Parameters
643 ----------
644 seed:
645 64-byte BIP39 seed or agent sub-seed.
646 hub:
647 Hub index from :func:`hub_index`. Required — see warning above.
648 entity_type:
649 Entity class. Default: :data:`ENTITY_HUMAN`.
650 entity_id:
651 Entity slot. Default: ``0``.
652 index:
653 Rotation index. Default: ``0`` (current key).
654
655 Returns
656 -------
657 DerivedKey
658 Identity key at ``m/purpose'/0'/entity_type'/entity_id'/0'/hub'/index'``.
659
660 Examples
661 --------
662 ::
663
664 # Human operator's MuseHub auth key, scoped to a specific hub
665 dk = derive_identity_key(seed, hub=hub_index("musehub.ai"))
666 priv = dk_to_ed25519(dk)
667 pub = priv.public_key().public_bytes_raw() # register this with MuseHub
668
669 # Agent slot 0's identity key (auth_agent_seed from derive_agent_sub_seed)
670 agent_dk = derive_identity_key(
671 auth_agent_seed, entity_type=ENTITY_AGENT, hub=hub_index("musehub.ai")
672 )
673 """
674 return derive_key(
675 seed,
676 domain=DOMAIN_IDENTITY,
677 entity_type=entity_type,
678 hub=hub,
679 entity_id=entity_id,
680 role=ROLE_SIGN,
681 index=index,
682 )
683
684 def derive_domain_key(
685 seed: bytes,
686 domain: int,
687 entity_type: int = ENTITY_HUMAN,
688 entity_id: int = 0,
689 role: int = ROLE_SIGN,
690 index: int = 0,
691 ) -> DerivedKey:
692 """Derive a domain-specific signing key.
693
694 Use this for keys that sign *content* within a particular domain — commit
695 provenance, music project signatures, payment claims, etc. The identity
696 key (:func:`derive_identity_key`) handles *authentication*; this function
697 handles *attestation*.
698
699 Parameters
700 ----------
701 seed:
702 64-byte BIP39 seed or agent sub-seed.
703 domain:
704 Domain index (``DOMAIN_CODE``, ``DOMAIN_MUSIC``, etc.).
705 :data:`DOMAIN_IDENTITY` is valid but :func:`derive_identity_key` is
706 preferred for that case.
707 entity_type:
708 Entity class. Default: :data:`ENTITY_HUMAN`.
709 entity_id:
710 Entity slot. Default: ``0``.
711 role:
712 Key role within the domain. Default: :data:`ROLE_SIGN`.
713 index:
714 Rotation index. Default: ``0``.
715
716 Returns
717 -------
718 DerivedKey
719 Domain key at the requested coordinates.
720
721 Examples
722 --------
723 ::
724
725 # Human operator's commit signing key
726 code_dk = derive_domain_key(seed, domain=DOMAIN_CODE)
727
728 # Human operator's Stori project signing key
729 music_dk = derive_domain_key(seed, domain=DOMAIN_MUSIC)
730
731 # Human operator's MPay payment key
732 pay_dk = derive_domain_key(seed, domain=DOMAIN_PAYMENTS, role=ROLE_RECEIVE)
733 """
734 return derive_key(seed, domain, entity_type, entity_id, role, index)
735
736 def derive_agent_sub_seed(
737 seed: bytes,
738 domain: int,
739 agent_id: int,
740 ) -> bytearray:
741 """Derive a 64-byte domain-scoped sub-seed for an agent.
742
743 Agent processes must never receive the operator's master seed. Instead,
744 the operator derives a **per-domain, per-agent sub-seed** and injects it
745 into the agent process. The agent treats this sub-seed exactly like a
746 regular BIP39 seed — it calls :func:`derive_identity_key` and
747 :func:`derive_domain_key` with it as normal.
748
749 The sub-seed is rooted at::
750
751 m / purpose' / domain' / ENTITY_AGENT' / agent_id'
752
753 So the agent's key tree sits entirely within the operator's ``domain``
754 sub-tree. The agent physically cannot derive keys in any other domain,
755 nor can it derive the operator's keys — SLIP-0010 hardened derivation
756 guarantees this.
757
758 Sub-seed composition::
759
760 sub_seed = dk.private_bytes + dk.chain_code # 64 bytes
761
762 Both halves are required: the chain code enables further child derivation.
763
764 Parameters
765 ----------
766 seed:
767 Master 64-byte BIP39 seed (operator's seed).
768 domain:
769 The domain to scope this agent to. The agent can only derive keys
770 within this domain from the returned sub-seed.
771 agent_id:
772 Agent slot index within the domain. Must be >= 0.
773
774 Returns
775 -------
776 bytes
777 64-byte domain-scoped sub-seed. Treat with the same care as the
778 master seed — never log it, store it unencrypted, or transmit over
779 an unauthenticated channel.
780
781 Raises
782 ------
783 HdKeyError
784 If *domain* or *agent_id* is negative.
785 Slip010Error
786 If *seed* is shorter than 16 bytes.
787
788 Security
789 --------
790 Each (domain, agent_id) pair produces a unique, independent sub-seed.
791 Granting multiple domain sub-seeds to an agent (for cross-domain
792 capability) is done at the orchestration layer (agentception) by injecting
793 multiple sub-seeds — never by combining them or using the master seed.
794
795 Examples
796 --------
797 ::
798
799 # Music composition agent — scoped to music domain only
800 music_seed = derive_agent_sub_seed(seed, domain=DOMAIN_MUSIC, agent_id=0)
801
802 # Same agent also needs to authenticate — inject identity sub-seed separately
803 auth_seed = derive_agent_sub_seed(seed, domain=DOMAIN_IDENTITY, agent_id=0)
804
805 # Agent uses each seed for its respective domain
806 agent_identity_dk = derive_identity_key(auth_seed, hub=hub_index("musehub.ai"))
807 agent_music_dk = derive_domain_key(music_seed, domain=DOMAIN_MUSIC)
808 """
809 _validate_non_negative(domain, "domain")
810 _validate_non_negative(agent_id, "agent_id")
811 # Root: m / purpose' / domain' / ENTITY_AGENT' / agent_id'
812 dk = master_key(seed)
813 for hardened_index in [hardened(MUSE_PURPOSE), hardened(domain), hardened(ENTITY_AGENT), hardened(agent_id)]:
814 next_dk = child_key(dk, hardened_index)
815 dk.zero()
816 dk = next_dk
817 sub_seed = SecretByteArray(dk.private_bytes) + bytearray(dk.chain_code)
818 dk.zero()
819 return SecretByteArray(sub_seed)
820
821 # ---------------------------------------------------------------------------
822 # Key materialisation helpers
823 # ---------------------------------------------------------------------------
824
825 def dk_to_ed25519(dk: DerivedKey) -> "Ed25519PrivateKey":
826 """Materialise a :class:`~muse.core.slip010.DerivedKey` as an Ed25519 signing key.
827
828 Thin re-export of :func:`muse.core.slip010.to_ed25519_private_key` so
829 callers can use a single import from this module::
830
831 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
832
833 Parameters
834 ----------
835 dk:
836 Derived key from any ``derive_*`` function in this module.
837
838 Returns
839 -------
840 Ed25519PrivateKey
841 ``cryptography`` library signing key. Call ``.sign(message)`` to
842 produce an Ed25519 signature, and ``.public_key().public_bytes_raw()``
843 for the 32-byte public key.
844
845 Examples
846 --------
847 ::
848
849 dk = derive_identity_key(seed, hub=hub_index("musehub.ai"))
850 priv = dk_to_ed25519(dk)
851 sig = priv.sign(b"hello muse")
852 priv.public_key().verify(sig, b"hello muse") # does not raise → valid
853 """
854 return to_ed25519_private_key(dk)
855
856 def public_bytes_from_seed(
857 seed: bytes,
858 domain: int = DOMAIN_IDENTITY,
859 entity_type: int = ENTITY_HUMAN,
860 entity_id: int = 0,
861 role: int = ROLE_SIGN,
862 index: int = 0,
863 ) -> bytes:
864 """Return the raw 32-byte Ed25519 public key at the given Muse coordinates.
865
866 Convenience one-liner for callers that only need the public key — e.g.
867 to display a fingerprint or register with MuseHub.
868
869 Parameters
870 ----------
871 seed:
872 64-byte BIP39 seed or agent sub-seed.
873 domain:
874 Domain index. Default: :data:`DOMAIN_IDENTITY`.
875 entity_type:
876 Entity class. Default: :data:`ENTITY_HUMAN`.
877 entity_id:
878 Entity slot. Default: ``0``.
879 role:
880 Key role. Default: :data:`ROLE_SIGN`.
881 index:
882 Rotation index. Default: ``0``.
883
884 Returns
885 -------
886 bytes
887 32-byte raw Ed25519 public key.
888
889 Examples
890 --------
891 ::
892
893 pub = public_bytes_from_seed(seed) # identity key public bytes
894 fingerprint = pub.hex()[:16] # short display fingerprint
895 assert len(pub) == 32
896 """
897 dk = derive_key(seed, domain, entity_type, entity_id, role, index)
898 try:
899 private_key = dk_to_ed25519(dk)
900 finally:
901 dk.zero()
902 return private_key.public_key().public_bytes_raw()
903
904 # ---------------------------------------------------------------------------
905 # Internal validators
906 # ---------------------------------------------------------------------------
907
908 def _validate_non_negative(value: int, name: str) -> None:
909 if value < 0:
910 raise HdKeyError(f"{name} must be >= 0; got {value}.")
911
912 def _validate_entity_type(entity_type: int) -> None:
913 if entity_type < 0 or entity_type > 2:
914 raise HdKeyError(
915 f"entity_type must be 0 (ENTITY_HUMAN), 1 (ENTITY_AGENT), "
916 f"or 2 (ENTITY_ORG); got {entity_type}."
917 )
918
919 def _validate_role(role: int) -> None:
920 if role < 0 or role > 4:
921 raise HdKeyError(
922 f"role must be 0 (ROLE_SIGN), 1 (ROLE_RECEIVE), 2 (ROLE_PROVISION), "
923 f"3 (ROLE_ATTEST), or 4 (ROLE_DELEGATE); got {role}."
924 )
File History 1 commit
sha256:94f494a8f59e4b708ebb89e304209737ce34324a33aae83840a6f6b06d7b8d9d docs: add domain-extensibility.md — the two-axis breadth/de… Sonnet 5 2 days ago