gabriel / muse public
slip010.py python
495 lines 16.5 KB
Raw
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 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'=service, 3'=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 from __future__ import annotations
88
89 import hmac
90 import hashlib
91 import re
92 from dataclasses import dataclass
93 from typing import TYPE_CHECKING
94
95 if TYPE_CHECKING:
96 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
97
98 __all__ = [
99 "Slip010Error",
100 "DerivedKey",
101 "MUSE_PURPOSE",
102 "HARDENED_OFFSET",
103 "master_key",
104 "child_key",
105 "derive_path",
106 "to_ed25519_private_key",
107 "hardened",
108 "parse_path",
109 ]
110
111 # ---------------------------------------------------------------------------
112 # Constants
113 # ---------------------------------------------------------------------------
114
115 #: The hardened index offset. Any index >= this value is hardened.
116 HARDENED_OFFSET: int = 0x80000000
117
118 #: Muse-specific Ed25519 purpose index (unhardened form; add HARDENED_OFFSET when deriving).
119 #: Value: 1_075_233_755 = int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF
120 #: Derivation: sha256(b"muse") = 0x4016c3db... → first 4 bytes & 0x7FFFFFFF = 1_075_233_755
121 MUSE_PURPOSE: int = 1_075_233_755
122
123 #: HMAC key used for SLIP-0010 Ed25519 master key derivation (per spec).
124 _SLIP010_ED25519_KEY = b"ed25519 seed"
125
126 # ---------------------------------------------------------------------------
127 # Errors
128 # ---------------------------------------------------------------------------
129
130
131 class Slip010Error(ValueError):
132 """Raised when SLIP-0010 derivation fails.
133
134 Common causes:
135 - Unhardened index passed to an Ed25519 derivation function.
136 - Malformed path string (e.g. ``m/0/1`` instead of ``m/0'/1'``).
137 - Seed too short (must be at least 16 bytes).
138
139 Subclasses :class:`ValueError` so callers that catch ``ValueError`` still
140 work. Use ``except Slip010Error`` for precise handling.
141
142 Examples
143 --------
144 ::
145
146 try:
147 child_key(parent_sk, parent_chain, 0) # unhardened → error
148 except Slip010Error as exc:
149 print(f"derivation error: {exc}")
150 """
151
152
153 # ---------------------------------------------------------------------------
154 # Data types
155 # ---------------------------------------------------------------------------
156
157
158 @dataclass(frozen=True, slots=True)
159 class DerivedKey:
160 """An Ed25519 private key with its SLIP-0010 chain code.
161
162 Both fields are 32 bytes. The chain code is required to derive child keys;
163 it acts as a second secret that prevents child key derivation without
164 knowledge of the parent.
165
166 Attributes
167 ----------
168 private_bytes:
169 32-byte Ed25519 private scalar. Feed into :func:`to_ed25519_private_key`
170 to obtain a usable signing key.
171 chain_code:
172 32-byte SLIP-0010 chain code. Required for further child derivation.
173 Guard it with the same care as the private key.
174
175 Security
176 --------
177 Both fields are immutable (frozen dataclass). Do not store instances in
178 logs or error messages — they contain private key material.
179
180 Examples
181 --------
182 ::
183
184 dk = master_key(seed)
185 assert len(dk.private_bytes) == 32
186 assert len(dk.chain_code) == 32
187 """
188
189 private_bytes: bytes
190 chain_code: bytes
191
192 def __repr__(self) -> str:
193 """Redact key material from repr to prevent accidental logging."""
194 return (
195 f"DerivedKey(private_bytes=<redacted 32 bytes>, "
196 f"chain_code=<redacted 32 bytes>)"
197 )
198
199
200 # ---------------------------------------------------------------------------
201 # Core derivation primitives
202 # ---------------------------------------------------------------------------
203
204
205 def master_key(seed: bytes) -> DerivedKey:
206 """Derive the SLIP-0010 Ed25519 master key from a BIP39 seed.
207
208 Implements::
209
210 I = HMAC-SHA512(key=b"ed25519 seed", data=seed)
211 master_private_bytes = I[:32]
212 master_chain_code = I[32:]
213
214 Parameters
215 ----------
216 seed:
217 64-byte BIP39 seed (output of :func:`muse.core.bip39.mnemonic_to_seed`).
218 Must be at least 16 bytes; shorter inputs are rejected.
219
220 Returns
221 -------
222 DerivedKey
223 Master private key and chain code. This is the root of the Ed25519 HD
224 key tree — guard it as carefully as the mnemonic itself.
225
226 Raises
227 ------
228 Slip010Error
229 If *seed* is shorter than 16 bytes.
230
231 Security
232 --------
233 HMAC-SHA512 is provided by Python's ``hmac`` + ``hashlib`` modules, which
234 use the ``cryptography`` / OpenSSL backend. The hardcoded key
235 ``b"ed25519 seed"`` is specified by SLIP-0010 and must not be changed.
236
237 Examples
238 --------
239 ::
240
241 from muse.core.bip39 import mnemonic_to_seed
242 seed = mnemonic_to_seed("abandon " * 11 + "about")
243 dk = master_key(seed)
244 assert len(dk.private_bytes) == 32
245 """
246 if len(seed) < 16:
247 raise Slip010Error(
248 f"Seed must be at least 16 bytes; got {len(seed)}. "
249 "Use muse.core.bip39.mnemonic_to_seed to generate a valid 64-byte seed."
250 )
251 I = hmac.new(_SLIP010_ED25519_KEY, seed, hashlib.sha512).digest()
252 return DerivedKey(private_bytes=I[:32], chain_code=I[32:])
253
254
255 def child_key(parent: DerivedKey, index: int) -> DerivedKey:
256 """Derive a hardened SLIP-0010 Ed25519 child key.
257
258 SLIP-0010 Ed25519 supports **hardened derivation only** (index ≥ 2³¹).
259 Passing an unhardened index is a hard error — it would be cryptographically
260 unsafe for Ed25519 and is not allowed by the specification.
261
262 Implements::
263
264 data = b"\\x00" + parent.private_bytes + index.to_bytes(4, "big")
265 I = HMAC-SHA512(key=parent.chain_code, data=data)
266 child_private_bytes = I[:32]
267 child_chain_code = I[32:]
268
269 Parameters
270 ----------
271 parent:
272 Parent :class:`DerivedKey` (master or any previously derived key).
273 index:
274 Hardened child index. Must satisfy ``index >= 0x80000000`` (2³¹).
275 Use the :func:`hardened` helper to construct hardened indices from
276 human-friendly numbers, e.g. ``hardened(703)`` for ``703'``.
277
278 Returns
279 -------
280 DerivedKey
281 Child private key and chain code.
282
283 Raises
284 ------
285 Slip010Error
286 If *index* is not hardened (< 2³¹).
287
288 Security
289 --------
290 Hardened derivation means that knowledge of the child private key (and
291 chain code) does **not** reveal the parent private key. This is the
292 key security property that makes SLIP-0010 safe for Ed25519.
293
294 Examples
295 --------
296 ::
297
298 dk = master_key(seed)
299 child = child_key(dk, hardened(703)) # m/703'
300 grandchild = child_key(child, hardened(0)) # m/703'/0'
301 """
302 if index < HARDENED_OFFSET:
303 raise Slip010Error(
304 f"SLIP-0010 Ed25519 only supports hardened child derivation. "
305 f"Index {index} is not hardened (must be >= {HARDENED_OFFSET:#010x}). "
306 f"Use hardened({index}) to derive the hardened variant."
307 )
308 data = b"\x00" + parent.private_bytes + index.to_bytes(4, "big")
309 I = hmac.new(parent.chain_code, data, hashlib.sha512).digest()
310 return DerivedKey(private_bytes=I[:32], chain_code=I[32:])
311
312
313 # ---------------------------------------------------------------------------
314 # Path derivation
315 # ---------------------------------------------------------------------------
316
317 _PATH_RE = re.compile(r"^m(/\d+')+$")
318 _COMPONENT_RE = re.compile(r"(\d+)'")
319
320
321 def parse_path(path: str) -> list[int]:
322 """Parse a SLIP-0010 hardened-only path string into a list of absolute indices.
323
324 All components must be hardened (``'`` suffix required). Unhardened
325 components are rejected because SLIP-0010 Ed25519 does not support them.
326
327 Parameters
328 ----------
329 path:
330 Derivation path in standard notation, e.g. ``"m/703'/0'/0'/0'"``.
331 Must start with ``"m/"`` and contain only hardened components.
332
333 Returns
334 -------
335 list[int]
336 Absolute child indices (each >= ``HARDENED_OFFSET``), in derivation order.
337 E.g. ``"m/703'/0'/0'/0'"`` → ``[703+2³¹, 0+2³¹, 0+2³¹, 0+2³¹]``.
338
339 Raises
340 ------
341 Slip010Error
342 If *path* is malformed, empty, or contains any unhardened component.
343
344 Examples
345 --------
346 ::
347
348 indices = parse_path("m/703'/0'/0'/0'")
349 assert indices == [703 + HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET]
350
351 parse_path("m/0/1") # raises Slip010Error — unhardened components
352 """
353 path = path.strip()
354 if not _PATH_RE.match(path):
355 raise Slip010Error(
356 f"Invalid SLIP-0010 path: {path!r}. "
357 "Path must be of the form 'm/n1'/n2'/...' with all-hardened components. "
358 "Example: \"m/703'/0'/0'/0'\""
359 )
360 return [int(m) + HARDENED_OFFSET for m in _COMPONENT_RE.findall(path)]
361
362
363 def derive_path(seed: bytes, path: str) -> DerivedKey:
364 """Derive an Ed25519 key at *path* from a BIP39 *seed*.
365
366 This is the primary high-level entry point for key derivation. It
367 combines :func:`master_key` with repeated :func:`child_key` calls
368 for each component of *path*.
369
370 All path components must be hardened (``'`` suffix). This is a hard
371 constraint of SLIP-0010 Ed25519 — see the module docstring for why.
372
373 Parameters
374 ----------
375 seed:
376 64-byte BIP39 seed (from :func:`muse.core.bip39.mnemonic_to_seed`).
377 path:
378 Derivation path, e.g. ``"m/703'/0'/0'/0'"``.
379
380 Returns
381 -------
382 DerivedKey
383 Ed25519 private key and chain code at the requested path.
384
385 Raises
386 ------
387 Slip010Error
388 If *path* is malformed or *seed* is too short.
389
390 Performance
391 -----------
392 Each path component requires one HMAC-SHA512 call. A 4-component path
393 (``m/703'/0'/0'/0'``) takes < 1 ms on modern hardware. Keys may be
394 cached in-process but must never be written to disk in raw form.
395
396 Examples
397 --------
398 ::
399
400 from muse.core.bip39 import mnemonic_to_seed
401 from muse.core.slip010 import derive_path, to_ed25519_private_key
402
403 seed = mnemonic_to_seed("abandon " * 11 + "about")
404
405 # Human operator MSign identity
406 dk = derive_path(seed, "m/703'/0'/0'/0'")
407
408 # Agent slot 1 MSign identity
409 dk_agent = derive_path(seed, "m/703'/1'/0'/0'")
410 """
411 indices = parse_path(path)
412 dk = master_key(seed)
413 for index in indices:
414 dk = child_key(dk, index)
415 return dk
416
417
418 # ---------------------------------------------------------------------------
419 # Key materialisation
420 # ---------------------------------------------------------------------------
421
422
423 def to_ed25519_private_key(dk: DerivedKey) -> "Ed25519PrivateKey":
424 """Materialise a :class:`DerivedKey` as a ``cryptography`` Ed25519 private key.
425
426 The returned key object can sign bytes directly::
427
428 private_key = to_ed25519_private_key(dk)
429 signature = private_key.sign(message)
430
431 And expose the public key::
432
433 public_key = private_key.public_key()
434 pub_bytes = public_key.public_bytes_raw() # 32 bytes
435
436 Parameters
437 ----------
438 dk:
439 :class:`DerivedKey` from :func:`master_key`, :func:`child_key`, or
440 :func:`derive_path`.
441
442 Returns
443 -------
444 Ed25519PrivateKey
445 A ``cryptography`` library Ed25519 private key, ready for signing.
446 Compatible with all MSign operations in :mod:`muse.core.msign`.
447
448 Examples
449 --------
450 ::
451
452 dk = derive_path(seed, "m/703'/0'/0'/0'")
453 private_key = to_ed25519_private_key(dk)
454 public_bytes = private_key.public_key().public_bytes_raw()
455 assert len(public_bytes) == 32
456 """
457 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
458 return Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
459
460
461 # ---------------------------------------------------------------------------
462 # Index helpers
463 # ---------------------------------------------------------------------------
464
465
466 def hardened(n: int) -> int:
467 """Return the hardened form of index *n* (adds the hardened offset 2³¹).
468
469 Parameters
470 ----------
471 n:
472 Unhardened index (0 to 2³¹ − 1).
473
474 Returns
475 -------
476 int
477 ``n + 0x80000000``. Pass this to :func:`child_key`.
478
479 Raises
480 ------
481 Slip010Error
482 If *n* is negative or already >= ``HARDENED_OFFSET``.
483
484 Examples
485 --------
486 ::
487
488 assert hardened(703) == 703 + 0x80000000
489 assert hardened(0) == 0x80000000
490 """
491 if n < 0 or n >= HARDENED_OFFSET:
492 raise Slip010Error(
493 f"Index {n} is out of range for hardened() — must be 0 ≤ n < {HARDENED_OFFSET}."
494 )
495 return n + HARDENED_OFFSET
File History 1 commit
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b feat: supercharge muse blame + remove --porcelain everywhere Human patch 164 days ago