"""muse.core.slip010 — SLIP-0010 Ed25519 hierarchical deterministic key derivation. SLIP-0010 extends BIP32 HD key derivation to curves other than secp256k1. Muse uses it exclusively for Ed25519 — the curve that powers MSign HTTP authentication and off-chain MPay claims. Why SLIP-0010 instead of BIP32 for Ed25519? -------------------------------------------- BIP32's public-key child derivation relies on EC point addition, which is well-defined for secp256k1. Ed25519 uses a different group (Curve25519 / Edwards form) where the cofactor makes unhardened child derivation unsafe — a leaked child key can reveal the parent private key. SLIP-0010 restricts Ed25519 derivation to *hardened-only* (index ≥ 2³¹), eliminating the vulnerability while keeping the same HMAC-SHA512 core as BIP32. Algorithm --------- All index values must satisfy ``index >= 0x80000000`` (hardened). **Master key from BIP39 seed**:: I = HMAC-SHA512(key=b"ed25519 seed", data=bip39_seed) sk_bytes = I[:32] # 256-bit private scalar chain = I[32:] # 256-bit chain code **Child key derivation (hardened only)**:: I = HMAC-SHA512(key=parent_chain, data=b"\\x00" + parent_sk + index.to_bytes(4, "big")) child_sk = I[:32] child_chain = I[32:] **Path notation**: ``m/1075233755'/0'/0'/0'/0'/0'`` - ``m`` — master key (derived from seed) - Each component ``n'`` — hardened index (``n + 0x80000000``) - Muse purpose: **1 075 233 755** = ``int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF`` Muse HD path structure (Ed25519) ---------------------------------- :: m / 1075233755' / domain' / entity_type' / entity_id' / role' / index' │ │ │ │ │ └── Key rotation index │ │ │ │ └─────────── Role (0'=sign, 1'=receive, 2'=provision, 3'=attest, 4'=delegate) │ │ │ └───────────────────────── Entity ID (0'=first, 1'=second, …) │ │ └───────────────────────────────────────── Entity type (0'=human, 1'=agent, 2'=service, 3'=org) │ └──────────────────────────────────────────────────── Domain (0'=identity, 1'=payments, 2'=code, 3'=music, 4'=midi, 5'=prose, 6'=blockchain, …) └──────────────────────────────────────────────────────────────────── Purpose (all hardened) Purpose derivation:: sha256(b"muse") = 0x4016c3db... first 4 bytes = 0x4016c3db & 0x7FFFFFFF = 1_075_233_755 Reproducible by anyone: ``int.from_bytes(hashlib.sha256(b"muse").digest()[:4], "big") & 0x7FFFFFFF`` Security properties ------------------- - All Ed25519 derivation is hardened: a leaked child key **cannot** reveal the parent key or any sibling key (SLIP-0010 §3, contrast with BIP32 unhardened). - The master key material never leaves this module — callers receive :class:`DerivedKey` objects, not raw bytes. - HMAC-SHA512 is provided by the ``cryptography`` library (OpenSSL bindings, FIPS-validated on supported platforms). References ---------- - SLIP-0010: https://github.com/satoshilabs/slips/blob/master/slip-0010.md - BIP32: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki Examples -------- :: from muse.core.bip39 import mnemonic_to_seed from muse.core.slip010 import master_key, derive_path, to_ed25519_private_key seed = mnemonic_to_seed("abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about") # Derive identity key at m/1075233755'/0'/0'/0'/0'/0' dk = derive_path(seed, "m/1075233755'/0'/0'/0'/0'/0'") private_key = to_ed25519_private_key(dk) public_key = private_key.public_key() """ from __future__ import annotations import hmac import hashlib import re from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey __all__ = [ "Slip010Error", "DerivedKey", "MUSE_PURPOSE", "HARDENED_OFFSET", "master_key", "child_key", "derive_path", "to_ed25519_private_key", "hardened", "parse_path", ] # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- #: The hardened index offset. Any index >= this value is hardened. HARDENED_OFFSET: int = 0x80000000 #: Muse-specific Ed25519 purpose index (unhardened form; add HARDENED_OFFSET when deriving). #: Value: 1_075_233_755 = int.from_bytes(sha256(b"muse")[:4], "big") & 0x7FFFFFFF #: Derivation: sha256(b"muse") = 0x4016c3db... → first 4 bytes & 0x7FFFFFFF = 1_075_233_755 MUSE_PURPOSE: int = 1_075_233_755 #: HMAC key used for SLIP-0010 Ed25519 master key derivation (per spec). _SLIP010_ED25519_KEY = b"ed25519 seed" # --------------------------------------------------------------------------- # Errors # --------------------------------------------------------------------------- class Slip010Error(ValueError): """Raised when SLIP-0010 derivation fails. Common causes: - Unhardened index passed to an Ed25519 derivation function. - Malformed path string (e.g. ``m/0/1`` instead of ``m/0'/1'``). - Seed too short (must be at least 16 bytes). Subclasses :class:`ValueError` so callers that catch ``ValueError`` still work. Use ``except Slip010Error`` for precise handling. Examples -------- :: try: child_key(parent_sk, parent_chain, 0) # unhardened → error except Slip010Error as exc: print(f"derivation error: {exc}") """ # --------------------------------------------------------------------------- # Data types # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class DerivedKey: """An Ed25519 private key with its SLIP-0010 chain code. Both fields are 32 bytes. The chain code is required to derive child keys; it acts as a second secret that prevents child key derivation without knowledge of the parent. Attributes ---------- private_bytes: 32-byte Ed25519 private scalar. Feed into :func:`to_ed25519_private_key` to obtain a usable signing key. chain_code: 32-byte SLIP-0010 chain code. Required for further child derivation. Guard it with the same care as the private key. Security -------- Both fields are immutable (frozen dataclass). Do not store instances in logs or error messages — they contain private key material. Examples -------- :: dk = master_key(seed) assert len(dk.private_bytes) == 32 assert len(dk.chain_code) == 32 """ private_bytes: bytes chain_code: bytes def __repr__(self) -> str: """Redact key material from repr to prevent accidental logging.""" return ( f"DerivedKey(private_bytes=, " f"chain_code=)" ) # --------------------------------------------------------------------------- # Core derivation primitives # --------------------------------------------------------------------------- def master_key(seed: bytes) -> DerivedKey: """Derive the SLIP-0010 Ed25519 master key from a BIP39 seed. Implements:: I = HMAC-SHA512(key=b"ed25519 seed", data=seed) master_private_bytes = I[:32] master_chain_code = I[32:] Parameters ---------- seed: 64-byte BIP39 seed (output of :func:`muse.core.bip39.mnemonic_to_seed`). Must be at least 16 bytes; shorter inputs are rejected. Returns ------- DerivedKey Master private key and chain code. This is the root of the Ed25519 HD key tree — guard it as carefully as the mnemonic itself. Raises ------ Slip010Error If *seed* is shorter than 16 bytes. Security -------- HMAC-SHA512 is provided by Python's ``hmac`` + ``hashlib`` modules, which use the ``cryptography`` / OpenSSL backend. The hardcoded key ``b"ed25519 seed"`` is specified by SLIP-0010 and must not be changed. Examples -------- :: from muse.core.bip39 import mnemonic_to_seed seed = mnemonic_to_seed("abandon " * 11 + "about") dk = master_key(seed) assert len(dk.private_bytes) == 32 """ if len(seed) < 16: raise Slip010Error( f"Seed must be at least 16 bytes; got {len(seed)}. " "Use muse.core.bip39.mnemonic_to_seed to generate a valid 64-byte seed." ) I = hmac.new(_SLIP010_ED25519_KEY, seed, hashlib.sha512).digest() return DerivedKey(private_bytes=I[:32], chain_code=I[32:]) def child_key(parent: DerivedKey, index: int) -> DerivedKey: """Derive a hardened SLIP-0010 Ed25519 child key. SLIP-0010 Ed25519 supports **hardened derivation only** (index ≥ 2³¹). Passing an unhardened index is a hard error — it would be cryptographically unsafe for Ed25519 and is not allowed by the specification. Implements:: data = b"\\x00" + parent.private_bytes + index.to_bytes(4, "big") I = HMAC-SHA512(key=parent.chain_code, data=data) child_private_bytes = I[:32] child_chain_code = I[32:] Parameters ---------- parent: Parent :class:`DerivedKey` (master or any previously derived key). index: Hardened child index. Must satisfy ``index >= 0x80000000`` (2³¹). Use the :func:`hardened` helper to construct hardened indices from human-friendly numbers, e.g. ``hardened(703)`` for ``703'``. Returns ------- DerivedKey Child private key and chain code. Raises ------ Slip010Error If *index* is not hardened (< 2³¹). Security -------- Hardened derivation means that knowledge of the child private key (and chain code) does **not** reveal the parent private key. This is the key security property that makes SLIP-0010 safe for Ed25519. Examples -------- :: dk = master_key(seed) child = child_key(dk, hardened(703)) # m/703' grandchild = child_key(child, hardened(0)) # m/703'/0' """ if index < HARDENED_OFFSET: raise Slip010Error( f"SLIP-0010 Ed25519 only supports hardened child derivation. " f"Index {index} is not hardened (must be >= {HARDENED_OFFSET:#010x}). " f"Use hardened({index}) to derive the hardened variant." ) data = b"\x00" + parent.private_bytes + index.to_bytes(4, "big") I = hmac.new(parent.chain_code, data, hashlib.sha512).digest() return DerivedKey(private_bytes=I[:32], chain_code=I[32:]) # --------------------------------------------------------------------------- # Path derivation # --------------------------------------------------------------------------- _PATH_RE = re.compile(r"^m(/\d+')+$") _COMPONENT_RE = re.compile(r"(\d+)'") def parse_path(path: str) -> list[int]: """Parse a SLIP-0010 hardened-only path string into a list of absolute indices. All components must be hardened (``'`` suffix required). Unhardened components are rejected because SLIP-0010 Ed25519 does not support them. Parameters ---------- path: Derivation path in standard notation, e.g. ``"m/703'/0'/0'/0'"``. Must start with ``"m/"`` and contain only hardened components. Returns ------- list[int] Absolute child indices (each >= ``HARDENED_OFFSET``), in derivation order. E.g. ``"m/703'/0'/0'/0'"`` → ``[703+2³¹, 0+2³¹, 0+2³¹, 0+2³¹]``. Raises ------ Slip010Error If *path* is malformed, empty, or contains any unhardened component. Examples -------- :: indices = parse_path("m/703'/0'/0'/0'") assert indices == [703 + HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET, HARDENED_OFFSET] parse_path("m/0/1") # raises Slip010Error — unhardened components """ path = path.strip() if not _PATH_RE.match(path): raise Slip010Error( f"Invalid SLIP-0010 path: {path!r}. " "Path must be of the form 'm/n1'/n2'/...' with all-hardened components. " "Example: \"m/703'/0'/0'/0'\"" ) return [int(m) + HARDENED_OFFSET for m in _COMPONENT_RE.findall(path)] def derive_path(seed: bytes, path: str) -> DerivedKey: """Derive an Ed25519 key at *path* from a BIP39 *seed*. This is the primary high-level entry point for key derivation. It combines :func:`master_key` with repeated :func:`child_key` calls for each component of *path*. All path components must be hardened (``'`` suffix). This is a hard constraint of SLIP-0010 Ed25519 — see the module docstring for why. Parameters ---------- seed: 64-byte BIP39 seed (from :func:`muse.core.bip39.mnemonic_to_seed`). path: Derivation path, e.g. ``"m/703'/0'/0'/0'"``. Returns ------- DerivedKey Ed25519 private key and chain code at the requested path. Raises ------ Slip010Error If *path* is malformed or *seed* is too short. Performance ----------- Each path component requires one HMAC-SHA512 call. A 4-component path (``m/703'/0'/0'/0'``) takes < 1 ms on modern hardware. Keys may be cached in-process but must never be written to disk in raw form. Examples -------- :: from muse.core.bip39 import mnemonic_to_seed from muse.core.slip010 import derive_path, to_ed25519_private_key seed = mnemonic_to_seed("abandon " * 11 + "about") # Human operator MSign identity dk = derive_path(seed, "m/703'/0'/0'/0'") # Agent slot 1 MSign identity dk_agent = derive_path(seed, "m/703'/1'/0'/0'") """ indices = parse_path(path) dk = master_key(seed) for index in indices: dk = child_key(dk, index) return dk # --------------------------------------------------------------------------- # Key materialisation # --------------------------------------------------------------------------- def to_ed25519_private_key(dk: DerivedKey) -> "Ed25519PrivateKey": """Materialise a :class:`DerivedKey` as a ``cryptography`` Ed25519 private key. The returned key object can sign bytes directly:: private_key = to_ed25519_private_key(dk) signature = private_key.sign(message) And expose the public key:: public_key = private_key.public_key() pub_bytes = public_key.public_bytes_raw() # 32 bytes Parameters ---------- dk: :class:`DerivedKey` from :func:`master_key`, :func:`child_key`, or :func:`derive_path`. Returns ------- Ed25519PrivateKey A ``cryptography`` library Ed25519 private key, ready for signing. Compatible with all MSign operations in :mod:`muse.core.msign`. Examples -------- :: dk = derive_path(seed, "m/703'/0'/0'/0'") private_key = to_ed25519_private_key(dk) public_bytes = private_key.public_key().public_bytes_raw() assert len(public_bytes) == 32 """ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey return Ed25519PrivateKey.from_private_bytes(dk.private_bytes) # --------------------------------------------------------------------------- # Index helpers # --------------------------------------------------------------------------- def hardened(n: int) -> int: """Return the hardened form of index *n* (adds the hardened offset 2³¹). Parameters ---------- n: Unhardened index (0 to 2³¹ − 1). Returns ------- int ``n + 0x80000000``. Pass this to :func:`child_key`. Raises ------ Slip010Error If *n* is negative or already >= ``HARDENED_OFFSET``. Examples -------- :: assert hardened(703) == 703 + 0x80000000 assert hardened(0) == 0x80000000 """ if n < 0 or n >= HARDENED_OFFSET: raise Slip010Error( f"Index {n} is out of range for hardened() — must be 0 ≤ n < {HARDENED_OFFSET}." ) return n + HARDENED_OFFSET