secp256k1_sign.py
python
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf
chore: bump version to 0.2.0rc14
Sonnet 4.6
patch
2 days ago
| 1 | """muse.core.secp256k1_sign — BIP32 secp256k1 derivation, EIP-191 signing, AVAX addresses. |
| 2 | |
| 3 | All cryptographic operations delegate to battle-tested, audited libraries: |
| 4 | |
| 5 | - ``hmac`` + ``hashlib`` (Python stdlib) — HMAC-SHA512 for BIP32 child derivation |
| 6 | - ``eth_keys`` (Ethereum Foundation) — secp256k1 signing, recovery, address derivation |
| 7 | - ``Crypto.Hash.keccak`` (pycryptodome) — keccak256 for EVM address derivation |
| 8 | |
| 9 | Nothing in this module implements cryptographic primitives. |
| 10 | |
| 11 | BIP32 Derivation |
| 12 | ---------------- |
| 13 | Derives secp256k1 keys from a BIP39 seed via the standard BIP32 HD wallet |
| 14 | algorithm (``key="Bitcoin seed"``). Supports both hardened (``index >= 2^31``) |
| 15 | and unhardened derivation at every path level. |
| 16 | |
| 17 | Path structure used by Muse |
| 18 | --------------------------- |
| 19 | :: |
| 20 | |
| 21 | m / 44' / 60' / account' / 0 / index — AVAX C-Chain (EVM) |
| 22 | │ │ │ │ │ └──── Address index (unhardened, 0=default) |
| 23 | │ │ │ │ └───────── Change (unhardened, 0=external) |
| 24 | │ │ │ └──────────────────── Account (hardened) |
| 25 | │ │ └─────────────────────────── Coin type 60 = Ethereum / EVM |
| 26 | │ └──────────────────────────────── Purpose 44 (BIP44) |
| 27 | └───────────────────────────────────── Master key ("Bitcoin seed" HMAC root) |
| 28 | |
| 29 | This path is compatible with MetaMask, Ledger, Trezor, and all EVM tooling. |
| 30 | |
| 31 | EIP-191 Signing |
| 32 | --------------- |
| 33 | Signs a UTF-8 message with the Ethereum personal_sign prefix:: |
| 34 | |
| 35 | "\\x19Ethereum Signed Message:\\n" + str(len(msg)) + msg |
| 36 | |
| 37 | The signature is a 65-byte compact secp256k1 signature ``(r || s || v)`` where |
| 38 | ``v ∈ {27, 28}`` (legacy Ethereum recovery ID). This is the format accepted |
| 39 | by ``ecrecover`` on EVM chains and the Avalanche contract layer. |
| 40 | |
| 41 | AVAX C-Chain Addresses |
| 42 | ----------------------- |
| 43 | Derived from the secp256k1 uncompressed public key via:: |
| 44 | |
| 45 | uncompressed = public_key.to_uncompressed_bytes() # 64 bytes (no prefix) |
| 46 | address_bytes = keccak256(uncompressed)[12:] # last 20 bytes |
| 47 | address = eip55_checksum(address_bytes.hex()) |
| 48 | |
| 49 | This is identical to the standard Ethereum/EVM address format. |
| 50 | |
| 51 | Examples |
| 52 | -------- |
| 53 | :: |
| 54 | |
| 55 | from muse.core.bip39 import mnemonic_to_seed |
| 56 | from muse.core.secp256k1_sign import derive_avax_key, avax_c_chain_address, eip191_sign |
| 57 | |
| 58 | seed = mnemonic_to_seed("abandon abandon abandon abandon abandon " |
| 59 | "abandon abandon abandon abandon abandon abandon about") |
| 60 | |
| 61 | key = derive_avax_key(seed, account=0, index=0) |
| 62 | address = avax_c_chain_address(key.public_key) |
| 63 | # "0x9858EfFD232B4033E47d90003D41EC34EcaedA94" |
| 64 | |
| 65 | sig = eip191_sign(key, "Hello Muse") |
| 66 | assert len(sig) == 65 |
| 67 | """ |
| 68 | |
| 69 | import hashlib |
| 70 | import hmac |
| 71 | import struct |
| 72 | from dataclasses import dataclass |
| 73 | |
| 74 | from Crypto.Hash import keccak as _keccak # pycryptodome — battle-tested Keccak |
| 75 | from eth_keys import keys as _eth_keys # Ethereum Foundation secp256k1 |
| 76 | |
| 77 | # --------------------------------------------------------------------------- |
| 78 | # Constants — all public secp256k1 parameters (not secrets) |
| 79 | # --------------------------------------------------------------------------- |
| 80 | |
| 81 | #: secp256k1 group order n (prime — from SEC 2 specification). |
| 82 | _SECP256K1_N: int = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 |
| 83 | |
| 84 | #: BIP32 HMAC key for secp256k1 master key derivation. |
| 85 | _BIP32_BITCOIN_SEED: bytes = b"Bitcoin seed" |
| 86 | |
| 87 | #: BIP44 purpose index (hardened). |
| 88 | _BIP44_PURPOSE: int = 44 |
| 89 | |
| 90 | #: BIP44 coin type for Ethereum / AVAX C-Chain (hardened). |
| 91 | _COIN_TYPE_ETH: int = 60 |
| 92 | |
| 93 | #: Hardened index offset — indices >= this value are hardened. |
| 94 | _HARDENED: int = 0x80000000 |
| 95 | |
| 96 | # --------------------------------------------------------------------------- |
| 97 | # Errors |
| 98 | # --------------------------------------------------------------------------- |
| 99 | |
| 100 | class Bip32Error(ValueError): |
| 101 | """Raised when BIP32 derivation fails. |
| 102 | |
| 103 | Common causes: |
| 104 | - Derived child key scalar is zero or >= secp256k1_n (astronomically rare). |
| 105 | - Unhardened derivation attempted on an invalid key. |
| 106 | - Malformed derivation path. |
| 107 | """ |
| 108 | |
| 109 | # --------------------------------------------------------------------------- |
| 110 | # Internal: BIP32 key node |
| 111 | # --------------------------------------------------------------------------- |
| 112 | |
| 113 | @dataclass(frozen=True, slots=True) |
| 114 | class _Bip32Node: |
| 115 | """Internal BIP32 key node: private scalar + chain code. |
| 116 | |
| 117 | Attributes: |
| 118 | private_int: Private key scalar (integer, 0 < scalar < n). |
| 119 | chain_code: 32-byte BIP32 chain code. |
| 120 | """ |
| 121 | |
| 122 | private_int: int |
| 123 | chain_code: bytes |
| 124 | |
| 125 | def _compressed_pubkey(private_int: int) -> bytes: |
| 126 | """Return the 33-byte compressed SEC public key for a secp256k1 private scalar. |
| 127 | |
| 128 | Uses ``eth_keys`` to derive the public key — no custom EC math. |
| 129 | |
| 130 | Args: |
| 131 | private_int: Private key scalar (0 < scalar < secp256k1_n). |
| 132 | |
| 133 | Returns: |
| 134 | 33-byte compressed point in SEC format (02/03 prefix). |
| 135 | """ |
| 136 | priv = _eth_keys.PrivateKey(private_int.to_bytes(32, "big")) |
| 137 | return priv.public_key.to_compressed_bytes() # 33 bytes: 02/03 || x_be32 |
| 138 | |
| 139 | def _derive_child(node: _Bip32Node, index: int) -> _Bip32Node: |
| 140 | """Derive a BIP32 child node at *index* (hardened or unhardened). |
| 141 | |
| 142 | Args: |
| 143 | node: Parent BIP32 node. |
| 144 | index: Child index. Use ``index | 0x80000000`` for hardened. |
| 145 | |
| 146 | Returns: |
| 147 | Child :class:`_Bip32Node`. |
| 148 | |
| 149 | Raises: |
| 150 | Bip32Error: If the derived scalar is invalid (zero or >= n). |
| 151 | """ |
| 152 | if index >= _HARDENED: |
| 153 | # Hardened: data = 0x00 || parent_private_key_bytes || index_be4 |
| 154 | data = b"\x00" + node.private_int.to_bytes(32, "big") + struct.pack(">I", index) |
| 155 | else: |
| 156 | # Unhardened: data = compressed_parent_pubkey || index_be4 |
| 157 | data = _compressed_pubkey(node.private_int) + struct.pack(">I", index) |
| 158 | |
| 159 | I = hmac.new(node.chain_code, data, hashlib.sha512).digest() # noqa: N806 |
| 160 | IL, IR = I[:32], I[32:] # noqa: N806 |
| 161 | |
| 162 | IL_int = int.from_bytes(IL, "big") # noqa: N806 |
| 163 | if IL_int >= _SECP256K1_N: |
| 164 | raise Bip32Error(f"BIP32: IL >= secp256k1_n at index {index} — retry with next index") |
| 165 | |
| 166 | child_int = (IL_int + node.private_int) % _SECP256K1_N |
| 167 | if child_int == 0: |
| 168 | raise Bip32Error(f"BIP32: child scalar is zero at index {index} — retry with next index") |
| 169 | |
| 170 | return _Bip32Node(private_int=child_int, chain_code=IR) |
| 171 | |
| 172 | def _parse_path(path: str) -> list[int]: |
| 173 | """Parse a BIP32 path string into a list of index integers. |
| 174 | |
| 175 | ``'`` suffix means hardened (``index | 0x80000000``). |
| 176 | |
| 177 | Args: |
| 178 | path: Path string, e.g. ``"m/44'/60'/0'/0/0"``. |
| 179 | |
| 180 | Returns: |
| 181 | List of index integers. |
| 182 | |
| 183 | Raises: |
| 184 | Bip32Error: If the path is malformed. |
| 185 | """ |
| 186 | if not path.startswith("m"): |
| 187 | raise Bip32Error(f"BIP32 path must start with 'm': {path!r}") |
| 188 | parts = path.lstrip("m").lstrip("/").split("/") |
| 189 | indices: list[int] = [] |
| 190 | for part in parts: |
| 191 | if not part: |
| 192 | continue |
| 193 | hardened = part.endswith("'") |
| 194 | raw = part.rstrip("'") |
| 195 | try: |
| 196 | idx = int(raw) |
| 197 | except ValueError: |
| 198 | raise Bip32Error(f"Invalid BIP32 path component: {part!r}") |
| 199 | if idx < 0: |
| 200 | raise Bip32Error(f"Negative BIP32 index: {idx}") |
| 201 | indices.append(idx | _HARDENED if hardened else idx) |
| 202 | return indices |
| 203 | |
| 204 | # --------------------------------------------------------------------------- |
| 205 | # Public: BIP32 derivation |
| 206 | # --------------------------------------------------------------------------- |
| 207 | |
| 208 | def bip32_master_key(seed: bytes) -> _Bip32Node: |
| 209 | """Derive the BIP32 secp256k1 master private key from a 64-byte BIP39 seed. |
| 210 | |
| 211 | Uses HMAC-SHA512 with ``key="Bitcoin seed"`` per the BIP32 specification. |
| 212 | |
| 213 | Args: |
| 214 | seed: 64-byte BIP39 seed from :func:`muse.core.bip39.mnemonic_to_seed`. |
| 215 | |
| 216 | Returns: |
| 217 | :class:`_Bip32Node` (private scalar + chain code) for the master key. |
| 218 | |
| 219 | Raises: |
| 220 | Bip32Error: If the derived master scalar is zero or >= secp256k1_n |
| 221 | (practically impossible with any real seed). |
| 222 | |
| 223 | Examples:: |
| 224 | |
| 225 | seed = mnemonic_to_seed("abandon abandon ... about") |
| 226 | master = bip32_master_key(seed) |
| 227 | """ |
| 228 | I = hmac.new(_BIP32_BITCOIN_SEED, seed, hashlib.sha512).digest() # noqa: N806 |
| 229 | IL, IR = I[:32], I[32:] # noqa: N806 |
| 230 | IL_int = int.from_bytes(IL, "big") # noqa: N806 |
| 231 | if IL_int == 0 or IL_int >= _SECP256K1_N: |
| 232 | raise Bip32Error("BIP32: master key scalar is invalid — seed is unusable") |
| 233 | return _Bip32Node(private_int=IL_int, chain_code=IR) |
| 234 | |
| 235 | def bip32_derive_path(seed: bytes, path: str) -> _Bip32Node: |
| 236 | """Derive a BIP32 secp256k1 key at an arbitrary path from a BIP39 seed. |
| 237 | |
| 238 | Args: |
| 239 | seed: 64-byte BIP39 seed. |
| 240 | path: BIP32 path string, e.g. ``"m/44'/60'/0'/0/0"``. |
| 241 | |
| 242 | Returns: |
| 243 | :class:`_Bip32Node` at the requested path. |
| 244 | |
| 245 | Raises: |
| 246 | Bip32Error: If the path is malformed or derivation fails. |
| 247 | |
| 248 | Examples:: |
| 249 | |
| 250 | node = bip32_derive_path(seed, "m/44'/60'/0'/0/0") |
| 251 | """ |
| 252 | node = bip32_master_key(seed) |
| 253 | for idx in _parse_path(path): |
| 254 | node = _derive_child(node, idx) |
| 255 | return node |
| 256 | |
| 257 | def derive_avax_key( |
| 258 | seed: bytes, |
| 259 | account: int = 0, |
| 260 | index: int = 0, |
| 261 | ) -> _eth_keys.PrivateKey: |
| 262 | """Derive the AVAX C-Chain (EVM) signing key at BIP44 path ``m/44'/60'/account'/0/index``. |
| 263 | |
| 264 | This path is compatible with MetaMask, Ledger Ethereum app, Trezor, and all |
| 265 | EVM tooling. The derived key can sign EIP-191 messages and EIP-155 |
| 266 | transactions accepted by Avalanche C-Chain. |
| 267 | |
| 268 | Args: |
| 269 | seed: 64-byte BIP39 seed from :func:`muse.core.bip39.mnemonic_to_seed`. |
| 270 | account: BIP44 account index (>= 0, hardened in path). Default: 0. |
| 271 | index: Address index within the account (>= 0, unhardened). Default: 0. |
| 272 | |
| 273 | Returns: |
| 274 | ``eth_keys.PrivateKey`` on secp256k1 — ready for signing and address derivation. |
| 275 | |
| 276 | Raises: |
| 277 | Bip32Error: If any derivation step produces an invalid scalar. |
| 278 | |
| 279 | Examples:: |
| 280 | |
| 281 | seed = mnemonic_to_seed("abandon ... about") |
| 282 | key = derive_avax_key(seed) |
| 283 | address = avax_c_chain_address(key.public_key) |
| 284 | # "0x9858EfFD232B4033E47d90003D41EC34EcaedA94" |
| 285 | """ |
| 286 | path = f"m/{_BIP44_PURPOSE}'/{_COIN_TYPE_ETH}'/{account}'/0/{index}" |
| 287 | node = bip32_derive_path(seed, path) |
| 288 | return _eth_keys.PrivateKey(node.private_int.to_bytes(32, "big")) |
| 289 | |
| 290 | # --------------------------------------------------------------------------- |
| 291 | # Public: AVAX / EVM addresses |
| 292 | # --------------------------------------------------------------------------- |
| 293 | |
| 294 | def keccak256(data: bytes) -> bytes: |
| 295 | """Return the 32-byte Keccak-256 digest of *data*. |
| 296 | |
| 297 | Uses ``pycryptodome`` (``Crypto.Hash.keccak``) — the battle-tested |
| 298 | Keccak implementation. This is Ethereum's keccak256, **not** FIPS SHA3-256 |
| 299 | (they use different padding). |
| 300 | |
| 301 | Args: |
| 302 | data: Arbitrary bytes to hash. |
| 303 | |
| 304 | Returns: |
| 305 | 32-byte Keccak-256 digest. |
| 306 | |
| 307 | Examples:: |
| 308 | |
| 309 | assert keccak256(b"").hex() == "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" |
| 310 | """ |
| 311 | h = _keccak.new(digest_bits=256) |
| 312 | h.update(data) |
| 313 | return h.digest() |
| 314 | |
| 315 | def eip55_checksum(address_hex: str) -> str: |
| 316 | """Apply EIP-55 mixed-case checksum encoding to a hex Ethereum address. |
| 317 | |
| 318 | Args: |
| 319 | address_hex: 40-character lowercase hex string (without ``0x`` prefix). |
| 320 | |
| 321 | Returns: |
| 322 | EIP-55 checksummed address with ``0x`` prefix, e.g. ``"0xDe0B295..."``. |
| 323 | |
| 324 | Examples:: |
| 325 | |
| 326 | eip55_checksum("de0b295669a9fd93d5f28d9ec85e40f4cb697bae") |
| 327 | # "0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAE" |
| 328 | """ |
| 329 | addr_lower = address_hex.lower() |
| 330 | checksum_hash = keccak256(addr_lower.encode("ascii")).hex() |
| 331 | result = "".join( |
| 332 | c.upper() if int(checksum_hash[i], 16) >= 8 else c |
| 333 | for i, c in enumerate(addr_lower) |
| 334 | ) |
| 335 | return f"0x{result}" |
| 336 | |
| 337 | def avax_c_chain_address(public_key: _eth_keys.PublicKey) -> str: |
| 338 | """Derive the AVAX C-Chain (EVM) address from a secp256k1 public key. |
| 339 | |
| 340 | Delegates entirely to ``eth_keys.PublicKey.to_checksum_address()`` which |
| 341 | computes ``keccak256(x || y)[12:]`` and applies EIP-55 encoding internally. |
| 342 | |
| 343 | Args: |
| 344 | public_key: ``eth_keys.PublicKey`` on secp256k1. |
| 345 | |
| 346 | Returns: |
| 347 | EIP-55 checksummed ``0x``-prefixed address string. |
| 348 | |
| 349 | Examples:: |
| 350 | |
| 351 | key = derive_avax_key(seed) |
| 352 | addr = avax_c_chain_address(key.public_key) |
| 353 | # "0x9858EfFD232B4033E47d90003D41EC34EcaedA94" |
| 354 | """ |
| 355 | return public_key.to_checksum_address() |
| 356 | |
| 357 | # --------------------------------------------------------------------------- |
| 358 | # Public: EIP-191 signing |
| 359 | # --------------------------------------------------------------------------- |
| 360 | |
| 361 | def eip191_message(message: str | bytes) -> bytes: |
| 362 | """Build the EIP-191 canonical bytes for ``personal_sign``. |
| 363 | |
| 364 | Prepends the Ethereum personal-sign prefix so the message cannot be |
| 365 | confused with a raw transaction:: |
| 366 | |
| 367 | b"\\x19Ethereum Signed Message:\\n" + str(len(msg)).encode() + msg |
| 368 | |
| 369 | Args: |
| 370 | message: UTF-8 string or raw bytes to sign. |
| 371 | |
| 372 | Returns: |
| 373 | Prefixed bytes ready for keccak256 hashing and signing. |
| 374 | |
| 375 | Examples:: |
| 376 | |
| 377 | msg = eip191_message("Hello Muse") |
| 378 | # b"\\x19Ethereum Signed Message:\\n10Hello Muse" |
| 379 | """ |
| 380 | msg_bytes = message.encode("utf-8") if isinstance(message, str) else message |
| 381 | prefix = f"\x19Ethereum Signed Message:\n{len(msg_bytes)}".encode("utf-8") |
| 382 | return prefix + msg_bytes |
| 383 | |
| 384 | def eip191_sign(private_key: _eth_keys.PrivateKey, message: str | bytes) -> bytes: |
| 385 | """Sign a message with EIP-191 ``personal_sign`` encoding. |
| 386 | |
| 387 | The returned 65-byte signature is in ``(r || s || v)`` compact form where |
| 388 | ``v ∈ {27, 28}`` (legacy Ethereum recovery ID). This is the format |
| 389 | accepted by ``ecrecover`` on EVM chains including Avalanche C-Chain. |
| 390 | |
| 391 | Signing is delegated entirely to ``eth_keys`` (Ethereum Foundation library). |
| 392 | |
| 393 | Args: |
| 394 | private_key: ``eth_keys.PrivateKey`` (e.g. from :func:`derive_avax_key`). |
| 395 | message: UTF-8 string or bytes to sign. |
| 396 | |
| 397 | Returns: |
| 398 | 65-byte signature: ``r`` (32 bytes) + ``s`` (32 bytes) + ``v`` (1 byte, |
| 399 | value 27 or 28). |
| 400 | |
| 401 | Examples:: |
| 402 | |
| 403 | key = derive_avax_key(seed) |
| 404 | sig = eip191_sign(key, "MPAY2\\ngabriel\\nalice\\n0.001\\nAVAX\\nnonce\\n1234") |
| 405 | assert len(sig) == 65 |
| 406 | """ |
| 407 | digest = keccak256(eip191_message(message)) |
| 408 | sig = private_key.sign_msg_hash(digest) |
| 409 | # eth_keys v is 0 or 1; EVM ecrecover expects 27 or 28. |
| 410 | sig_bytes = sig.to_bytes() # r(32) || s(32) || v(0 or 1) |
| 411 | return sig_bytes[:64] + bytes([sig_bytes[64] + 27]) |
| 412 | |
| 413 | def eip191_verify( |
| 414 | signature: bytes, |
| 415 | message: str | bytes, |
| 416 | expected_address: str, |
| 417 | ) -> bool: |
| 418 | """Verify an EIP-191 signature against an expected AVAX C-Chain address. |
| 419 | |
| 420 | Recovers the signer's public key via ``eth_keys`` and checks that the |
| 421 | derived EVM address matches *expected_address*. No custom EC math. |
| 422 | |
| 423 | Args: |
| 424 | signature: 65-byte EIP-191 signature from :func:`eip191_sign`. |
| 425 | message: The original message (string or bytes). |
| 426 | expected_address: EIP-55 checksummed ``0x``-prefixed address, e.g. |
| 427 | ``"0x9858EfFD232B4033E47d90003D41EC34EcaedA94"``. |
| 428 | |
| 429 | Returns: |
| 430 | ``True`` if the signature was produced by *expected_address*. |
| 431 | |
| 432 | Examples:: |
| 433 | |
| 434 | key = derive_avax_key(seed) |
| 435 | sig = eip191_sign(key, "Hello Muse") |
| 436 | assert eip191_verify(sig, "Hello Muse", avax_c_chain_address(key.public_key)) |
| 437 | """ |
| 438 | if len(signature) != 65: |
| 439 | return False |
| 440 | |
| 441 | v_byte = signature[64] |
| 442 | if v_byte not in (27, 28): |
| 443 | return False |
| 444 | |
| 445 | # Convert v from 27/28 back to 0/1 for eth_keys. |
| 446 | eth_sig_bytes = signature[:64] + bytes([v_byte - 27]) |
| 447 | digest = keccak256(eip191_message(message)) |
| 448 | |
| 449 | try: |
| 450 | sig = _eth_keys.Signature(signature_bytes=eth_sig_bytes) |
| 451 | recovered_pub = sig.recover_public_key_from_msg_hash(digest) |
| 452 | return avax_c_chain_address(recovered_pub).lower() == expected_address.lower() |
| 453 | except Exception: |
| 454 | return False |
File History
1 commit
sha256:06dba78c2a78e251b580422dd1fd547f3c8357ff18f7709a860873b2d24dbbbf
chore: bump version to 0.2.0rc14
Sonnet 4.6
patch
2 days ago