From 1bc934b86e3a783e70a4e7c46f561aee779575e5 Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 18 Jul 2026 19:19:22 +0200 Subject: [PATCH 1/2] feat(musig): add libsecp256k1 MuSig2 bindings --- setup.py | 1 + src/electrum_ecc/ecc_fast.py | 52 ++++ src/electrum_ecc/musig.py | 572 +++++++++++++++++++++++++++++++++++ 3 files changed, 625 insertions(+) create mode 100644 src/electrum_ecc/musig.py diff --git a/setup.py b/setup.py index 03650c2..f719f4f 100644 --- a/setup.py +++ b/setup.py @@ -113,6 +113,7 @@ def compile_secp(build_dir: str) -> None: '--enable-module-recovery', '--enable-module-extrakeys', '--enable-module-schnorrsig', + '--enable-module-musig', '--enable-experimental', '--enable-module-ecdh', '--enable-benchmark=no', diff --git a/src/electrum_ecc/ecc_fast.py b/src/electrum_ecc/ecc_fast.py index d48bf29..ec1723c 100644 --- a/src/electrum_ecc/ecc_fast.py +++ b/src/electrum_ecc/ecc_fast.py @@ -49,6 +49,9 @@ class LibModuleMissing(Exception): pass def load_library(): global HAS_SCHNORR + global HAS_MUSIG + + HAS_MUSIG = False libnames_local = [] libnames_anywhere = [] @@ -180,6 +183,54 @@ def load_library(): # raise LibModuleMissing('libsecp256k1 library found but it was built ' # 'without required module (--enable-module-extrakeys)') + # --enable-module-musig + try: + char_ptr = POINTER(c_char) + char_ptr_ptr = POINTER(char_ptr) + secp256k1.secp256k1_musig_pubnonce_parse.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_pubnonce_parse.restype = c_int + secp256k1.secp256k1_musig_pubnonce_serialize.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_pubnonce_serialize.restype = c_int + secp256k1.secp256k1_musig_aggnonce_parse.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_aggnonce_parse.restype = c_int + secp256k1.secp256k1_musig_aggnonce_serialize.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_aggnonce_serialize.restype = c_int + secp256k1.secp256k1_musig_partial_sig_parse.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_partial_sig_parse.restype = c_int + secp256k1.secp256k1_musig_partial_sig_serialize.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_partial_sig_serialize.restype = c_int + + secp256k1.secp256k1_musig_pubkey_agg.argtypes = [c_void_p, char_ptr, char_ptr, char_ptr_ptr, c_size_t] + secp256k1.secp256k1_musig_pubkey_agg.restype = c_int + secp256k1.secp256k1_musig_pubkey_ec_tweak_add.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_pubkey_ec_tweak_add.restype = c_int + secp256k1.secp256k1_musig_pubkey_xonly_tweak_add.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_pubkey_xonly_tweak_add.restype = c_int + + secp256k1.secp256k1_musig_nonce_gen.argtypes = [ + c_void_p, POINTER(c_char), POINTER(c_char), POINTER(c_char), + POINTER(c_char), POINTER(c_char), POINTER(c_char), + POINTER(c_char), POINTER(c_char), + ] + secp256k1.secp256k1_musig_nonce_gen.restype = c_int + secp256k1.secp256k1_musig_nonce_agg.argtypes = [c_void_p, char_ptr, char_ptr_ptr, c_size_t] + secp256k1.secp256k1_musig_nonce_agg.restype = c_int + secp256k1.secp256k1_musig_nonce_process.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char), POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_nonce_process.restype = c_int + secp256k1.secp256k1_musig_partial_sign.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char), POINTER(c_char), POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_partial_sign.restype = c_int + secp256k1.secp256k1_musig_partial_sig_verify.argtypes = [c_void_p, POINTER(c_char), POINTER(c_char), POINTER(c_char), POINTER(c_char), POINTER(c_char)] + secp256k1.secp256k1_musig_partial_sig_verify.restype = c_int + secp256k1.secp256k1_musig_partial_sig_agg.argtypes = [c_void_p, char_ptr, char_ptr, char_ptr_ptr, c_size_t] + secp256k1.secp256k1_musig_partial_sig_agg.restype = c_int + except (OSError, AttributeError): + _logger.warning( + 'libsecp256k1 library found but it was built without desired module ' + '(--enable-module-musig)' + ) + else: + HAS_MUSIG = True + secp256k1.ctx = secp256k1.secp256k1_context_create(SECP256K1_CONTEXT_SIGN | SECP256K1_CONTEXT_VERIFY) ret = secp256k1.secp256k1_context_randomize(secp256k1.ctx, os.urandom(32)) if not ret: @@ -194,6 +245,7 @@ def load_library(): _libsecp256k1 = None HAS_SCHNORR = True +HAS_MUSIG = False try: _libsecp256k1 = load_library() except BaseException as e: diff --git a/src/electrum_ecc/musig.py b/src/electrum_ecc/musig.py new file mode 100644 index 0000000..8abaab7 --- /dev/null +++ b/src/electrum_ecc/musig.py @@ -0,0 +1,572 @@ +# Copyright (C) 2024-2026 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php +"""A randomized, 32-byte-message subset of BIP327 MuSig2. + +This is a thin wrapper around libsecp256k1's MuSig module. It supports the +standard randomized nonce flow, key aggregation and tweaking, partial signing +and verification, and signature aggregation. It is not a complete BIP327 +implementation. + +Secret nonces must never be copied, serialized, or reused. This module exposes +no secret nonce serialization. It reserves each ``SecNonce`` once immediately +before signing and then attempts to erase it. Erasure is best effort: Python +cannot guarantee timely finalization, prevent process-memory copies, or promise +that an interpreter or operating system will physically overwrite memory. +""" + +from __future__ import annotations + +import secrets +from collections.abc import Sequence +from ctypes import ( + POINTER, + Array, + byref, + c_char, + c_size_t, + cast, + create_string_buffer, + memset, +) +from threading import Lock +from typing import Optional + +from . import ecc_fast +from .ecc_fast import LibModuleMissing, SECP256K1_EC_COMPRESSED, _libsecp256k1 + + +_KEYAGG_CACHE_SIZE = 197 +_SECNONCE_SIZE = 132 +_PUBNONCE_SIZE = 132 +_AGGNONCE_SIZE = 132 +_SESSION_SIZE = 133 +_PARTIAL_SIG_SIZE = 36 + +_PUBNONCE_WIRE_SIZE = 66 +_AGGNONCE_WIRE_SIZE = 66 +_PARTIAL_SIG_WIRE_SIZE = 32 +_PUBKEY_SIZE = 64 +_KEYPAIR_SIZE = 96 + +_INTERNAL = object() +_BYTES_LIKE = (bytes, bytearray, memoryview) + + +def _check() -> None: + if not ecc_fast.HAS_MUSIG: + raise LibModuleMissing( + "libsecp256k1 library found but it was built without the required " + "module (--enable-module-musig)" + ) + + +def _as_bytes(value: object, name: str, size: Optional[int] = None) -> bytes: + if not isinstance(value, _BYTES_LIKE): + raise TypeError(f"{name} must be bytes-like") + result = bytes(value) + if size is not None and len(result) != size: + raise ValueError(f"{name} must be {size} bytes") + return result + + +def _as_sequence(value: object, name: str) -> Sequence: + if not isinstance(value, Sequence) or isinstance(value, _BYTES_LIKE + (str,)): + raise TypeError(f"{name} must be a sequence") + if len(value) == 0: + raise ValueError(f"at least one {name[:-1]} is required") + return value + + +def _require_exact(value: object, expected_type: type, name: str) -> None: + if type(value) is not expected_type: + raise TypeError(f"{name} must be {expected_type.__name__}") + + +def _pointer_array(buffers: Sequence[Array]) -> Array: + array_type = POINTER(c_char) * len(buffers) + return array_type(*(cast(buf, POINTER(c_char)) for buf in buffers)) + + +def _parse_pubkey(pubkey: object) -> bytes: + pubkey_bytes = _as_bytes(pubkey, "pubkey") + out = create_string_buffer(_PUBKEY_SIZE) + if 1 != _libsecp256k1.secp256k1_ec_pubkey_parse( + _libsecp256k1.ctx, out, pubkey_bytes, len(pubkey_bytes) + ): + raise ValueError("invalid public key") + return bytes(out) + + +def _serialize_pubkey(pubkey: Array) -> bytes: + out = create_string_buffer(33) + out_len = c_size_t(33) + if 1 != _libsecp256k1.secp256k1_ec_pubkey_serialize( + _libsecp256k1.ctx, out, byref(out_len), pubkey, SECP256K1_EC_COMPRESSED + ): + raise RuntimeError("public key serialization failed") + return bytes(out) + + +def _keypair_from_seckey(seckey: bytes) -> Array: + out = create_string_buffer(_KEYPAIR_SIZE) + if 1 != _libsecp256k1.secp256k1_keypair_create(_libsecp256k1.ctx, out, seckey): + memset(out, 0, _KEYPAIR_SIZE) + raise ValueError("invalid seckey") + return out + + +def _pubkey_from_seckey(seckey: bytes) -> bytes: + out = create_string_buffer(_PUBKEY_SIZE) + if 1 != _libsecp256k1.secp256k1_ec_pubkey_create(_libsecp256k1.ctx, out, seckey): + raise ValueError("invalid seckey") + return bytes(out) + + +class KeyAggCache: + """Opaque key aggregation state, mutated in place by tweaks.""" + + __slots__ = ("_buf", "_aggregate_xonly") + + def __init__( + self, buf: object = b"", aggregate_xonly: object = b"", *, _token: object = None + ) -> None: + if _token is not _INTERNAL: + raise TypeError("KeyAggCache instances must be created with from_pubkeys") + self._buf = _as_bytes(buf, "KeyAggCache buffer", _KEYAGG_CACHE_SIZE) + self._aggregate_xonly = _as_bytes( + aggregate_xonly, "aggregate x-only pubkey", 32 + ) + + @classmethod + def from_pubkeys(cls, pubkeys: Sequence[bytes]) -> "KeyAggCache": + """Aggregate an ordered, nonempty sequence of secp256k1 public keys.""" + _check() + if cls is not KeyAggCache: + raise TypeError("KeyAggCache subclasses are not supported") + pubkeys = _as_sequence(pubkeys, "pubkeys") + parsed = [_parse_pubkey(pubkey) for pubkey in pubkeys] + buffers = [create_string_buffer(pubkey, _PUBKEY_SIZE) for pubkey in parsed] + pointers = _pointer_array(buffers) + aggregate_xonly = create_string_buffer(_PUBKEY_SIZE) + cache = create_string_buffer(_KEYAGG_CACHE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_pubkey_agg( + _libsecp256k1.ctx, + aggregate_xonly, + cache, + pointers, + c_size_t(len(buffers)), + ): + raise ValueError("musig_pubkey_agg failed") + xonly = create_string_buffer(32) + if 1 != _libsecp256k1.secp256k1_xonly_pubkey_serialize( + _libsecp256k1.ctx, xonly, aggregate_xonly + ): + raise RuntimeError("aggregate public key serialization failed") + return cls(bytes(cache), bytes(xonly), _token=_INTERNAL) + + def aggregate_xonly_pubkey(self) -> bytes: + """Return the current aggregate key as 32-byte BIP340 x-only bytes.""" + _require_exact(self, KeyAggCache, "keyagg_cache") + return self._aggregate_xonly + + def apply_plain_tweak(self, tweak32: bytes) -> bytes: + """Apply a plain EC tweak and return the compressed aggregate key.""" + return self._apply_tweak(tweak32, xonly=False) + + def apply_xonly_tweak(self, tweak32: bytes) -> bytes: + """Apply an x-only tweak and return the compressed aggregate key.""" + return self._apply_tweak(tweak32, xonly=True) + + def _apply_tweak(self, tweak32: object, *, xonly: bool) -> bytes: + _check() + _require_exact(self, KeyAggCache, "keyagg_cache") + tweak = _as_bytes(tweak32, "tweak", 32) + cache = create_string_buffer(bytes(self._buf), _KEYAGG_CACHE_SIZE) + output_pubkey = create_string_buffer(_PUBKEY_SIZE) + function = ( + _libsecp256k1.secp256k1_musig_pubkey_xonly_tweak_add + if xonly + else _libsecp256k1.secp256k1_musig_pubkey_ec_tweak_add + ) + if 1 != function(_libsecp256k1.ctx, output_pubkey, cache, tweak): + raise ValueError("musig tweak failed") + + compressed = _serialize_pubkey(output_pubkey) + self._buf = bytes(cache) + self._aggregate_xonly = compressed[1:] + return compressed + + +class PubNonce: + """A validated, serializable 66-byte public nonce.""" + + __slots__ = ("_wire",) + + def __init__(self, wire66: bytes) -> None: + _check() + wire = _as_bytes(wire66, "pubnonce", _PUBNONCE_WIRE_SIZE) + out = create_string_buffer(_PUBNONCE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_pubnonce_parse( + _libsecp256k1.ctx, out, wire + ): + raise ValueError("invalid pubnonce") + self._wire = wire + + def to_bytes(self) -> bytes: + _require_exact(self, PubNonce, "pubnonce") + return self._wire + + def _to_internal(self) -> bytes: + _require_exact(self, PubNonce, "pubnonce") + out = create_string_buffer(_PUBNONCE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_pubnonce_parse( + _libsecp256k1.ctx, out, self._wire + ): + raise RuntimeError("stored pubnonce became invalid") + return bytes(out) + + +class SecNonce: + """A single-use secret nonce with best-effort in-memory cleanup. + + ``partial_sign`` atomically reserves a nonce under a per-instance lock + before entering libsecp256k1. From that point onward it remains consumed and + is zeroed even if the C call fails. Python cannot guarantee complete erasure. + """ + + __slots__ = ("_buf", "_consumed", "_lock", "_pubkey", "_pubnonce") + + def __init__(self, pubkey: object = b"", *, _token: object = None) -> None: + if _token is not _INTERNAL: + raise TypeError("SecNonce instances must be created with nonce_gen") + self._pubkey = _as_bytes(pubkey, "internal pubkey", _PUBKEY_SIZE) + self._buf = create_string_buffer(_SECNONCE_SIZE) + self._consumed = False + self._lock = Lock() + self._pubnonce: Optional[PubNonce] = None + + def __copy__(self) -> "SecNonce": + raise TypeError("SecNonce must not be copied") + + def __deepcopy__(self, memo: dict[int, object]) -> "SecNonce": + raise TypeError("SecNonce must not be copied") + + def _reserve_for_signing(self) -> Array: + _require_exact(self, SecNonce, "secnonce") + with self._lock: + if self._consumed: + raise ValueError( + "secnonce already consumed (nonce reuse would leak the seckey)" + ) + self._consumed = True + return self._buf + + def _wipe(self) -> None: + memset(self._buf, 0, _SECNONCE_SIZE) + + def __del__(self) -> None: + try: + self._wipe() + except BaseException: + pass + + +class AggNonce: + """A validated, serializable 66-byte aggregate nonce.""" + + __slots__ = ("_wire",) + + def __init__(self, wire66: bytes) -> None: + _check() + wire = _as_bytes(wire66, "aggnonce", _AGGNONCE_WIRE_SIZE) + out = create_string_buffer(_AGGNONCE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_aggnonce_parse( + _libsecp256k1.ctx, out, wire + ): + raise ValueError("invalid aggnonce") + self._wire = wire + + def to_bytes(self) -> bytes: + _require_exact(self, AggNonce, "aggnonce") + return self._wire + + def _to_internal(self) -> bytes: + _require_exact(self, AggNonce, "aggnonce") + out = create_string_buffer(_AGGNONCE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_aggnonce_parse( + _libsecp256k1.ctx, out, self._wire + ): + raise RuntimeError("stored aggnonce became invalid") + return bytes(out) + + +class PartialSig: + """A validated, serializable 32-byte MuSig2 partial signature.""" + + __slots__ = ("_wire",) + + def __init__(self, wire32: bytes) -> None: + _check() + wire = _as_bytes(wire32, "partial sig", _PARTIAL_SIG_WIRE_SIZE) + out = create_string_buffer(_PARTIAL_SIG_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_partial_sig_parse( + _libsecp256k1.ctx, out, wire + ): + raise ValueError("invalid partial sig") + self._wire = wire + + def to_bytes(self) -> bytes: + _require_exact(self, PartialSig, "partial_sig") + return self._wire + + def _to_internal(self) -> bytes: + _require_exact(self, PartialSig, "partial_sig") + out = create_string_buffer(_PARTIAL_SIG_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_partial_sig_parse( + _libsecp256k1.ctx, out, self._wire + ): + raise RuntimeError("stored partial sig became invalid") + return bytes(out) + + +class Session: + """Opaque state created by ``nonce_process`` for one signing session.""" + + __slots__ = ("_buf",) + + def __init__(self, buf: object = b"", *, _token: object = None) -> None: + if _token is not _INTERNAL: + raise TypeError("Session instances must be created with nonce_process") + self._buf = _as_bytes(buf, "session", _SESSION_SIZE) + + +def nonce_gen( + *, + pubkey: bytes, + seckey: Optional[bytes] = None, + msg32: Optional[bytes] = None, + keyagg_cache: Optional[KeyAggCache] = None, + extra_input32: Optional[bytes] = None, + session_secrand32: Optional[bytearray] = None, +) -> tuple[SecNonce, PubNonce]: + """Generate a secret/public nonce pair using unique random session data. + + A supplied ``session_secrand32`` must be a mutable 32-byte ``bytearray`` and + is consumed and zeroed. Otherwise, secure randomness is generated locally. + """ + _check() + if session_secrand32 is not None and type(session_secrand32) is not bytearray: + raise TypeError("session_secrand32 must be a bytearray so it can be consumed") + if session_secrand32 is not None and len(session_secrand32) != 32: + raise ValueError("session_secrand32 must be 32 bytes") + if session_secrand32 is None: + session_secrand32 = bytearray(secrets.token_bytes(32)) + + secnonce: Optional[SecNonce] = None + try: + secrand = (c_char * 32).from_buffer(session_secrand32) + pubkey_bytes = _as_bytes(pubkey, "pubkey") + seckey_bytes = None if seckey is None else _as_bytes(seckey, "seckey", 32) + msg = None if msg32 is None else _as_bytes(msg32, "msg32", 32) + extra_input = ( + None + if extra_input32 is None + else _as_bytes(extra_input32, "extra_input32", 32) + ) + if keyagg_cache is not None: + _require_exact(keyagg_cache, KeyAggCache, "keyagg_cache") + parsed_pubkey = _parse_pubkey(pubkey_bytes) + if ( + seckey_bytes is not None + and _pubkey_from_seckey(seckey_bytes) != parsed_pubkey + ): + raise ValueError("seckey does not match pubkey") + secnonce = SecNonce(parsed_pubkey, _token=_INTERNAL) + pubnonce = create_string_buffer(_PUBNONCE_SIZE) + cache = bytes(keyagg_cache._buf) if keyagg_cache is not None else None + if 1 != _libsecp256k1.secp256k1_musig_nonce_gen( + _libsecp256k1.ctx, + secnonce._buf, + pubnonce, + secrand, + seckey_bytes, + parsed_pubkey, + msg, + cache, + extra_input, + ): + raise ValueError("musig_nonce_gen failed") + wire = create_string_buffer(_PUBNONCE_WIRE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_pubnonce_serialize( + _libsecp256k1.ctx, wire, pubnonce + ): + raise RuntimeError("pubnonce serialization failed") + public_nonce = PubNonce(bytes(wire)) + secnonce._pubnonce = public_nonce + return secnonce, public_nonce + except BaseException: + if secnonce is not None: + secnonce._wipe() + raise + finally: + session_secrand32[:] = bytes(32) + + +def nonce_agg(pubnonces: Sequence[PubNonce]) -> AggNonce: + """Aggregate a nonempty sequence of public nonces.""" + _check() + pubnonces = _as_sequence(pubnonces, "pubnonces") + for pubnonce in pubnonces: + _require_exact(pubnonce, PubNonce, "pubnonce") + buffers = [ + create_string_buffer(pubnonce._to_internal(), _PUBNONCE_SIZE) + for pubnonce in pubnonces + ] + pointers = _pointer_array(buffers) + aggregate = create_string_buffer(_AGGNONCE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_nonce_agg( + _libsecp256k1.ctx, aggregate, pointers, c_size_t(len(buffers)) + ): + raise ValueError("musig_nonce_agg failed") + wire = create_string_buffer(_AGGNONCE_WIRE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_aggnonce_serialize( + _libsecp256k1.ctx, wire, aggregate + ): + raise RuntimeError("aggnonce serialization failed") + return AggNonce(bytes(wire)) + + +def nonce_process( + *, aggnonce: AggNonce, msg32: bytes, keyagg_cache: KeyAggCache +) -> Session: + """Create a signing session for a 32-byte message.""" + _check() + _require_exact(aggnonce, AggNonce, "aggnonce") + _require_exact(keyagg_cache, KeyAggCache, "keyagg_cache") + msg = _as_bytes(msg32, "msg32", 32) + session = create_string_buffer(_SESSION_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_nonce_process( + _libsecp256k1.ctx, + session, + aggnonce._to_internal(), + msg, + bytes(keyagg_cache._buf), + ): + raise ValueError("musig_nonce_process failed") + return Session(bytes(session), _token=_INTERNAL) + + +def partial_sign( + *, + secnonce: SecNonce, + seckey: bytes, + keyagg_cache: KeyAggCache, + session: Session, +) -> PartialSig: + """Create a partial signature and irrevocably consume ``secnonce``.""" + _check() + _require_exact(secnonce, SecNonce, "secnonce") + _require_exact(keyagg_cache, KeyAggCache, "keyagg_cache") + _require_exact(session, Session, "session") + seckey_bytes = _as_bytes(seckey, "seckey", 32) + + keypair = _keypair_from_seckey(seckey_bytes) + try: + signer_pubkey = _pubkey_from_seckey(seckey_bytes) + if signer_pubkey != secnonce._pubkey: + raise ValueError("seckey does not match the secnonce pubkey") + if secnonce._pubnonce is None: + raise RuntimeError("secnonce has no associated pubnonce") + _require_exact(secnonce._pubnonce, PubNonce, "secnonce pubnonce") + pubnonce = secnonce._pubnonce._to_internal() + cache = bytes(keyagg_cache._buf) + session_buf = session._buf + partial_sig = create_string_buffer(_PARTIAL_SIG_SIZE) + + secnonce_buf = secnonce._reserve_for_signing() + try: + result = _libsecp256k1.secp256k1_musig_partial_sign( + _libsecp256k1.ctx, + partial_sig, + secnonce_buf, + keypair, + cache, + session_buf, + ) + finally: + secnonce._wipe() + if result != 1: + raise ValueError("musig_partial_sign failed") + if 1 != _libsecp256k1.secp256k1_musig_partial_sig_verify( + _libsecp256k1.ctx, + partial_sig, + pubnonce, + signer_pubkey, + cache, + session_buf, + ): + raise ValueError("generated partial signature failed self-verification") + wire = create_string_buffer(_PARTIAL_SIG_WIRE_SIZE) + if 1 != _libsecp256k1.secp256k1_musig_partial_sig_serialize( + _libsecp256k1.ctx, wire, partial_sig + ): + raise RuntimeError("partial signature serialization failed") + return PartialSig(bytes(wire)) + finally: + memset(keypair, 0, _KEYPAIR_SIZE) + + +def partial_sig_verify( + *, + partial_sig: PartialSig, + pubnonce: PubNonce, + pubkey: bytes, + keyagg_cache: KeyAggCache, + session: Session, +) -> bool: + """Verify one signer's partial signature for a signing session.""" + _check() + _require_exact(partial_sig, PartialSig, "partial_sig") + _require_exact(pubnonce, PubNonce, "pubnonce") + _require_exact(keyagg_cache, KeyAggCache, "keyagg_cache") + _require_exact(session, Session, "session") + pubkey_bytes = _as_bytes(pubkey, "pubkey") + parsed_pubkey = _parse_pubkey(pubkey_bytes) + return 1 == _libsecp256k1.secp256k1_musig_partial_sig_verify( + _libsecp256k1.ctx, + partial_sig._to_internal(), + pubnonce._to_internal(), + parsed_pubkey, + bytes(keyagg_cache._buf), + session._buf, + ) + + +def partial_sig_agg(*, session: Session, partial_sigs: Sequence[PartialSig]) -> bytes: + """Aggregate partial signatures into a possibly invalid BIP340 signature. + + A successful aggregation only means the inputs were well formed. The caller + must BIP340-verify the returned signature against the message and aggregate + x-only public key. + """ + _check() + _require_exact(session, Session, "session") + partial_sigs = _as_sequence(partial_sigs, "partial_sigs") + for partial_sig in partial_sigs: + _require_exact(partial_sig, PartialSig, "partial_sig") + buffers = [ + create_string_buffer(partial_sig._to_internal(), _PARTIAL_SIG_SIZE) + for partial_sig in partial_sigs + ] + pointers = _pointer_array(buffers) + signature = create_string_buffer(64) + if 1 != _libsecp256k1.secp256k1_musig_partial_sig_agg( + _libsecp256k1.ctx, + signature, + session._buf, + pointers, + c_size_t(len(buffers)), + ): + raise ValueError("musig_partial_sig_agg failed") + return bytes(signature) From c143ab969e2eedcb9520a2e0b42a53a8ccf1eea7 Mon Sep 17 00:00:00 2001 From: m0wer Date: Sat, 18 Jul 2026 19:19:31 +0200 Subject: [PATCH 2/2] test(musig): validate BIP327 vectors and fallback --- .github/workflows/ci.yml | 27 + tests/data/bip327/key_agg_vectors.json | 88 +++ tests/data/bip327/nonce_agg_vectors.json | 51 ++ tests/data/bip327/nonce_gen_vectors.json | 44 ++ tests/data/bip327/sig_agg_vectors.json | 152 +++++ tests/data/bip327/sign_verify_vectors.json | 212 +++++++ tests/data/bip327/tweak_vectors.json | 84 +++ tests/test_musig.py | 656 +++++++++++++++++++++ 8 files changed, 1314 insertions(+) create mode 100644 tests/data/bip327/key_agg_vectors.json create mode 100644 tests/data/bip327/nonce_agg_vectors.json create mode 100644 tests/data/bip327/nonce_gen_vectors.json create mode 100644 tests/data/bip327/sig_agg_vectors.json create mode 100644 tests/data/bip327/sign_verify_vectors.json create mode 100644 tests/data/bip327/tweak_vectors.json create mode 100644 tests/test_musig.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af8bd3e..f80eb25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: python3 --version pip freeze --all python3 -c "import electrum_ecc; print(f'{electrum_ecc.__version__=}'); print(electrum_ecc.ecc_fast.version_info())" + python3 -c "from electrum_ecc.ecc_fast import HAS_MUSIG; assert HAS_MUSIG" - name: Run pytest run: pytest tests --cov=electrum_ecc @@ -82,3 +83,29 @@ jobs: - name: import test from src/ working-directory: src run: python3 -c "import electrum_ecc; print(f'{electrum_ecc.__version__=}'); print(electrum_ecc.ecc_fast.version_info())" + + system-lib-without-musig: + name: "unittests: system libsecp256k1 without MuSig" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: true + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install system libsecp256k1 and package + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends libsecp256k1-1 + pip install pytest + ELECTRUM_ECC_DONT_COMPILE=1 pip install . + + - name: Verify fallback and run pytest + run: | + python3 -c "from electrum_ecc.ecc_fast import HAS_MUSIG; assert not HAS_MUSIG" + pytest tests diff --git a/tests/data/bip327/key_agg_vectors.json b/tests/data/bip327/key_agg_vectors.json new file mode 100644 index 0000000..b2e623d --- /dev/null +++ b/tests/data/bip327/key_agg_vectors.json @@ -0,0 +1,88 @@ +{ + "pubkeys": [ + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "03DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66", + "020000000000000000000000000000000000000000000000000000000000000005", + "02FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30", + "04F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9" + ], + "tweaks": [ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", + "252E4BD67410A76CDF933D30EAA1608214037F1B105A013ECCD3C5C184A6110B" + ], + "valid_test_cases": [ + { + "key_indices": [0, 1, 2], + "expected": "90539EEDE565F5D054F32CC0C220126889ED1E5D193BAF15AEF344FE59D4610C" + }, + { + "key_indices": [2, 1, 0], + "expected": "6204DE8B083426DC6EAF9502D27024D53FC826BF7D2012148A0575435DF54B2B" + }, + { + "key_indices": [0, 0, 0], + "expected": "B436E3BAD62B8CD409969A224731C193D051162D8C5AE8B109306127DA3AA935" + }, + { + "key_indices": [0, 0, 1, 1], + "expected": "69BC22BFA5D106306E48A20679DE1D7389386124D07571D0D872686028C26A3E" + } + ], + "error_test_cases": [ + { + "key_indices": [0, 3], + "tweak_indices": [], + "is_xonly": [], + "error": { + "type": "invalid_contribution", + "signer": 1, + "contrib": "pubkey" + }, + "comment": "Invalid public key" + }, + { + "key_indices": [0, 4], + "tweak_indices": [], + "is_xonly": [], + "error": { + "type": "invalid_contribution", + "signer": 1, + "contrib": "pubkey" + }, + "comment": "Public key exceeds field size" + }, + { + "key_indices": [5, 0], + "tweak_indices": [], + "is_xonly": [], + "error": { + "type": "invalid_contribution", + "signer": 0, + "contrib": "pubkey" + }, + "comment": "First byte of public key is not 2 or 3" + }, + { + "key_indices": [0, 1], + "tweak_indices": [0], + "is_xonly": [true], + "error": { + "type": "value", + "message": "The tweak must be less than n." + }, + "comment": "Tweak is out of range" + }, + { + "key_indices": [6], + "tweak_indices": [1], + "is_xonly": [false], + "error": { + "type": "value", + "message": "The result of tweaking cannot be infinity." + }, + "comment": "Intermediate tweaking result is point at infinity" + } + ] +} diff --git a/tests/data/bip327/nonce_agg_vectors.json b/tests/data/bip327/nonce_agg_vectors.json new file mode 100644 index 0000000..1c04b88 --- /dev/null +++ b/tests/data/bip327/nonce_agg_vectors.json @@ -0,0 +1,51 @@ +{ + "pnonces": [ + "020151C80F435648DF67A22B749CD798CE54E0321D034B92B709B567D60A42E66603BA47FBC1834437B3212E89A84D8425E7BF12E0245D98262268EBDCB385D50641", + "03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60248C264CDD57D3C24D79990B0F865674EB62A0F9018277A95011B41BFC193B833", + "020151C80F435648DF67A22B749CD798CE54E0321D034B92B709B567D60A42E6660279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", + "03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60379BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", + "04FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60248C264CDD57D3C24D79990B0F865674EB62A0F9018277A95011B41BFC193B833", + "03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A60248C264CDD57D3C24D79990B0F865674EB62A0F9018277A95011B41BFC193B831", + "03FF406FFD8ADB9CD29877E4985014F66A59F6CD01C0E88CAA8E5F3166B1F676A602FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30" + ], + "valid_test_cases": [ + { + "pnonce_indices": [0, 1], + "expected": "035FE1873B4F2967F52FEA4A06AD5A8ECCBE9D0FD73068012C894E2E87CCB5804B024725377345BDE0E9C33AF3C43C0A29A9249F2F2956FA8CFEB55C8573D0262DC8" + }, + { + "pnonce_indices": [2, 3], + "expected": "035FE1873B4F2967F52FEA4A06AD5A8ECCBE9D0FD73068012C894E2E87CCB5804B000000000000000000000000000000000000000000000000000000000000000000", + "comment": "Sum of second points encoded in the nonces is point at infinity which is serialized as 33 zero bytes" + } + ], + "error_test_cases": [ + { + "pnonce_indices": [0, 4], + "error": { + "type": "invalid_contribution", + "signer": 1, + "contrib": "pubnonce" + }, + "comment": "Public nonce from signer 1 is invalid due wrong tag, 0x04, in the first half" + }, + { + "pnonce_indices": [5, 1], + "error": { + "type": "invalid_contribution", + "signer": 0, + "contrib": "pubnonce" + }, + "comment": "Public nonce from signer 0 is invalid because the second half does not correspond to an X coordinate" + }, + { + "pnonce_indices": [6, 1], + "error": { + "type": "invalid_contribution", + "signer": 0, + "contrib": "pubnonce" + }, + "comment": "Public nonce from signer 0 is invalid because second half exceeds field size" + } + ] +} diff --git a/tests/data/bip327/nonce_gen_vectors.json b/tests/data/bip327/nonce_gen_vectors.json new file mode 100644 index 0000000..ced946f --- /dev/null +++ b/tests/data/bip327/nonce_gen_vectors.json @@ -0,0 +1,44 @@ +{ + "test_cases": [ + { + "rand_": "0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "sk": "0202020202020202020202020202020202020202020202020202020202020202", + "pk": "024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766", + "aggpk": "0707070707070707070707070707070707070707070707070707070707070707", + "msg": "0101010101010101010101010101010101010101010101010101010101010101", + "extra_in": "0808080808080808080808080808080808080808080808080808080808080808", + "expected_secnonce": "B114E502BEAA4E301DD08A50264172C84E41650E6CB726B410C0694D59EFFB6495B5CAF28D045B973D63E3C99A44B807BDE375FD6CB39E46DC4A511708D0E9D2024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766", + "expected_pubnonce": "02F7BE7089E8376EB355272368766B17E88E7DB72047D05E56AA881EA52B3B35DF02C29C8046FDD0DED4C7E55869137200FBDBFE2EB654267B6D7013602CAED3115A" + }, + { + "rand_": "0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "sk": "0202020202020202020202020202020202020202020202020202020202020202", + "pk": "024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766", + "aggpk": "0707070707070707070707070707070707070707070707070707070707070707", + "msg": "", + "extra_in": "0808080808080808080808080808080808080808080808080808080808080808", + "expected_secnonce": "E862B068500320088138468D47E0E6F147E01B6024244AE45EAC40ACE5929B9F0789E051170B9E705D0B9EB49049A323BBBBB206D8E05C19F46C6228742AA7A9024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766", + "expected_pubnonce": "023034FA5E2679F01EE66E12225882A7A48CC66719B1B9D3B6C4DBD743EFEDA2C503F3FD6F01EB3A8E9CB315D73F1F3D287CAFBB44AB321153C6287F407600205109" + }, + { + "rand_": "0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "sk": "0202020202020202020202020202020202020202020202020202020202020202", + "pk": "024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766", + "aggpk": "0707070707070707070707070707070707070707070707070707070707070707", + "msg": "2626262626262626262626262626262626262626262626262626262626262626262626262626", + "extra_in": "0808080808080808080808080808080808080808080808080808080808080808", + "expected_secnonce": "3221975ACBDEA6820EABF02A02B7F27D3A8EF68EE42787B88CBEFD9AA06AF3632EE85B1A61D8EF31126D4663A00DD96E9D1D4959E72D70FE5EBB6E7696EBA66F024D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766", + "expected_pubnonce": "02E5BBC21C69270F59BD634FCBFA281BE9D76601295345112C58954625BF23793A021307511C79F95D38ACACFF1B4DA98228B77E65AA216AD075E9673286EFB4EAF3" + }, + { + "rand_": "0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "sk": null, + "pk": "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "aggpk": null, + "msg": null, + "extra_in": null, + "expected_secnonce": "89BDD787D0284E5E4D5FC572E49E316BAB7E21E3B1830DE37DFE80156FA41A6D0B17AE8D024C53679699A6FD7944D9C4A366B514BAF43088E0708B1023DD289702F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "expected_pubnonce": "02C96E7CB1E8AA5DAC64D872947914198F607D90ECDE5200DE52978AD5DED63C000299EC5117C2D29EDEE8A2092587C3909BE694D5CFF0667D6C02EA4059F7CD9786" + } + ] +} diff --git a/tests/data/bip327/sig_agg_vectors.json b/tests/data/bip327/sig_agg_vectors.json new file mode 100644 index 0000000..519562c --- /dev/null +++ b/tests/data/bip327/sig_agg_vectors.json @@ -0,0 +1,152 @@ +{ + "pubkeys": [ + "03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + "02D2DC6F5DF7C56ACF38C7FA0AE7A759AE30E19B37359DFDE015872324C7EF6E05", + "03C7FB101D97FF930ACD0C6760852EF64E69083DE0B06AC6335724754BB4B0522C", + "02352433B21E7E05D3B452B81CAE566E06D2E003ECE16D1074AABA4289E0E3D581" + ], + "pnonces": [ + "036E5EE6E28824029FEA3E8A9DDD2C8483F5AF98F7177C3AF3CB6F47CAF8D94AE902DBA67E4A1F3680826172DA15AFB1A8CA85C7C5CC88900905C8DC8C328511B53E", + "03E4F798DA48A76EEC1C9CC5AB7A880FFBA201A5F064E627EC9CB0031D1D58FC5103E06180315C5A522B7EC7C08B69DCD721C313C940819296D0A7AB8E8795AC1F00", + "02C0068FD25523A31578B8077F24F78F5BD5F2422AFF47C1FADA0F36B3CEB6C7D202098A55D1736AA5FCC21CF0729CCE852575C06C081125144763C2C4C4A05C09B6", + "031F5C87DCFBFCF330DEE4311D85E8F1DEA01D87A6F1C14CDFC7E4F1D8C441CFA40277BF176E9F747C34F81B0D9F072B1B404A86F402C2D86CF9EA9E9C69876EA3B9", + "023F7042046E0397822C4144A17F8B63D78748696A46C3B9F0A901D296EC3406C302022B0B464292CF9751D699F10980AC764E6F671EFCA15069BBE62B0D1C62522A", + "02D97DDA5988461DF58C5897444F116A7C74E5711BF77A9446E27806563F3B6C47020CBAD9C363A7737F99FA06B6BE093CEAFF5397316C5AC46915C43767AE867C00" + ], + "tweaks": [ + "B511DA492182A91B0FFB9A98020D55F260AE86D7ECBD0399C7383D59A5F2AF7C", + "A815FE049EE3C5AAB66310477FBC8BCCCAC2F3395F59F921C364ACD78A2F48DC", + "75448A87274B056468B977BE06EB1E9F657577B7320B0A3376EA51FD420D18A8" + ], + "psigs": [ + "B15D2CD3C3D22B04DAE438CE653F6B4ECF042F42CFDED7C41B64AAF9B4AF53FB", + "6193D6AC61B354E9105BBDC8937A3454A6D705B6D57322A5A472A02CE99FCB64", + "9A87D3B79EC67228CB97878B76049B15DBD05B8158D17B5B9114D3C226887505", + "66F82EA90923689B855D36C6B7E032FB9970301481B99E01CDB4D6AC7C347A15", + "4F5AEE41510848A6447DCD1BBC78457EF69024944C87F40250D3EF2C25D33EFE", + "DDEF427BBB847CC027BEFF4EDB01038148917832253EBC355FC33F4A8E2FCCE4", + "97B890A26C981DA8102D3BC294159D171D72810FDF7C6A691DEF02F0F7AF3FDC", + "53FA9E08BA5243CBCB0D797C5EE83BC6728E539EB76C2D0BF0F971EE4E909971", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" + ], + "msg": "599C67EA410D005B9DA90817CF03ED3B1C868E4DA4EDF00A5880B0082C237869", + "valid_test_cases": [ + { + "aggnonce": "0341432722C5CD0268D829C702CF0D1CBCE57033EED201FD335191385227C3210C03D377F2D258B64AADC0E16F26462323D701D286046A2EA93365656AFD9875982B", + "nonce_indices": [ + 0, + 1 + ], + "key_indices": [ + 0, + 1 + ], + "tweak_indices": [], + "is_xonly": [], + "psig_indices": [ + 0, + 1 + ], + "expected": "041DA22223CE65C92C9A0D6C2CAC828AAF1EEE56304FEC371DDF91EBB2B9EF0912F1038025857FEDEB3FF696F8B99FA4BB2C5812F6095A2E0004EC99CE18DE1E" + }, + { + "aggnonce": "0224AFD36C902084058B51B5D36676BBA4DC97C775873768E58822F87FE437D792028CB15929099EEE2F5DAE404CD39357591BA32E9AF4E162B8D3E7CB5EFE31CB20", + "nonce_indices": [ + 0, + 2 + ], + "key_indices": [ + 0, + 2 + ], + "tweak_indices": [], + "is_xonly": [], + "psig_indices": [ + 2, + 3 + ], + "expected": "1069B67EC3D2F3C7C08291ACCB17A9C9B8F2819A52EB5DF8726E17E7D6B52E9F01800260A7E9DAC450F4BE522DE4CE12BA91AEAF2B4279219EF74BE1D286ADD9" + }, + { + "aggnonce": "0208C5C438C710F4F96A61E9FF3C37758814B8C3AE12BFEA0ED2C87FF6954FF186020B1816EA104B4FCA2D304D733E0E19CEAD51303FF6420BFD222335CAA402916D", + "nonce_indices": [ + 0, + 3 + ], + "key_indices": [ + 0, + 2 + ], + "tweak_indices": [ + 0 + ], + "is_xonly": [ + false + ], + "psig_indices": [ + 4, + 5 + ], + "expected": "5C558E1DCADE86DA0B2F02626A512E30A22CF5255CAEA7EE32C38E9A71A0E9148BA6C0E6EC7683B64220F0298696F1B878CD47B107B81F7188812D593971E0CC" + }, + { + "aggnonce": "02B5AD07AFCD99B6D92CB433FBD2A28FDEB98EAE2EB09B6014EF0F8197CD58403302E8616910F9293CF692C49F351DB86B25E352901F0E237BAFDA11F1C1CEF29FFD", + "nonce_indices": [ + 0, + 4 + ], + "key_indices": [ + 0, + 3 + ], + "tweak_indices": [ + 0, + 1, + 2 + ], + "is_xonly": [ + true, + false, + true + ], + "psig_indices": [ + 6, + 7 + ], + "expected": "839B08820B681DBA8DAF4CC7B104E8F2638F9388F8D7A555DC17B6E6971D7426CE07BF6AB01F1DB50E4E33719295F4094572B79868E440FB3DEFD3FAC1DB589E" + } + ], + "error_test_cases": [ + { + "aggnonce": "02B5AD07AFCD99B6D92CB433FBD2A28FDEB98EAE2EB09B6014EF0F8197CD58403302E8616910F9293CF692C49F351DB86B25E352901F0E237BAFDA11F1C1CEF29FFD", + "nonce_indices": [ + 0, + 4 + ], + "key_indices": [ + 0, + 3 + ], + "tweak_indices": [ + 0, + 1, + 2 + ], + "is_xonly": [ + true, + false, + true + ], + "psig_indices": [ + 7, + 8 + ], + "error": { + "type": "invalid_contribution", + "signer": 1, + "contrib": "psig" + }, + "comment": "Partial signature is invalid because it exceeds group size" + } + ] +} diff --git a/tests/data/bip327/sign_verify_vectors.json b/tests/data/bip327/sign_verify_vectors.json new file mode 100644 index 0000000..f71c8dd --- /dev/null +++ b/tests/data/bip327/sign_verify_vectors.json @@ -0,0 +1,212 @@ +{ + "sk": "7FB9E0E687ADA1EEBF7ECFE2F21E73EBDB51A7D450948DFE8D76D7F2D1007671", + "pubkeys": [ + "03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA661", + "020000000000000000000000000000000000000000000000000000000000000007" + ], + "secnonces": [ + "508B81A611F100A6B2B6B29656590898AF488BCF2E1F55CF22E5CFB84421FE61FA27FD49B1D50085B481285E1CA205D55C82CC1B31FF5CD54A489829355901F703935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9" + ], + "pnonces": [ + "0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480", + "0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F817980279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", + "032DE2662628C90B03F5E720284EB52FF7D71F4284F627B68A853D78C78E1FFE9303E4C5524E83FFE1493B9077CF1CA6BEB2090C93D930321071AD40B2F44E599046", + "0237C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0387BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480", + "0200000000000000000000000000000000000000000000000000000000000000090287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480" + ], + "aggnonces": [ + "028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61037496A3CC86926D452CAFCFD55D25972CA1675D549310DE296BFF42F72EEEA8C9", + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "048465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61037496A3CC86926D452CAFCFD55D25972CA1675D549310DE296BFF42F72EEEA8C9", + "028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61020000000000000000000000000000000000000000000000000000000000000009", + "028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD6102FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30" + ], + "msgs": [ + "F95466D086770E689964664219266FE5ED215C92AE20BAB5C9D79ADDDDF3C0CF", + "", + "2626262626262626262626262626262626262626262626262626262626262626262626262626" + ], + "valid_test_cases": [ + { + "key_indices": [0, 1, 2], + "nonce_indices": [0, 1, 2], + "aggnonce_index": 0, + "msg_index": 0, + "signer_index": 0, + "expected": "012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB" + }, + { + "key_indices": [1, 0, 2], + "nonce_indices": [1, 0, 2], + "aggnonce_index": 0, + "msg_index": 0, + "signer_index": 1, + "expected": "9FF2F7AAA856150CC8819254218D3ADEEB0535269051897724F9DB3789513A52" + }, + { + "key_indices": [1, 2, 0], + "nonce_indices": [1, 2, 0], + "aggnonce_index": 0, + "msg_index": 0, + "signer_index": 2, + "expected": "FA23C359F6FAC4E7796BB93BC9F0532A95468C539BA20FF86D7C76ED92227900" + }, + { + "key_indices": [0, 1], + "nonce_indices": [0, 3], + "aggnonce_index": 1, + "msg_index": 0, + "signer_index": 0, + "expected": "AE386064B26105404798F75DE2EB9AF5EDA5387B064B83D049CB7C5E08879531", + "comment": "Both halves of aggregate nonce correspond to point at infinity" + }, + { + "key_indices": [0, 1, 2], + "nonce_indices": [0, 1, 2], + "aggnonce_index": 0, + "msg_index": 1, + "signer_index": 0, + "expected": "D7D63FFD644CCDA4E62BC2BC0B1D02DD32A1DC3030E155195810231D1037D82D", + "comment": "Empty message" + }, + { + "key_indices": [0, 1, 2], + "nonce_indices": [0, 1, 2], + "aggnonce_index": 0, + "msg_index": 2, + "signer_index": 0, + "expected": "E184351828DA5094A97C79CABDAAA0BFB87608C32E8829A4DF5340A6F243B78C", + "comment": "38-byte message" + } + ], + "sign_error_test_cases": [ + { + "key_indices": [1, 2], + "aggnonce_index": 0, + "msg_index": 0, + "secnonce_index": 0, + "error": { + "type": "value", + "message": "The signer's pubkey must be included in the list of pubkeys." + }, + "comment": "The signers pubkey is not in the list of pubkeys. This test case is optional: it can be skipped by implementations that do not check that the signer's pubkey is included in the list of pubkeys." + }, + { + "key_indices": [1, 0, 3], + "aggnonce_index": 0, + "msg_index": 0, + "secnonce_index": 0, + "error": { + "type": "invalid_contribution", + "signer": 2, + "contrib": "pubkey" + }, + "comment": "Signer 2 provided an invalid public key" + }, + { + "key_indices": [1, 2, 0], + "aggnonce_index": 2, + "msg_index": 0, + "secnonce_index": 0, + "error": { + "type": "invalid_contribution", + "signer": null, + "contrib": "aggnonce" + }, + "comment": "Aggregate nonce is invalid due wrong tag, 0x04, in the first half" + }, + { + "key_indices": [1, 2, 0], + "aggnonce_index": 3, + "msg_index": 0, + "secnonce_index": 0, + "error": { + "type": "invalid_contribution", + "signer": null, + "contrib": "aggnonce" + }, + "comment": "Aggregate nonce is invalid because the second half does not correspond to an X coordinate" + }, + { + "key_indices": [1, 2, 0], + "aggnonce_index": 4, + "msg_index": 0, + "secnonce_index": 0, + "error": { + "type": "invalid_contribution", + "signer": null, + "contrib": "aggnonce" + }, + "comment": "Aggregate nonce is invalid because second half exceeds field size" + }, + { + "key_indices": [0, 1, 2], + "aggnonce_index": 0, + "msg_index": 0, + "signer_index": 0, + "secnonce_index": 1, + "error": { + "type": "value", + "message": "first secnonce value is out of range." + }, + "comment": "Secnonce is invalid which may indicate nonce reuse" + } + ], + "verify_fail_test_cases": [ + { + "sig": "FED54434AD4CFE953FC527DC6A5E5BE8F6234907B7C187559557CE87A0541C46", + "key_indices": [0, 1, 2], + "nonce_indices": [0, 1, 2], + "msg_index": 0, + "signer_index": 0, + "comment": "Wrong signature (which is equal to the negation of valid signature)" + }, + { + "sig": "012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB", + "key_indices": [0, 1, 2], + "nonce_indices": [0, 1, 2], + "msg_index": 0, + "signer_index": 1, + "comment": "Wrong signer" + }, + { + "sig": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", + "key_indices": [0, 1, 2], + "nonce_indices": [0, 1, 2], + "msg_index": 0, + "signer_index": 0, + "comment": "Signature exceeds group size" + } + ], + "verify_error_test_cases": [ + { + "sig": "012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB", + "key_indices": [0, 1, 2], + "nonce_indices": [4, 1, 2], + "msg_index": 0, + "signer_index": 0, + "error": { + "type": "invalid_contribution", + "signer": 0, + "contrib": "pubnonce" + }, + "comment": "Invalid pubnonce" + }, + { + "sig": "012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB", + "key_indices": [3, 1, 2], + "nonce_indices": [0, 1, 2], + "msg_index": 0, + "signer_index": 0, + "error": { + "type": "invalid_contribution", + "signer": 0, + "contrib": "pubkey" + }, + "comment": "Invalid pubkey" + } + ] +} diff --git a/tests/data/bip327/tweak_vectors.json b/tests/data/bip327/tweak_vectors.json new file mode 100644 index 0000000..d0a7cfe --- /dev/null +++ b/tests/data/bip327/tweak_vectors.json @@ -0,0 +1,84 @@ +{ + "sk": "7FB9E0E687ADA1EEBF7ECFE2F21E73EBDB51A7D450948DFE8D76D7F2D1007671", + "pubkeys": [ + "03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659" + ], + "secnonce": "508B81A611F100A6B2B6B29656590898AF488BCF2E1F55CF22E5CFB84421FE61FA27FD49B1D50085B481285E1CA205D55C82CC1B31FF5CD54A489829355901F703935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + "pnonces": [ + "0337C87821AFD50A8644D820A8F3E02E499C931865C2360FB43D0A0D20DAFE07EA0287BF891D2A6DEAEBADC909352AA9405D1428C15F4B75F04DAE642A95C2548480", + "0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F817980279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", + "032DE2662628C90B03F5E720284EB52FF7D71F4284F627B68A853D78C78E1FFE9303E4C5524E83FFE1493B9077CF1CA6BEB2090C93D930321071AD40B2F44E599046" + ], + "aggnonce": "028465FCF0BBDBCF443AABCCE533D42B4B5A10966AC09A49655E8C42DAAB8FCD61037496A3CC86926D452CAFCFD55D25972CA1675D549310DE296BFF42F72EEEA8C9", + "tweaks": [ + "E8F791FF9225A2AF0102AFFF4A9A723D9612A682A25EBE79802B263CDFCD83BB", + "AE2EA797CC0FE72AC5B97B97F3C6957D7E4199A167A58EB08BCAFFDA70AC0455", + "F52ECBC565B3D8BEA2DFD5B75A4F457E54369809322E4120831626F290FA87E0", + "1969AD73CC177FA0B4FCED6DF1F7BF9907E665FDE9BA196A74FED0A3CF5AEF9D", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" + ], + "msg": "F95466D086770E689964664219266FE5ED215C92AE20BAB5C9D79ADDDDF3C0CF", + "valid_test_cases": [ + { + "key_indices": [1, 2, 0], + "nonce_indices": [1, 2, 0], + "tweak_indices": [0], + "is_xonly": [true], + "signer_index": 2, + "expected": "E28A5C66E61E178C2BA19DB77B6CF9F7E2F0F56C17918CD13135E60CC848FE91", + "comment": "A single x-only tweak" + }, + { + "key_indices": [1, 2, 0], + "nonce_indices": [1, 2, 0], + "tweak_indices": [0], + "is_xonly": [false], + "signer_index": 2, + "expected": "38B0767798252F21BF5702C48028B095428320F73A4B14DB1E25DE58543D2D2D", + "comment": "A single plain tweak" + }, + { + "key_indices": [1, 2, 0], + "nonce_indices": [1, 2, 0], + "tweak_indices": [0, 1], + "is_xonly": [false, true], + "signer_index": 2, + "expected": "408A0A21C4A0F5DACAF9646AD6EB6FECD7F7A11F03ED1F48DFFF2185BC2C2408", + "comment": "A plain tweak followed by an x-only tweak" + }, + { + "key_indices": [1, 2, 0], + "nonce_indices": [1, 2, 0], + "tweak_indices": [0, 1, 2, 3], + "is_xonly": [false, false, true, true], + "signer_index": 2, + "expected": "45ABD206E61E3DF2EC9E264A6FEC8292141A633C28586388235541F9ADE75435", + "comment": "Four tweaks: plain, plain, x-only, x-only." + }, + { + "key_indices": [1, 2, 0], + "nonce_indices": [1, 2, 0], + "tweak_indices": [0, 1, 2, 3], + "is_xonly": [true, false, true, false], + "signer_index": 2, + "expected": "B255FDCAC27B40C7CE7848E2D3B7BF5EA0ED756DA81565AC804CCCA3E1D5D239", + "comment": "Four tweaks: x-only, plain, x-only, plain. If an implementation prohibits applying plain tweaks after x-only tweaks, it can skip this test vector or return an error." + } + ], + "error_test_cases": [ + { + "key_indices": [1, 2, 0], + "nonce_indices": [1, 2, 0], + "tweak_indices": [4], + "is_xonly": [false], + "signer_index": 2, + "error": { + "type": "value", + "message": "The tweak must be less than n." + }, + "comment": "Tweak is invalid because it exceeds group size" + } + ] +} diff --git a/tests/test_musig.py b/tests/test_musig.py new file mode 100644 index 0000000..5c2a3f8 --- /dev/null +++ b/tests/test_musig.py @@ -0,0 +1,656 @@ +import copy +from concurrent.futures import ThreadPoolExecutor +from ctypes import POINTER, addressof, c_char, c_size_t, c_void_p, memmove +import json +from pathlib import Path +from threading import Event +import unittest +from unittest import mock + +from electrum_ecc import ECPrivkey, ECPubkey +from electrum_ecc import ecc_fast, musig +from electrum_ecc.util import sha256 + + +# Canonical bitcoin/bips vectors pinned to this upstream commit: +# 9297c12729670d09f9149ec6d8bad967d8161bfe +_VECTOR_DIR = Path(__file__).with_name("data") / "bip327" +_GROUP_ORDER = bytes.fromhex( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" +) + + +def _vectors(name: str) -> dict: + with (_VECTOR_DIR / name).open(encoding="ascii") as f: + return json.load(f) + + +def _keypair(seed: bytes) -> tuple[ECPrivkey, bytes]: + private_key = ECPrivkey(sha256(seed)) + return private_key, private_key.get_public_key_bytes(compressed=True) + + +def _secnonce_from_vector( + *, secnonce97: bytes, seckey: bytes, pubkey: bytes, pubnonce: musig.PubNonce +) -> musig.SecNonce: + """Load a public BIP327 test nonce into v0.7.1's opaque nonce object.""" + if len(secnonce97) != 97 or secnonce97[64:] != pubkey: + raise ValueError("invalid BIP327 secnonce fixture") + secnonce, _ = musig.nonce_gen(pubkey=pubkey, seckey=seckey) + # v0.7.1 stores a four-byte magic followed by the two nonce scalars. + memmove(addressof(secnonce._buf) + 4, secnonce97, 64) + secnonce._pubnonce = pubnonce + return secnonce + + +def _signing_state(n_signers: int = 2, *, tweak: bool = False): + private_keys, pubkeys = zip( + *[_keypair(f"signer-{i}".encode()) for i in range(n_signers)] + ) + msg32 = sha256(b"musig test message") + cache = musig.KeyAggCache.from_pubkeys(pubkeys) + if tweak: + cache.apply_xonly_tweak(sha256(b"taproot tweak")) + nonce_pairs = [ + musig.nonce_gen( + pubkey=pubkey, + seckey=private_key.get_secret_bytes(), + msg32=msg32, + keyagg_cache=cache, + ) + for private_key, pubkey in zip(private_keys, pubkeys) + ] + secnonces, pubnonces = zip(*nonce_pairs) + session = musig.nonce_process( + aggnonce=musig.nonce_agg(pubnonces), + msg32=msg32, + keyagg_cache=cache, + ) + return private_keys, pubkeys, msg32, cache, secnonces, pubnonces, session + + +def _apply_vector_tweaks(cache, data, case) -> None: + for index, is_xonly in zip(case["tweak_indices"], case["is_xonly"]): + tweak = bytes.fromhex(data["tweaks"][index]) + if is_xonly: + cache.apply_xonly_tweak(tweak) + else: + cache.apply_plain_tweak(tweak) + + +@unittest.skipUnless( + ecc_fast.HAS_MUSIG, "libsecp256k1 built without --enable-module-musig" +) +class TestMuSig(unittest.TestCase): + def test_ctypes_pointer_array_abi(self): + char_ptr = POINTER(c_char) + char_ptr_ptr = POINTER(char_ptr) + expected = { + "secp256k1_musig_pubkey_agg": [ + c_void_p, + char_ptr, + char_ptr, + char_ptr_ptr, + c_size_t, + ], + "secp256k1_musig_nonce_agg": [ + c_void_p, + char_ptr, + char_ptr_ptr, + c_size_t, + ], + "secp256k1_musig_partial_sig_agg": [ + c_void_p, + char_ptr, + char_ptr, + char_ptr_ptr, + c_size_t, + ], + } + for name, argtypes in expected.items(): + with self.subTest(name=name): + self.assertEqual(argtypes, getattr(musig._libsecp256k1, name).argtypes) + + def _round_trip(self, n_signers: int, *, tweak: bool = False) -> None: + state = _signing_state(n_signers, tweak=tweak) + private_keys, pubkeys, msg32, cache, secnonces, pubnonces, session = state + partial_sigs = [ + musig.partial_sign( + secnonce=secnonce, + seckey=private_key.get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + for private_key, secnonce in zip(private_keys, secnonces) + ] + for partial_sig, pubnonce, pubkey in zip(partial_sigs, pubnonces, pubkeys): + self.assertTrue( + musig.partial_sig_verify( + partial_sig=partial_sig, + pubnonce=pubnonce, + pubkey=pubkey, + keyagg_cache=cache, + session=session, + ) + ) + signature = musig.partial_sig_agg(session=session, partial_sigs=partial_sigs) + aggregate = ECPubkey(b"\x02" + cache.aggregate_xonly_pubkey()) + self.assertTrue(aggregate.schnorr_verify(signature, msg32)) + + def test_two_and_three_signer_round_trips(self): + self._round_trip(2) + self._round_trip(3) + + def test_taproot_tweak_round_trips(self): + self._round_trip(2, tweak=True) + self._round_trip(3, tweak=True) + + def test_tweak_result_preserves_full_pubkey_parity(self): + _, pubkeys, _, _, _, _, _ = _signing_state() + tweak = bytes.fromhex( + "5A25B7645A273D89B8DAB0AFAFC5338C3DC5CF1A20AD113806C11897A9CEF074" + ) + for apply in ("apply_plain_tweak", "apply_xonly_tweak"): + with self.subTest(apply=apply): + cache = musig.KeyAggCache.from_pubkeys(pubkeys) + aggregate = getattr(cache, apply)(tweak) + self.assertEqual(3, aggregate[0]) + self.assertEqual(aggregate[1:], cache.aggregate_xonly_pubkey()) + + def test_secnonce_reuse_and_copy_are_rejected(self): + state = _signing_state() + private_keys, _, _, cache, secnonces, _, session = state + with self.assertRaises(TypeError): + copy.copy(secnonces[0]) + with self.assertRaises(TypeError): + copy.deepcopy(secnonces[0]) + musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + with self.assertRaises(ValueError): + musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + + def test_concurrent_secnonce_consumption_is_atomic(self): + state = _signing_state() + private_keys, _, _, cache, secnonces, _, session = state + original_sign = musig._libsecp256k1.secp256k1_musig_partial_sign + entered_sign = Event() + finish_sign = Event() + + def blocked_sign(*args): + entered_sign.set() + if not finish_sign.wait(5): + raise TimeoutError("timed out waiting to finish partial signing") + return original_sign(*args) + + def sign(): + try: + return musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + except Exception as e: + return e + + with mock.patch.object( + musig._libsecp256k1, + "secp256k1_musig_partial_sign", + side_effect=blocked_sign, + ): + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(sign) + self.assertTrue(entered_sign.wait(5)) + second = executor.submit(sign) + try: + second_result = second.result(timeout=5) + self.assertIs(type(second_result), ValueError) + self.assertNotEqual(bytes(132), bytes(secnonces[0]._buf)) + finally: + finish_sign.set() + first_result = first.result(timeout=5) + results = [first_result, second_result] + self.assertEqual(1, sum(type(result) is musig.PartialSig for result in results)) + self.assertEqual(1, sum(type(result) is ValueError for result in results)) + + def test_signing_failure_consumes_and_zeroes_secnonce(self): + state = _signing_state() + private_keys, _, _, cache, secnonces, _, session = state + function = "secp256k1_musig_partial_sign" + with mock.patch.object(musig._libsecp256k1, function, return_value=0): + with self.assertRaisesRegex(ValueError, "musig_partial_sign failed"): + musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + self.assertEqual(bytes(132), bytes(secnonces[0]._buf)) + with self.assertRaises(ValueError): + musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + + def test_mismatched_key_does_not_consume_before_signing(self): + state = _signing_state() + private_keys, _, _, cache, secnonces, _, session = state + with self.assertRaises(ValueError): + musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[1].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + self.assertIsInstance( + musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ), + musig.PartialSig, + ) + + def test_strict_opaque_argument_types(self): + state = _signing_state() + private_keys, pubkeys, msg32, cache, secnonces, pubnonces, session = state + aggnonce = musig.nonce_agg(pubnonces) + partial_sig = musig.partial_sign( + secnonce=secnonces[1], + seckey=private_keys[1].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + calls = [ + lambda: musig.nonce_gen(pubkey=pubkeys[0], keyagg_cache=pubnonces[0]), + lambda: musig.nonce_agg([aggnonce]), + lambda: musig.nonce_process( + aggnonce=pubnonces[0], msg32=msg32, keyagg_cache=cache + ), + lambda: musig.nonce_process( + aggnonce=aggnonce, msg32=msg32, keyagg_cache=session + ), + lambda: musig.partial_sign( + secnonce=pubnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ), + lambda: musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=session, + session=session, + ), + lambda: musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=cache, + ), + lambda: musig.partial_sig_verify( + partial_sig=pubnonces[0], + pubnonce=pubnonces[0], + pubkey=pubkeys[0], + keyagg_cache=cache, + session=session, + ), + lambda: musig.partial_sig_verify( + partial_sig=partial_sig, + pubnonce=partial_sig, + pubkey=pubkeys[0], + keyagg_cache=cache, + session=session, + ), + lambda: musig.partial_sig_verify( + partial_sig=partial_sig, + pubnonce=pubnonces[1], + pubkey=pubkeys[1], + keyagg_cache=session, + session=session, + ), + lambda: musig.partial_sig_verify( + partial_sig=partial_sig, + pubnonce=pubnonces[1], + pubkey=pubkeys[1], + keyagg_cache=cache, + session=cache, + ), + lambda: musig.partial_sig_agg(session=cache, partial_sigs=[partial_sig]), + lambda: musig.partial_sig_agg(session=session, partial_sigs=[pubnonces[0]]), + ] + for call in calls: + with self.subTest(call=call): + with self.assertRaises(TypeError): + call() + + def test_input_validation_and_invalid_scalars(self): + _, pubkey = _keypair(b"validation") + cache = musig.KeyAggCache.from_pubkeys([pubkey]) + invalid_calls = [ + lambda: musig.PubNonce(None), + lambda: musig.AggNonce(None), + lambda: musig.PartialSig(None), + lambda: musig.KeyAggCache.from_pubkeys(None), + lambda: musig.nonce_gen(pubkey=pubkey, msg32=object()), + ] + for call in invalid_calls: + with self.subTest(call=call): + with self.assertRaises(TypeError): + call() + with self.assertRaises(ValueError): + musig.nonce_gen(pubkey=pubkey, seckey=bytes(32)) + with self.assertRaises(ValueError): + musig.PartialSig(_GROUP_ORDER) + with self.assertRaises(ValueError): + musig.PubNonce(bytes(65)) + with self.assertRaises(ValueError): + musig.AggNonce(bytes(65)) + with self.assertRaises(ValueError): + musig.PartialSig(bytes(31)) + with self.assertRaises(ValueError): + cache.apply_plain_tweak(_GROUP_ORDER) + with self.assertRaises(ValueError): + cache.apply_xonly_tweak(bytes(31)) + + def test_session_randomness_is_consumed_on_failure(self): + _, pubkey = _keypair(b"randomness") + session_random = bytearray(b"\x01" * 32) + with self.assertRaises(ValueError): + musig.nonce_gen( + pubkey=pubkey, + seckey=bytes.fromhex("01" * 32), + session_secrand32=session_random, + ) + self.assertEqual(bytearray(32), session_random) + session_random = bytearray(b"\x02" * 32) + with self.assertRaises(TypeError): + musig.nonce_gen( + pubkey=pubkey, + msg32=object(), + session_secrand32=session_random, + ) + self.assertEqual(bytearray(32), session_random) + with self.assertRaises(TypeError): + musig.nonce_gen(pubkey=pubkey, session_secrand32=bytes(32)) + + def test_nonce_generation_failure_wipes_generated_secnonce(self): + private_key, pubkey = _keypair(b"nonce-cleanup") + created = [] + wiped = [] + original_init = musig.SecNonce.__init__ + original_wipe = musig.SecNonce._wipe + + def track_init(secnonce, *args, **kwargs): + original_init(secnonce, *args, **kwargs) + created.append(secnonce) + + def track_wipe(secnonce): + original_wipe(secnonce) + wiped.append(secnonce) + + # Track the instance created by this nonce_gen call instead of + # counting wipes globally: unrelated SecNonce objects from earlier + # tests can be garbage collected during the patch window, and their + # __del__ also invokes the patched _wipe. + with mock.patch.object(musig.SecNonce, "__init__", track_init): + with mock.patch.object(musig.SecNonce, "_wipe", track_wipe): + with mock.patch.object( + musig._libsecp256k1, + "secp256k1_musig_pubnonce_serialize", + return_value=0, + ): + with self.assertRaisesRegex( + RuntimeError, "pubnonce serialization failed" + ): + musig.nonce_gen( + pubkey=pubkey, + seckey=private_key.get_secret_bytes(), + ) + self.assertEqual(1, len(created)) + secnonce = created[0] + # `created` holds a strong reference, so this wipe cannot come + # from __del__: nonce_gen must have wiped it on failure. + self.assertTrue(any(entry is secnonce for entry in wiped)) + self.assertEqual(bytes(132), bytes(secnonce._buf)) + + def test_tampered_partial_signature_fails_verification(self): + state = _signing_state() + private_keys, pubkeys, _, cache, secnonces, pubnonces, session = state + partial_sig = musig.partial_sign( + secnonce=secnonces[0], + seckey=private_keys[0].get_secret_bytes(), + keyagg_cache=cache, + session=session, + ) + self.assertFalse( + musig.partial_sig_verify( + partial_sig=partial_sig, + pubnonce=pubnonces[1], + pubkey=pubkeys[1], + keyagg_cache=cache, + session=session, + ) + ) + + def test_opaque_types_cannot_be_constructed_directly(self): + with self.assertRaises(TypeError): + musig.KeyAggCache(bytes(197), bytes(32)) + with self.assertRaises(TypeError): + musig.SecNonce(bytes(64)) + with self.assertRaises(TypeError): + musig.Session(bytes(133)) + + def test_bip327_key_aggregation_vectors(self): + data = _vectors("key_agg_vectors.json") + pubkeys = [bytes.fromhex(value) for value in data["pubkeys"]] + for case in data["valid_test_cases"]: + with self.subTest(case=case): + cache = musig.KeyAggCache.from_pubkeys( + [pubkeys[index] for index in case["key_indices"]] + ) + self.assertEqual( + bytes.fromhex(case["expected"]), cache.aggregate_xonly_pubkey() + ) + for case in data["error_test_cases"]: + with self.subTest(case=case): + with self.assertRaises(ValueError): + cache = musig.KeyAggCache.from_pubkeys( + [pubkeys[index] for index in case["key_indices"]] + ) + _apply_vector_tweaks(cache, data, case) + + def test_bip327_nonce_generation_public_vector(self): + data = _vectors("nonce_gen_vectors.json") + compatible = [ + case + for case in data["test_cases"] + if case["aggpk"] is None and (case["msg"] is None or len(case["msg"]) == 64) + ] + self.assertEqual(1, len(compatible)) + for case in compatible: + session_random = bytearray.fromhex(case["rand_"]) + _, pubnonce = musig.nonce_gen( + pubkey=bytes.fromhex(case["pk"]), + seckey=None if case["sk"] is None else bytes.fromhex(case["sk"]), + msg32=None if case["msg"] is None else bytes.fromhex(case["msg"]), + extra_input32=( + None + if case["extra_in"] is None + else bytes.fromhex(case["extra_in"]) + ), + session_secrand32=session_random, + ) + self.assertEqual( + bytes.fromhex(case["expected_pubnonce"]), pubnonce.to_bytes() + ) + + def test_bip327_nonce_aggregation_vectors(self): + data = _vectors("nonce_agg_vectors.json") + pubnonce_wires = [bytes.fromhex(value) for value in data["pnonces"]] + for case in data["valid_test_cases"]: + with self.subTest(case=case): + aggregate = musig.nonce_agg( + [musig.PubNonce(pubnonce_wires[i]) for i in case["pnonce_indices"]] + ) + self.assertEqual(bytes.fromhex(case["expected"]), aggregate.to_bytes()) + for case in data["error_test_cases"]: + with self.subTest(case=case): + with self.assertRaises(ValueError): + [musig.PubNonce(pubnonce_wires[i]) for i in case["pnonce_indices"]] + + def test_bip327_sign_verify_public_vectors(self): + data = _vectors("sign_verify_vectors.json") + pubkeys = [bytes.fromhex(value) for value in data["pubkeys"]] + pubnonces = [bytes.fromhex(value) for value in data["pnonces"]] + aggnonces = [bytes.fromhex(value) for value in data["aggnonces"]] + seckey = bytes.fromhex(data["sk"]) + secnonce97 = bytes.fromhex(data["secnonces"][0]) + msg32 = bytes.fromhex(data["msgs"][0]) + cases = [case for case in data["valid_test_cases"] if case["msg_index"] == 0] + for case in cases: + with self.subTest(case=case): + cache = musig.KeyAggCache.from_pubkeys( + [pubkeys[i] for i in case["key_indices"]] + ) + nonces = [musig.PubNonce(pubnonces[i]) for i in case["nonce_indices"]] + session = musig.nonce_process( + aggnonce=musig.AggNonce(aggnonces[case["aggnonce_index"]]), + msg32=msg32, + keyagg_cache=cache, + ) + signer = case["signer_index"] + signer_pubkey = pubkeys[case["key_indices"][signer]] + secnonce = _secnonce_from_vector( + secnonce97=secnonce97, + seckey=seckey, + pubkey=signer_pubkey, + pubnonce=nonces[signer], + ) + partial_sig = musig.partial_sign( + secnonce=secnonce, + seckey=seckey, + keyagg_cache=cache, + session=session, + ) + self.assertEqual( + bytes.fromhex(case["expected"]), partial_sig.to_bytes() + ) + for case in data["verify_fail_test_cases"]: + if case["msg_index"] != 0: + continue + with self.subTest(case=case): + cache = musig.KeyAggCache.from_pubkeys( + [pubkeys[i] for i in case["key_indices"]] + ) + nonces = [musig.PubNonce(pubnonces[i]) for i in case["nonce_indices"]] + session = musig.nonce_process( + aggnonce=musig.nonce_agg(nonces), + msg32=msg32, + keyagg_cache=cache, + ) + try: + partial_sig = musig.PartialSig(bytes.fromhex(case["sig"])) + except ValueError: + self.assertIn("exceeds group size", case["comment"]) + continue + signer = case["signer_index"] + self.assertFalse( + musig.partial_sig_verify( + partial_sig=partial_sig, + pubnonce=nonces[signer], + pubkey=pubkeys[case["key_indices"][signer]], + keyagg_cache=cache, + session=session, + ) + ) + + def test_bip327_tweak_verify_vectors(self): + data = _vectors("tweak_vectors.json") + pubkeys = [bytes.fromhex(value) for value in data["pubkeys"]] + pubnonces = [bytes.fromhex(value) for value in data["pnonces"]] + seckey = bytes.fromhex(data["sk"]) + secnonce97 = bytes.fromhex(data["secnonce"]) + for case in data["valid_test_cases"]: + with self.subTest(case=case): + cache = musig.KeyAggCache.from_pubkeys( + [pubkeys[i] for i in case["key_indices"]] + ) + _apply_vector_tweaks(cache, data, case) + session = musig.nonce_process( + aggnonce=musig.AggNonce(bytes.fromhex(data["aggnonce"])), + msg32=bytes.fromhex(data["msg"]), + keyagg_cache=cache, + ) + signer = case["signer_index"] + signer_pubkey = pubkeys[case["key_indices"][signer]] + pubnonce = musig.PubNonce(pubnonces[case["nonce_indices"][signer]]) + secnonce = _secnonce_from_vector( + secnonce97=secnonce97, + seckey=seckey, + pubkey=signer_pubkey, + pubnonce=pubnonce, + ) + partial_sig = musig.partial_sign( + secnonce=secnonce, + seckey=seckey, + keyagg_cache=cache, + session=session, + ) + self.assertEqual( + bytes.fromhex(case["expected"]), partial_sig.to_bytes() + ) + for case in data["error_test_cases"]: + with self.assertRaises(ValueError): + cache = musig.KeyAggCache.from_pubkeys( + [pubkeys[i] for i in case["key_indices"]] + ) + _apply_vector_tweaks(cache, data, case) + + def test_bip327_signature_aggregation_vectors(self): + data = _vectors("sig_agg_vectors.json") + pubkeys = [bytes.fromhex(value) for value in data["pubkeys"]] + partial_sigs = [bytes.fromhex(value) for value in data["psigs"]] + msg32 = bytes.fromhex(data["msg"]) + for case in data["valid_test_cases"]: + with self.subTest(case=case): + cache = musig.KeyAggCache.from_pubkeys( + [pubkeys[i] for i in case["key_indices"]] + ) + _apply_vector_tweaks(cache, data, case) + session = musig.nonce_process( + aggnonce=musig.AggNonce(bytes.fromhex(case["aggnonce"])), + msg32=msg32, + keyagg_cache=cache, + ) + signature = musig.partial_sig_agg( + session=session, + partial_sigs=[ + musig.PartialSig(partial_sigs[i]) for i in case["psig_indices"] + ], + ) + self.assertEqual(bytes.fromhex(case["expected"]), signature) + aggregate = ECPubkey(b"\x02" + cache.aggregate_xonly_pubkey()) + self.assertTrue(aggregate.schnorr_verify(signature, msg32)) + for case in data["error_test_cases"]: + with self.assertRaises(ValueError): + [musig.PartialSig(partial_sigs[i]) for i in case["psig_indices"]] + + +class TestMuSigAvailability(unittest.TestCase): + def test_missing_module_fails_cleanly(self): + with mock.patch.object(ecc_fast, "HAS_MUSIG", False): + with self.assertRaises(ecc_fast.LibModuleMissing): + musig.KeyAggCache.from_pubkeys([bytes(33)])