Source code for btclib_secp256k1.ssa

# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.

"""Variant of Elliptic Curve Schnorr Signature Algorithm (ECSSA).

According to BIP340-Schnorr:
https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
"""

from __future__ import annotations

from types import TracebackType
from typing import Self, overload

from . import BytesLike, CData, MutableBytesLike, ffi, keys, lib, xonly
from ._scalar import entropy, octets, optional_entropy, scalar
from ._secret import keypair, take, wipe
from .context import ctx

__all__ = [
    "EXTRAPARAMS_MAGIC",
    "Signer",
    "nonce_bip340",
    "sign",
    "sign_custom",
    "verify",
]

# SECP256K1_SCHNORRSIG_EXTRAPARAMS_MAGIC: the libsecp256k1 macros do not
# survive the preprocessing of the headers into cffi definitions
EXTRAPARAMS_MAGIC = b"\xda\x6f\xb3\x8c"

# the width of a BIP340 signature: 64 bytes, which is what `_sign32` and
# `_sign_custom` write and what `_verify_` and `verify` read back, so the
# one statement of it answers for the argument checks as well as for the
# buffer, whose type is built from it. It saves some hundredths of a
# microsecond on calls of 30.20 and 30.54, where timing each of those
# against itself moves 0.08 and 0.003: nothing that measurement resolves,
# and not the reason either -- `xonly.py` says the reason
_SIGNATURE_SIZE = 64
_SIGNATURE_BUFFER_TYPE = ffi.typeof(f"char[{_SIGNATURE_SIZE}]")

# the algo libsecp256k1 hands its nonce function on every schnorrsig
# signing path, and what makes the derivation BIP340's rather than
# another protocol's
_NONCE_ALGO = b"BIP0340/nonce"


@overload
def nonce_bip340(
    msg_bytes: BytesLike,
    prvkey: BytesLike | int,
    aux_rand32: BytesLike | None = None,
) -> bytes: ...
@overload
def nonce_bip340(
    msg_bytes: BytesLike,
    prvkey: BytesLike | int,
    aux_rand32: BytesLike | None = None,
    *,
    into: MutableBytesLike,
) -> None: ...
@overload
def nonce_bip340(
    msg_bytes: BytesLike,
    prvkey: BytesLike | int,
    aux_rand32: BytesLike | None = None,
    *,
    into: MutableBytesLike | None,
) -> bytes | None: ...
[docs] def nonce_bip340( msg_bytes: BytesLike, prvkey: BytesLike | int, aux_rand32: BytesLike | None = None, *, into: MutableBytesLike | None = None, ) -> bytes | None: """Return the BIP340 nonce `sign` derives for a message and a key. libsecp256k1 exports its nonce function as a callable pointer, and this calls through it with what `secp256k1_schnorrsig_sign32` passes: the message, the key the signature is made with, the x-only public key, the `BIP0340/nonce` tag and the auxiliary randomness. So what comes back is the `k` of the signature `sign` makes of the same arguments -- the first 32 octets of that signature are the x of `k` times the generator, which is what `tests/nonces_test.py` holds it to. The key BIP340 signs with is the one of the even-y point, so a private key whose point has odd y enters the derivation negated, and the x-only public key with it. Both are derived here rather than asked of the caller: that is what makes the answer the nonce of the signature rather than of a key nobody signs with. It is here for the reason `dsa.nonce_rfc6979` is: a python implementation of BIP340's nonce derivation has published vectors and no oracle, and the aux is where implementations diverge. **The nonce is the secret the signature is built on**, and reading it into python takes it out of constant-time code: see `dsa.nonce_rfc6979`, which says what that means. Args: msg_bytes: the message, of any length. prvkey: the private key, 32 bytes or an int below 2**256. aux_rand32: the 32 bytes of auxiliary randomness, or None for the derivation libsecp256k1 makes without any -- which answers what 32 zero octets answer, the tagged hash of those zeros being what it substitutes. `sign` given None generates 32 fresh octets instead of passing none, so what reproduces a signature's nonce is the aux that signature was made with. into: a writable 32-byte buffer to receive the result, instead of the `bytes` this otherwise returns. See `_secret.take` and SECURITY.md for what that does and does not buy. Returns: The 32-byte nonce -- or None where `into` was given and holds it. Raises: TypeError: if `into` is not a writable bytearray or memoryview of octets. ValueError: if aux_rand32 is given and is not 32 bytes, or if the private key is not 32 bytes, does not fit in them, or is not in [1, n-1]. RuntimeError: if libsecp256k1 fails to derive one, which the tag above keeps it from doing. Example: >>> from btclib_secp256k1 import ssa >>> nonce = ssa.nonce_bip340(bytes(32), 1, bytes(32)) >>> len(nonce) 32 """ msg_bytes = octets(msg_bytes, "message") xonly_pubkey, parity = xonly.from_prvkey(prvkey) aux = optional_entropy(aux_rand32) nonce = ffi.new("unsigned char[32]") # the key that signs, which BIP340 negates where the point has odd y: # negated into a buffer of ours, wiped below, so that no `bytes` of # n - d is made negated = ffi.new("unsigned char[32]") try: if parity: keys.prvkey_negate(prvkey, into=ffi.buffer(negated)) prvkey_bytes = negated else: prvkey_bytes = scalar(prvkey, "private key") derived = lib.secp256k1_nonce_function_bip340( nonce, msg_bytes, len(msg_bytes), prvkey_bytes, xonly_pubkey, _NONCE_ALGO, len(_NONCE_ALGO), aux, ) finally: wipe(negated) if not derived: raise RuntimeError("BIP340 nonce derivation failed") return take(nonce, into=into)
[docs] def sign( msg_bytes: BytesLike, prvkey: BytesLike | int, aux_rand32: BytesLike | None = None, *, verify: bool = True, ) -> bytes: """Create a Schnorr signature of a 32-byte message hash. The keypair this needs is built from the private key and wiped before returning, which is the right cost for one signature and half the cost of each of several: signing more than once under one key is `Signer`, which builds it once. `verify` is BIP340's own last step -- "If Verify(bytes(P), m, sig) returns failure, abort" -- and the one thing here that is more than a libsecp256k1 call. A computation gone wrong, memory gone bad or a fault induced on purpose, yields a signature that is invalid and may leak something about the secret key, and not answering with one is the whole of the protection. It is on by default because the BIP puts it inside the algorithm rather than beside it, and it can be turned off because the BIP says that too, "if the computation cost is prohibitive" -- which here is one verification and no multiplication: 28.57 microseconds against 15.87, where ECDSA's same check is 31.67 against 12.15 for having to derive the public key first. The cheaper of the two is the one a specification asks for. changelog/v0.8.0.3.md names the session those come from, and `_abort_unless_verified` carries the rest of the reasoning. Args: msg_bytes: the 32-byte message hash. prvkey: the private key, 32 bytes or an int below 2**256. The signature is of its x-only public key, so libsecp256k1 negates the key where its y is odd, as BIP340 prescribes. aux_rand32: the 32 bytes of auxiliary randomness BIP340 defines, or None for fresh randomness. Never a shorter value: BIP340 defines a 32-byte a, and padding a short one would make a caller mistake a valid argument. verify: whether to check the signature under its own public key before returning it. It costs a verification and no point multiplication, the keypair already holding the point, and False is what a caller that has measured that against its own threat model passes. Returns: The 64-byte signature. Raises: ValueError: if the message hash is not 32 bytes, if aux_rand32 is given and is not 32 bytes, or if the private key is not 32 bytes, does not fit in them, or is not in [1, n-1]. RuntimeError: if libsecp256k1 fails to sign, which no input can make it do, or if `verify` asks and the signature does not verify. Example: >>> from btclib_secp256k1 import keys, ssa, xonly >>> msg, prvkey = bytes(32), 1 >>> pubkey, _ = xonly.from_pubkey(keys.pubkey_from_prvkey(prvkey)) >>> sig = ssa.sign(msg, prvkey, bytes(32)) >>> ssa.verify(msg, pubkey, sig) True >>> ssa.sign(msg, prvkey, bytes(32), verify=False) == sig True """ keypair_obj = keypair(prvkey) try: return _sign32(msg_bytes, keypair_obj, aux_rand32, verify=verify) finally: # a keypair carries the private key: overwrite it whether the # signature was made, refused, or never attempted, the argument # checks inside being able to raise between the two wipe(keypair_obj)
[docs] def sign_custom( msg_bytes: BytesLike, prvkey: BytesLike | int, aux_rand32: BytesLike | None = None, *, verify: bool = True, ) -> bytes: """Create a Schnorr signature of a message of any length. BIP340 signs messages of arbitrary length, while bitcoin only ever signs a 32-byte hash of what it commits to: unless the protocol at hand says otherwise, hash the message with a tag of its own (hashes.tagged_sha256) and sign that instead, so that a signature cannot be read as one of a different protocol. For a 32-byte message the signature is the one sign returns. Signing more than one message under one key is `Signer.sign_custom`, for the reason given there. Args: msg_bytes: the message, of any length. prvkey: the private key, 32 bytes or an int below 2**256. aux_rand32: the 32 bytes of auxiliary randomness, or None for fresh randomness. verify: whether to check the signature under its own public key before returning it, as `sign` documents and BIP340 prescribes. One verification and no point multiplication, which on a 32-byte message is `sign`'s 28.57 microseconds against 15.87, the extraparams struct this fills aside; a longer message costs more to sign and to check by the same hash. Returns: The 64-byte signature. Raises: ValueError: if aux_rand32 is given and is not 32 bytes, or if the private key is not 32 bytes, does not fit in them, or is not in [1, n-1]. RuntimeError: if libsecp256k1 fails to sign, which no input can make it do, or if `verify` asks and the signature does not verify. """ keypair_obj = keypair(prvkey) try: return _sign_custom(msg_bytes, keypair_obj, aux_rand32, verify=verify) finally: # the keypair carries the private key: see sign wipe(keypair_obj)
[docs] class Signer: """Sign under one private key repeatedly, building the keypair once. `sign` builds a `secp256k1_keypair` and wipes it before returning, so a caller signing a second message under the same key builds it again -- and that keypair is about half of what a BIP340 signature costs here, being the point multiplication of the public key. This holds one across calls instead, so the first signature is the only one paying for it, and `sign` and `sign_custom` are the same signatures over it. `pubkey()` is the x-only key those signatures verify against, read off that same keypair rather than derived a second time. What that hands the caller is the lifetime of a secret, and it is the trade this makes deliberately. A keypair is the private key in libsecp256k1's own layout, in memory this package owns and can overwrite; the two functions own one for the length of a call and wipe it in a `finally`, while a signer holds it until it is told to let go. `wipe` is that instruction, and the `with` statement is how to give it without having to remember: on the way out of the block the keypair is overwritten, whether the block ended in a signature or in an exception. A wiped signer refuses to sign rather than signing with the zeros, and it cannot be revived -- the private key is kept nowhere else here, which is the point -- so signing again means building another one. A signer told neither way is dropped holding the key: cffi frees the keypair without overwriting it, and nothing here runs behind it to do so. A finalizer would, at a time nothing specifies, which is a guarantee in the shape of one and not in fact -- so the instruction stays the caller's to give, and SECURITY.md records this as the one buffer of the package whose zeroing is asked for rather than done. What it does not change is the python side: the `bytes` or `int` handed in here is a python object like any other, and SECURITY.md records why that copy cannot be taken back. Args: prvkey: the private key, 32 bytes or an int below 2**256. The keypair holds it as given. Every signature is of its x-only public key, so libsecp256k1 negates the key where its y is odd, as BIP340 prescribes -- inside each signature, not once here. Raises: ValueError: if the private key is not 32 bytes, does not fit in them, or is not in [1, n-1]. Example: >>> from btclib_secp256k1 import keys, ssa, xonly >>> msg, prvkey = bytes(32), 1 >>> pubkey, _ = xonly.from_pubkey(keys.pubkey_from_prvkey(prvkey)) >>> with ssa.Signer(prvkey) as signer: ... sig = signer.sign(msg, bytes(32)) >>> sig == ssa.sign(msg, prvkey, bytes(32)) True >>> ssa.verify(msg, pubkey, sig) True """ # pydoclint (DOC301) asks that this carry no docstring of its own, # the class docstring above being where the constructor is documented def __init__(self, prvkey: BytesLike | int) -> None: # noqa: D107 # None once wiped, which is what tells the two states apart: a # wiped keypair is 96 zero octets and looks like any other self._keypair: CData | None = keypair(prvkey)
[docs] def sign( self, msg_bytes: BytesLike, aux_rand32: BytesLike | None = None, *, verify: bool = True, ) -> bytes: """Create a Schnorr signature of a 32-byte message hash. The signature `ssa.sign` makes of the same arguments, over the keypair built when this signer was. Args: msg_bytes: the 32-byte message hash. aux_rand32: the 32 bytes of auxiliary randomness BIP340 defines, or None for fresh randomness. verify: whether to check the signature under this signer's own public key before returning it, as `ssa.sign` documents and BIP340 prescribes. It is the same cost here as there, the point being read off the keypair either way -- and because the signature is the cheaper, the keypair being built already, it is the larger share: 20.82 microseconds against 8.18, where `ssa.sign` is 28.57 against 15.87. The check is more than half again this signature and four fifths of that one, which is the same increment either way. 1.545 of a signature here against `dsa.sign`'s 1.607 is the reading worth having: this is where BIP340's cheaper check costs what ECDSA's does, the keypair already built having taken the signature down to where the increment dominates. Returns: The 64-byte signature. Raises: ValueError: if the message hash is not 32 bytes, if aux_rand32 is given and is not 32 bytes, or if this signer has been wiped. RuntimeError: if libsecp256k1 fails to sign, which no input can make it do, or if `verify` asks and the signature does not verify. """ return _sign32(msg_bytes, self._held(), aux_rand32, verify=verify)
[docs] def sign_custom( self, msg_bytes: BytesLike, aux_rand32: BytesLike | None = None, *, verify: bool = True, ) -> bytes: """Create a Schnorr signature of a message of any length. The signature `ssa.sign_custom` makes of the same arguments, and what that function says about hashing a message first holds here too. Args: msg_bytes: the message, of any length. aux_rand32: the 32 bytes of auxiliary randomness, or None for fresh randomness. verify: whether to check the signature under this signer's own public key before returning it, as `ssa.sign` documents and BIP340 prescribes. Returns: The 64-byte signature. Raises: ValueError: if aux_rand32 is given and is not 32 bytes, or if this signer has been wiped. RuntimeError: if libsecp256k1 fails to sign, which no input can make it do, or if `verify` asks and the signature does not verify. """ return _sign_custom(msg_bytes, self._held(), aux_rand32, verify=verify)
[docs] def pubkey(self) -> tuple[bytes, int]: """Return the x-only public key this signer signs under. The keypair already holds the point, so this is a read of it and not a multiplication: `xonly.from_prvkey` is the same answer derived from the private key, and costs the point multiplication this signer has already paid for. Returns: The 32-byte x coordinate BIP340 verifies against, and the parity of the y of the private key's own point: 0 for even, 1 for odd. A signature made here verifies against the 32 bytes whichever the parity is, which is what the negation BIP340 prescribes is for. Raises: ValueError: if this signer has been wiped. RuntimeError: if libsecp256k1 fails to convert or serialize the key, which a keypair it built cannot make it do. """ return xonly.from_keypair(self._held())
[docs] def wipe(self) -> None: """Overwrite the keypair, ending what this signer can do. The deliberate half of the trade above, and what `__exit__` calls. Signing afterwards raises rather than signing with the zeros left behind, and wiping twice is not an error: a signer used as a context manager and wiped inside the block is the case that makes it one. """ if self._keypair is not None: wipe(self._keypair) self._keypair = None
def __enter__(self) -> Self: """Return this signer, for the `with` block that wipes it. Returns: The signer itself, nothing being built here that the constructor did not already build. """ return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """Wipe the keypair, whatever ended the block. Nothing is suppressed: returning None lets an exception raised inside the block go on being raised, the wipe having happened first. Args: exc_type: the class of the exception ending the block, if one is. exc_value: that exception. traceback: its traceback. """ self.wipe() def _held(self) -> CData: """Return the keypair, or refuse if it has been wiped. Returns: The libsecp256k1 keypair this signer holds. Raises: ValueError: if `wipe` has already overwritten it. """ if self._keypair is None: raise ValueError("this signer has been wiped") return self._keypair
def _verify_( msg_bytes: BytesLike, xonly_pubkey: CData, signature_bytes: BytesLike ) -> bool: """Verify a Schnorr signature against an already-parsed x-only key. The private half of `verify`, for a caller who already holds the parsed key -- one that proved octets the x coordinate of a point, which is what `xonly.parse` answers and what this verification would ask again, or one checking several signatures against the same key: see the package docstring for what the two underscores mean throughout. Args: msg_bytes: the message, of any length. xonly_pubkey: the already-parsed x-only public key, as `xonly.parse` returns. signature_bytes: the 64-byte signature. Returns: True if the signature is valid for that key and message -- and False where libsecp256k1 could not read the key, which is the same answer it gives a signature that simply does not verify. This raises nothing for it: a caller passing an object of its own is the one that can be handed an unreadable one, and `context.check` immediately after the call is what says the False is not a verdict. `verify` parses the key from octets and so has no such case. Raises: ValueError: if the signature is not 64 bytes. """ msg_bytes = octets(msg_bytes, "message") signature_bytes = octets(signature_bytes, "signature", _SIGNATURE_SIZE) verified = lib.secp256k1_schnorrsig_verify( ctx, signature_bytes, msg_bytes, len(msg_bytes), xonly_pubkey ) return bool(verified)
[docs] def verify( msg_bytes: BytesLike, pubkey_bytes: BytesLike, signature_bytes: BytesLike ) -> bool: """Verify a Schnorr signature against a public key, in any of its forms. BIP340 verifies against an x coordinate, so `02 || x`, `03 || x` and `04 || x || y` are one key and any of the three is accepted: the y is not consulted, a signer whose point has odd y having signed with `n - d` for exactly that reason. Which form to hand in is a question of cost -- the uncompressed one is read rather than lifted, see `xonly.parse`. Args: msg_bytes: the message, of any length. It is the 32-byte hash for a signature made by `sign`. pubkey_bytes: the public key, 32, 33 or 65 bytes. signature_bytes: the 64-byte signature. Returns: True if the signature is valid for that key and message. Raises: ValueError: if the signature is not 64 bytes, or if the public key is not a valid point in one of the three serializations. A well-formed signature that simply does not verify is False, not an exception. """ # spelled out rather than composed of `xonly.parse` and `_verify_`: # the frame between them is 0.009 microseconds of the 14.275 this # call costs -- an Apple M5, macOS 26.6, arm64, CPython 3.13.14, the # two spellings alternated in one process over 7 rounds of 20 000 # calls, minimum kept for each. That is the smallest of these and is # kept for consistency with the rest rather than on its own account. # `_verify_` makes the same calls for a caller holding the x-only # key, and tests/parsed_keys_test.py asserts the two answer alike xonly_pubkey = xonly.parse(pubkey_bytes) msg_bytes = octets(msg_bytes, "message") signature_bytes = octets(signature_bytes, "signature", _SIGNATURE_SIZE) return bool( lib.secp256k1_schnorrsig_verify( ctx, signature_bytes, msg_bytes, len(msg_bytes), xonly_pubkey ) )
def _abort_unless_verified( keypair_obj: CData, msg_bytes: BytesLike, signature_bytes: bytes ) -> None: """Check a signature just made, under the keypair that made it. Named for what it does rather than for what it asks: `_verify_` is thirty lines up, answers `bool` and is what a caller composes with, where this one raises and answers nothing. Two neighbours told apart by tense would invite `if _abort_unless_verified(...)`, which is syntactically fine and means nothing at all. The verb is BIP340's own -- "returns failure, abort". BIP340's *Default Signing* ends with this step -- "If Verify(bytes(P), m, sig) returns failure, abort" -- and says what it is for: a random or attacker provoked computation error yields a signature that is invalid and may leak something about the secret key, and not publishing one is the whole of the protection. Schnorr is linear, so what leaks is not vague: two signatures over one nonce and two challenges give up `d` by subtraction. The BIP puts the step inside the algorithm and allows omitting it where the cost is prohibitive, which is why `sign` does it unless told not to rather than the other way round. Written once because `_sign32` and `_sign_custom` both end in it, and because the key has to be the one that signed: two spellings would be two chances for it to be some other key, and a check against the wrong key proves nothing while looking like proof. The key is read off the keypair rather than derived, which is what makes this cheaper than the same step is in `dsa`: BIP340 needs the public key to sign at all, so the multiplication is already paid and what is left is the verification. Args: keypair_obj: the keypair that made the signature. msg_bytes: the message it was made of, already checked. signature_bytes: the 64-byte signature just made. Raises: RuntimeError: if the signature does not verify, which no input can make happen: what it reports is the computation itself having gone wrong. """ xonly_pubkey = xonly._from_keypair_(keypair_obj) if not _verify_(msg_bytes, xonly_pubkey, signature_bytes): raise RuntimeError("signing produced a signature that does not verify") def _sign32( msg_bytes: BytesLike, keypair_obj: CData, aux_rand32: BytesLike | None, *, verify: bool = True, ) -> bytes: """Sign a 32-byte message hash with a keypair somebody else owns. The whole of `sign` and of `Signer.sign` except for the keypair: where it comes from and who overwrites it is the only difference between the two, so every argument check is here rather than at each of the two call sites, where a second spelling could drift from this one. Named after the libsecp256k1 call it is. Args: msg_bytes: the 32-byte message hash. keypair_obj: the libsecp256k1 keypair to sign with, wiped by whoever built it and not here. aux_rand32: the 32 bytes of auxiliary randomness, or None for fresh randomness. verify: whether to check the signature before answering with it, as `_abort_unless_verified` documents and BIP340 prescribes. Returns: The 64-byte signature. Raises: ValueError: if the message hash is not 32 bytes, or if aux_rand32 is given and is not 32 bytes. RuntimeError: if libsecp256k1 refuses the keypair -- one it cannot read -- or fails to sign for any other reason, which a keypair it built cannot make it do, or if `verify` asks and the signature does not verify. """ msg_bytes = octets(msg_bytes, "message hash", 32) sig = ffi.new(_SIGNATURE_BUFFER_TYPE) if not lib.secp256k1_schnorrsig_sign32( ctx, sig, msg_bytes, keypair_obj, entropy(aux_rand32) ): msg = "schnorr signing failed" raise RuntimeError(msg) signature_bytes = ffi.unpack(sig, _SIGNATURE_SIZE) if verify: _abort_unless_verified(keypair_obj, msg_bytes, signature_bytes) return signature_bytes def _sign_custom( msg_bytes: BytesLike, keypair_obj: CData, aux_rand32: BytesLike | None, *, verify: bool = True, ) -> bytes: """Sign a message of any length with a keypair somebody else owns. What `_sign32` is to `sign`, this is to `sign_custom`. Args: msg_bytes: the message, of any length. keypair_obj: the libsecp256k1 keypair to sign with, wiped by whoever built it and not here. aux_rand32: the 32 bytes of auxiliary randomness, or None for fresh randomness. verify: whether to check the signature before answering with it, as `_abort_unless_verified` documents and BIP340 prescribes. Returns: The 64-byte signature. Raises: ValueError: if aux_rand32 is given and is not 32 bytes. RuntimeError: if libsecp256k1 refuses the keypair -- one it cannot read -- or fails to sign for any other reason, which a keypair it built cannot make it do, or if `verify` asks and the signature does not verify. """ msg_bytes = octets(msg_bytes, "message") sig = ffi.new(_SIGNATURE_BUFFER_TYPE) ndata = ffi.new("char[32]", entropy(aux_rand32)) extraparams = ffi.new("secp256k1_schnorrsig_extraparams *") extraparams.magic = EXTRAPARAMS_MAGIC extraparams.noncefp = ffi.NULL # ndata has to stay referenced until the call is over: cffi keeps # alive what a variable points to, not what a struct field does extraparams.ndata = ndata if not lib.secp256k1_schnorrsig_sign_custom( ctx, sig, msg_bytes, len(msg_bytes), keypair_obj, extraparams ): msg = "schnorr signing failed" raise RuntimeError(msg) signature_bytes = ffi.unpack(sig, _SIGNATURE_SIZE) if verify: _abort_unless_verified(keypair_obj, msg_bytes, signature_bytes) return signature_bytes