slip010.py
python
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