gabriel / muse public
secp256k1_sign.py python
454 lines 16.0 KB
Raw
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