gabriel / muse public
slip010.py python
555 lines 19.4 KB
Raw
sha256:2a703f78341332ef0beb9856d2267de6aec89b3883c31519b6900b667d026e62 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor ⚠ breaking 7 days ago
1 """muse.core.slip010 — SLIP-0010 Ed25519 hierarchical deterministic key derivation.
2
3 SLIP-0010 extends BIP32 HD key derivation to curves other than secp256k1.
4 Muse uses it exclusively for Ed25519 — the curve that powers MSign HTTP
5 authentication and off-chain MPay claims.
6
7 Why SLIP-0010 instead of BIP32 for Ed25519?
8 --------------------------------------------
9 BIP32's public-key child derivation relies on EC point addition, which is
10 well-defined for secp256k1. Ed25519 uses a different group (Curve25519 /
11 Edwards form) where the cofactor makes unhardened child derivation unsafe —
12 a leaked child key can reveal the parent private key. SLIP-0010 restricts
13 Ed25519 derivation to *hardened-only* (index ≥ 2³¹), eliminating the
14 vulnerability while keeping the same HMAC-SHA512 core as BIP32.
15
16 Algorithm
17 ---------
18 All index values must satisfy ``index >= 0x80000000`` (hardened).
19
20 **Master key from BIP39 seed**::
21
22 I = HMAC-SHA512(key=b"ed25519 seed", data=bip39_seed)
23 sk_bytes = I[:32] # 256-bit private scalar
24 chain = I[32:] # 256-bit chain code
25
26 **Child key derivation (hardened only)**::
27
28 I = HMAC-SHA512(key=parent_chain, data=b"\\x00" + parent_sk + index.to_bytes(4, "big"))
29 child_sk = I[:32]
30 child_chain = I[32:]
31
32 **Path notation**: ``m/1075233755'/0'/0'/0'/0'/0'``
33 - ``m`` — master key (derived from seed)
34 - Each component ``n'`` — hardened index (``n + 0x80000000``)
35 - Muse purpose: **1 075 233 755** = ``int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF``
36
37 Muse HD path structure (Ed25519)
38 ----------------------------------
39 ::
40
41 m / 1075233755' / domain' / entity_type' / entity_id' / role' / index'
42 │ │ │ │ │ └── Key rotation index
43 │ │ │ │ └─────────── Role (0'=sign, 1'=receive, 2'=provision, 3'=attest, 4'=delegate)
44 │ │ │ └───────────────────────── Entity ID (0'=first, 1'=second, …)
45 │ │ └───────────────────────────────────────── Entity type (0'=human, 1'=agent, 2'=org)
46 │ └──────────────────────────────────────────────────── Domain (0'=identity, 1'=payments, 2'=code, 3'=music, 4'=midi, 5'=prose, 6'=blockchain, …)
47 └──────────────────────────────────────────────────────────────────── Purpose (all hardened)
48
49 Purpose derivation::
50
51 sha256(b"muse") = 0x4016c3db...
52 first 4 bytes = 0x4016c3db
53 & 0x7FFFFFFF = 1_075_233_755
54
55 Reproducible by anyone: ``int.from_bytes(hashlib.sha256(b"muse").digest()[:4], "big") & 0x7FFFFFFF``
56
57 Security properties
58 -------------------
59 - All Ed25519 derivation is hardened: a leaked child key **cannot** reveal the
60 parent key or any sibling key (SLIP-0010 §3, contrast with BIP32 unhardened).
61 - The master key material never leaves this module — callers receive
62 :class:`DerivedKey` objects, not raw bytes.
63 - HMAC-SHA512 is provided by the ``cryptography`` library (OpenSSL bindings,
64 FIPS-validated on supported platforms).
65
66 References
67 ----------
68 - SLIP-0010: https://github.com/satoshilabs/slips/blob/master/slip-0010.md
69 - BIP32: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
70
71 Examples
72 --------
73 ::
74
75 from muse.core.bip39 import mnemonic_to_seed
76 from muse.core.slip010 import master_key, derive_path, to_ed25519_private_key
77
78 seed = mnemonic_to_seed("abandon abandon abandon abandon abandon abandon "
79 "abandon abandon abandon abandon abandon about")
80
81 # Derive identity key at m/1075233755'/0'/0'/0'/0'/0'
82 dk = derive_path(seed, "m/1075233755'/0'/0'/0'/0'/0'")
83 private_key = to_ed25519_private_key(dk)
84 public_key = private_key.public_key()
85 """
86
87 import hmac
88 import hashlib
89 import re
90 from dataclasses import dataclass
91 from typing import TYPE_CHECKING
92
93 if TYPE_CHECKING:
94 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
95
96 __all__ = [
97 "Slip010Error",
98 "SecretByteArray",
99 "DerivedKey",
100 "MUSE_PURPOSE",
101 "HARDENED_OFFSET",
102 "master_key",
103 "child_key",
104 "derive_path",
105 "to_ed25519_private_key",
106 "hardened",
107 "parse_path",
108 ]
109
110 # ---------------------------------------------------------------------------
111 # Constants
112 # ---------------------------------------------------------------------------
113
114 #: The hardened index offset. Any index >= this value is hardened.
115 HARDENED_OFFSET: int = 0x80000000
116
117 #: Muse-specific Ed25519 purpose index (unhardened form; add HARDENED_OFFSET when deriving).
118 #: Value: 1_075_233_755 = int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF
119 #: Derivation: sha256(b"muse") = 0x4016c3db... → first 4 bytes & 0x7FFFFFFF = 1_075_233_755
120 MUSE_PURPOSE: int = 1_075_233_755
121
122 #: HMAC key used for SLIP-0010 Ed25519 master key derivation (per spec).
123 _SLIP010_ED25519_KEY = b"ed25519 seed"
124
125 # ---------------------------------------------------------------------------
126 # Errors
127 # ---------------------------------------------------------------------------
128
129 class Slip010Error(ValueError):
130 """Raised when SLIP-0010 derivation fails.
131
132 Common causes:
133 - Unhardened index passed to an Ed25519 derivation function.
134 - Malformed path string (e.g. ``m/0/1`` instead of ``m/0'/1'``).
135 - Seed too short (must be at least 16 bytes).
136
137 Subclasses :class:`ValueError` so callers that catch ``ValueError`` still
138 work. Use ``except Slip010Error`` for precise handling.
139
140 Examples
141 --------
142 ::
143
144 try:
145 child_key(parent_sk, parent_chain, 0) # unhardened → error
146 except Slip010Error as exc:
147 print(f"derivation error: {exc}")
148 """
149
150 # ---------------------------------------------------------------------------
151 # Data types
152 # ---------------------------------------------------------------------------
153
154 class SecretByteArray(bytearray):
155 """A ``bytearray`` that zeroes itself when garbage-collected.
156
157 Use this for sensitive byte buffers that cross function boundaries — agent
158 sub-seeds, intermediate secrets — where you cannot guarantee the caller will
159 manually zero the buffer. ``__del__`` is a best-effort safety net; always
160 call :meth:`zero` explicitly when you are done, since GC timing is not
161 guaranteed.
162
163 Examples
164 --------
165 ::
166
167 sub = SecretByteArray(seed_bytes)
168 # ... use sub ...
169 sub.zero() # explicit best-practice
170 # __del__ fires on GC as a backstop
171 """
172
173 def zero(self) -> None:
174 """Overwrite the buffer with null bytes."""
175 self[:] = b"\x00" * len(self)
176
177 def __del__(self) -> None:
178 try:
179 self.zero()
180 except Exception:
181 pass
182
183 @dataclass(slots=True)
184 class DerivedKey:
185 """An Ed25519 private key with its SLIP-0010 chain code.
186
187 Both fields are 32 bytes. The chain code is required to derive child keys;
188 it acts as a second secret that prevents child key derivation without
189 knowledge of the parent.
190
191 Attributes
192 ----------
193 private_bytes:
194 32-byte Ed25519 private scalar as a mutable ``bytearray``.
195 Feed into :func:`to_ed25519_private_key` to obtain a usable signing key.
196 Call :meth:`zero` immediately after the key object has been created.
197 chain_code:
198 32-byte SLIP-0010 chain code as a mutable ``bytearray``.
199 Required for further child derivation.
200 Guard it with the same care as the private key.
201
202 Security
203 --------
204 Fields are ``bytearray`` so they can be overwritten after use.
205 Always call :meth:`zero` once the derived key material is no longer needed.
206 Do not store instances in logs or error messages — they contain private key
207 material.
208
209 Python memory-zeroing boundary
210 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
211 ``DerivedKey`` is the zeroing boundary for this implementation. We control
212 these ``bytearray`` buffers directly, so :meth:`zero` genuinely overwrites
213 the key bytes in-place.
214
215 Bytes returned by C extensions — ``hmac.digest()``, PEM serialisation,
216 file reads — are immutable ``bytes`` objects; Python provides no way to
217 zero them. We minimise the exposure window (use-then-discard immediately),
218 but cannot eliminate residual copies in the allocator's free list.
219
220 **This is a known Python runtime limitation.** The Rust port will use the
221 ``zeroize`` crate, which derives a ``Zeroize`` trait that zeroes memory on
222 ``Drop`` with a compiler fence preventing the optimizer from eliding the
223 write. Until then, do not treat this implementation as suitable for
224 mission-critical production use where key material must be provably erased.
225
226 Examples
227 --------
228 ::
229
230 dk = master_key(seed)
231 assert len(dk.private_bytes) == 32
232 dk.zero() # wipe after use
233 """
234
235 private_bytes: bytearray
236 chain_code: bytearray
237
238 def zero(self) -> None:
239 """Overwrite both fields with null bytes to remove key material from memory."""
240 self.private_bytes[:] = b"\x00" * len(self.private_bytes)
241 self.chain_code[:] = b"\x00" * len(self.chain_code)
242
243 def __del__(self) -> None:
244 """Safety-net zeroing — fires when the object is garbage-collected.
245
246 Callers must still call :meth:`zero` explicitly (ideally in a
247 ``try/finally`` block) because GC timing is not guaranteed. This
248 method is the last line of defence against forgotten zeroing.
249 """
250 try:
251 self.zero()
252 except Exception:
253 pass
254
255 def __repr__(self) -> str:
256 """Redact key material from repr to prevent accidental logging."""
257 return (
258 f"DerivedKey(private_bytes=<redacted 32 bytes>, "
259 f"chain_code=<redacted 32 bytes>)"
260 )
261
262 # ---------------------------------------------------------------------------
263 # Core derivation primitives
264 # ---------------------------------------------------------------------------
265
266 def master_key(seed: bytes) -> DerivedKey:
267 """Derive the SLIP-0010 Ed25519 master key from a BIP39 seed.
268
269 Implements::
270
271 I = HMAC-SHA512(key=b"ed25519 seed", data=seed)
272 master_private_bytes = I[:32]
273 master_chain_code = I[32:]
274
275 Parameters
276 ----------
277 seed:
278 64-byte BIP39 seed (output of :func:`muse.core.bip39.mnemonic_to_seed`).
279 Must be at least 16 bytes; shorter inputs are rejected.
280
281 Returns
282 -------
283 DerivedKey
284 Master private key and chain code. This is the root of the Ed25519 HD
285 key tree — guard it as carefully as the mnemonic itself.
286
287 Raises
288 ------
289 Slip010Error
290 If *seed* is shorter than 16 bytes.
291
292 Security
293 --------
294 HMAC-SHA512 is provided by Python's ``hmac`` + ``hashlib`` modules, which
295 use the ``cryptography`` / OpenSSL backend. The hardcoded key
296 ``b"ed25519 seed"`` is specified by SLIP-0010 and must not be changed.
297
298 Examples
299 --------
300 ::
301
302 from muse.core.bip39 import mnemonic_to_seed
303 seed = mnemonic_to_seed("abandon " * 11 + "about")
304 dk = master_key(seed)
305 assert len(dk.private_bytes) == 32
306 """
307 if len(seed) < 16:
308 raise Slip010Error(
309 f"Seed must be at least 16 bytes; got {len(seed)}. "
310 "Use muse.core.bip39.mnemonic_to_seed to generate a valid 64-byte seed."
311 )
312 I = bytearray(hmac.new(_SLIP010_ED25519_KEY, seed, hashlib.sha512).digest())
313 dk = DerivedKey(private_bytes=bytearray(I[:32]), chain_code=bytearray(I[32:]))
314 I[:] = b"\x00" * 64
315 return dk
316
317 def child_key(parent: DerivedKey, index: int) -> DerivedKey:
318 """Derive a hardened SLIP-0010 Ed25519 child key.
319
320 SLIP-0010 Ed25519 supports **hardened derivation only** (index ≥ 2³¹).
321 Passing an unhardened index is a hard error — it would be cryptographically
322 unsafe for Ed25519 and is not allowed by the specification.
323
324 Implements::
325
326 data = b"\\x00" + parent.private_bytes + index.to_bytes(4, "big")
327 I = HMAC-SHA512(key=parent.chain_code, data=data)
328 child_private_bytes = I[:32]
329 child_chain_code = I[32:]
330
331 Parameters
332 ----------
333 parent:
334 Parent :class:`DerivedKey` (master or any previously derived key).
335 index:
336 Hardened child index. Must satisfy ``index >= 0x80000000`` (2³¹).
337 Use the :func:`hardened` helper to construct hardened indices from
338 human-friendly numbers, e.g. ``hardened(703)`` for ``703'``.
339
340 Returns
341 -------
342 DerivedKey
343 Child private key and chain code.
344
345 Raises
346 ------
347 Slip010Error
348 If *index* is not hardened (< 2³¹).
349
350 Security
351 --------
352 Hardened derivation means that knowledge of the child private key (and
353 chain code) does **not** reveal the parent private key. This is the
354 key security property that makes SLIP-0010 safe for Ed25519.
355
356 Examples
357 --------
358 ::
359
360 dk = master_key(seed)
361 child = child_key(dk, hardened(703)) # m/703'
362 grandchild = child_key(child, hardened(0)) # m/703'/0'
363 """
364 if index < HARDENED_OFFSET:
365 raise Slip010Error(
366 f"SLIP-0010 Ed25519 only supports hardened child derivation. "
367 f"Index {index} is not hardened (must be >= {HARDENED_OFFSET:#010x}). "
368 f"Use hardened({index}) to derive the hardened variant."
369 )
370 data = bytearray(b"\x00") + parent.private_bytes + index.to_bytes(4, "big")
371 I = bytearray(hmac.new(parent.chain_code, data, hashlib.sha512).digest())
372 dk = DerivedKey(private_bytes=bytearray(I[:32]), chain_code=bytearray(I[32:]))
373 I[:] = b"\x00" * 64
374 data[:] = b"\x00" * len(data)
375 return dk
376
377 # ---------------------------------------------------------------------------
378 # Path derivation
379 # ---------------------------------------------------------------------------
380
381 _PATH_RE = re.compile(r"^m(/\d+')+$")
382 _COMPONENT_RE = re.compile(r"(\d+)'")
383
384 def parse_path(path: str) -> list[int]:
385 """Parse a SLIP-0010 hardened-only path string into a list of absolute indices.
386
387 All components must be hardened (``'`` suffix required). Unhardened
388 components are rejected because SLIP-0010 Ed25519 does not support them.
389
390 Parameters
391 ----------
392 path:
393 Derivation path in standard notation, e.g. ``"m/703'/0'/0'/0'"``.
394 Must start with ``"m/"`` and contain only hardened components.
395
396 Returns
397 -------
398 list[int]
399 Absolute child indices (each >= ``HARDENED_OFFSET``), in derivation order.
400 E.g. ``"m/703'/0'/0'/0'"`` → ``[703+2³¹, 0+2³¹, 0+2³¹, 0+2³¹]``.
401
402 Raises
403 ------
404 Slip010Error
405 If *path* is malformed, empty, or contains any unhardened component.
406
407 Examples
408 --------
409 ::
410
411 indices = parse_path("m/703'/0'/0'/0'")
412 assert indices == [703 + HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET]
413
414 parse_path("m/0/1") # raises Slip010Error — unhardened components
415 """
416 path = path.strip()
417 if not _PATH_RE.match(path):
418 raise Slip010Error(
419 f"Invalid SLIP-0010 path: {path!r}. "
420 "Path must be of the form 'm/n1'/n2'/...' with all-hardened components. "
421 "Example: \"m/703'/0'/0'/0'\""
422 )
423 return [int(m) + HARDENED_OFFSET for m in _COMPONENT_RE.findall(path)]
424
425 def derive_path(seed: bytes, path: str) -> DerivedKey:
426 """Derive an Ed25519 key at *path* from a BIP39 *seed*.
427
428 This is the primary high-level entry point for key derivation. It
429 combines :func:`master_key` with repeated :func:`child_key` calls
430 for each component of *path*.
431
432 All path components must be hardened (``'`` suffix). This is a hard
433 constraint of SLIP-0010 Ed25519 — see the module docstring for why.
434
435 Parameters
436 ----------
437 seed:
438 64-byte BIP39 seed (from :func:`muse.core.bip39.mnemonic_to_seed`).
439 path:
440 Derivation path, e.g. ``"m/703'/0'/0'/0'"``.
441
442 Returns
443 -------
444 DerivedKey
445 Ed25519 private key and chain code at the requested path.
446
447 Raises
448 ------
449 Slip010Error
450 If *path* is malformed or *seed* is too short.
451
452 Performance
453 -----------
454 Each path component requires one HMAC-SHA512 call. A 4-component path
455 (``m/703'/0'/0'/0'``) takes < 1 ms on modern hardware. Keys may be
456 cached in-process but must never be written to disk in raw form.
457
458 Examples
459 --------
460 ::
461
462 from muse.core.bip39 import mnemonic_to_seed
463 from muse.core.slip010 import derive_path, to_ed25519_private_key
464
465 seed = mnemonic_to_seed("abandon " * 11 + "about")
466
467 # Human operator MSign identity
468 dk = derive_path(seed, "m/703'/0'/0'/0'")
469
470 # Agent slot 1 MSign identity
471 dk_agent = derive_path(seed, "m/703'/1'/0'/0'")
472 """
473 indices = parse_path(path)
474 dk = master_key(seed)
475 for index in indices:
476 next_dk = child_key(dk, index)
477 dk.zero() # wipe intermediate key — child no longer needs parent material
478 dk = next_dk
479 return dk
480
481 # ---------------------------------------------------------------------------
482 # Key materialisation
483 # ---------------------------------------------------------------------------
484
485 def to_ed25519_private_key(dk: DerivedKey) -> "Ed25519PrivateKey":
486 """Materialise a :class:`DerivedKey` as a ``cryptography`` Ed25519 private key.
487
488 The returned key object can sign bytes directly::
489
490 private_key = to_ed25519_private_key(dk)
491 signature = private_key.sign(message)
492
493 And expose the public key::
494
495 public_key = private_key.public_key()
496 pub_bytes = public_key.public_bytes_raw() # 32 bytes
497
498 Parameters
499 ----------
500 dk:
501 :class:`DerivedKey` from :func:`master_key`, :func:`child_key`, or
502 :func:`derive_path`.
503
504 Returns
505 -------
506 Ed25519PrivateKey
507 A ``cryptography`` library Ed25519 private key, ready for signing.
508 Compatible with all MSign operations in :mod:`muse.core.msign`.
509
510 Examples
511 --------
512 ::
513
514 dk = derive_path(seed, "m/703'/0'/0'/0'")
515 private_key = to_ed25519_private_key(dk)
516 public_bytes = private_key.public_key().public_bytes_raw()
517 assert len(public_bytes) == 32
518 """
519 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
520 return Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
521
522 # ---------------------------------------------------------------------------
523 # Index helpers
524 # ---------------------------------------------------------------------------
525
526 def hardened(n: int) -> int:
527 """Return the hardened form of index *n* (adds the hardened offset 2³¹).
528
529 Parameters
530 ----------
531 n:
532 Unhardened index (0 to 2³¹ − 1).
533
534 Returns
535 -------
536 int
537 ``n + 0x80000000``. Pass this to :func:`child_key`.
538
539 Raises
540 ------
541 Slip010Error
542 If *n* is negative or already >= ``HARDENED_OFFSET``.
543
544 Examples
545 --------
546 ::
547
548 assert hardened(703) == 703 + 0x80000000
549 assert hardened(0) == 0x80000000
550 """
551 if n < 0 or n >= HARDENED_OFFSET:
552 raise Slip010Error(
553 f"Index {n} is out of range for hardened() — must be 0 ≤ n < {HARDENED_OFFSET}."
554 )
555 return n + HARDENED_OFFSET
File History 1 commit
sha256:2a703f78341332ef0beb9856d2267de6aec89b3883c31519b6900b667d026e62 chore: delete muse/prose domain — hallucinated, never existed Sonnet 4.6 minor 7 days ago