Browse Source

BIP340 x-only keys and Schnorr signatures (#116)

* keys: add BIP340 x-only key support

* keys: support for BIP340 Schnorr signatures creation and verification

* More tests for xonly keys
anonswap
Antoine Poinsot 2 years ago
committed by GitHub
parent
commit
a7284cd4d6
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 125
      coincurve/keys.py
  2. 3
      tests/samples.py
  3. 61
      tests/test_keys.py

125
coincurve/keys.py

@ -1,3 +1,4 @@
import os
from typing import Tuple
from asn1crypto.keys import ECDomainParameters, ECPointBitString, ECPrivateKey, PrivateKeyAlgorithm, PrivateKeyInfo
@ -31,6 +32,7 @@ class PrivateKey:
self.secret: bytes = validate_secret(secret) if secret is not None else get_valid_secret()
self.context = context
self.public_key: PublicKey = PublicKey.from_valid_secret(self.secret, self.context)
self.xonly_pubkey: XonlyPublicKey = XonlyPublicKey.from_secret(self.secret, self.context)
def sign(self, message: bytes, hasher: Hasher = sha256, custom_nonce: Nonce = DEFAULT_NONCE) -> bytes:
"""
@ -59,6 +61,34 @@ class PrivateKey:
return cdata_to_der(signature, self.context)
def sign_schnorr(self, message: bytes, aux_randomness: bytes = None) -> bytes:
"""Create a Schnorr signature.
:param message: the message to be signed as an array of 32 bytes.
:param aux_randomness: an optional 32 bytes of fresh randomness.
:return: the 64-bytes Schnorr signature.
"""
if not isinstance(message, bytes) or len(message) != 32:
raise ValueError('"message" must be an array of 32 bytes')
if aux_randomness is not None and (not isinstance(aux_randomness, bytes) or len(aux_randomness) != 32):
raise ValueError('"aux_randomness" must be an array of 32 bytes')
keypair = ffi.new('secp256k1_keypair *')
res = lib.secp256k1_keypair_create(self.context.ctx, keypair, self.secret)
assert res, 'Secret must be valid, we just checked it'
aux_randomness = aux_randomness or os.urandom(32)
signature = ffi.new('unsigned char[64]')
res = lib.secp256k1_schnorrsig_sign32(self.context.ctx, signature, message, keypair, aux_randomness)
assert res, 'Secret key is valid, signing must not fail'
res = lib.secp256k1_schnorrsig_verify(
self.context.ctx, signature, message, len(message), self.xonly_pubkey.xonly_pubkey
)
assert res, 'libsecp must not give us an invalid signature'
return bytes(ffi.buffer(signature))
def sign_recoverable(self, message: bytes, hasher: Hasher = sha256, custom_nonce: Nonce = DEFAULT_NONCE) -> bytes:
"""
Create a recoverable ECDSA signature.
@ -484,3 +514,98 @@ class PublicKey:
def __eq__(self, other) -> bool:
return self.format(compressed=False) == other.format(compressed=False)
class XonlyPublicKey:
def __init__(self, data: bytes, parity: bool = False, context: Context = GLOBAL_CONTEXT):
"""A BIP340 'x-only' public key.
:param data: The formatted public key as a 32 bytes array or as an ffi 'secp256k1_xonly_pubkey *' type.
:param parity: Whether the encoded point is the negation of the pubkey.
:type data: bytes
:param context: a reference to a verification context.
"""
if isinstance(data, bytes):
if len(data) != 32:
raise ValueError('"data" if in bytes must be an array of 32 bytes')
self.xonly_pubkey = ffi.new('secp256k1_xonly_pubkey *')
parsed = lib.secp256k1_xonly_pubkey_parse(context.ctx, self.xonly_pubkey, data)
if not parsed:
raise ValueError('The public key could not be parsed or is invalid.')
else:
# data must be a cdata 'secp256k1_xonly_pubkey *' type
self.xonly_pubkey = data
self.parity = parity
self.context = context
@classmethod
def from_secret(cls, secret: bytes, context: Context = GLOBAL_CONTEXT):
"""Create an x-only public key from a given secret.
:param secret: the private key as an array of 32 bytes.
:return: The x-only public key.
"""
if not isinstance(secret, bytes) or len(secret) != 32:
raise ValueError('"data" must be an array of 32 bytes')
secret = validate_secret(secret)
keypair = ffi.new('secp256k1_keypair *')
res = lib.secp256k1_keypair_create(context.ctx, keypair, secret)
assert res, 'Secret must be valid, we just checked it'
xonly_pubkey = ffi.new('secp256k1_xonly_pubkey *')
pk_parity = ffi.new('int *')
res = lib.secp256k1_keypair_xonly_pub(context.ctx, xonly_pubkey, pk_parity, keypair)
assert res and pk_parity[0] in (0, 1), 'Must always return 1 and a boolean parity'
return cls(xonly_pubkey, parity=bool(pk_parity[0]), context=context)
def format(self) -> bytes:
"""Serialize the public key.
:return: The public key serialized as an array of 32 bytes.
"""
output32 = ffi.new('unsigned char [32]')
res = lib.secp256k1_xonly_pubkey_serialize(self.context.ctx, output32, self.xonly_pubkey)
assert res, 'Public key in self.xonly_pubkey must be valid'
return bytes(ffi.buffer(output32, 32))
def verify(self, signature: bytes, message: bytes) -> bool:
"""Verify a Schnorr signature over a given message.
:param signature: The 64-bytes Schnorr signature to verify.
:param message: The message to be verified.
:return: A boolean indicating whether or not the signature is correct.
"""
if not isinstance(signature, bytes) or len(signature) != 64:
raise ValueError('The "signature" parameter must be an array of 64 bytes')
if not isinstance(message, bytes):
raise ValueError('The "message" parameter must be an array of bytes')
return lib.secp256k1_schnorrsig_verify(self.context.ctx, signature, message, len(message), self.xonly_pubkey)
def tweak_add(self, tweak: bytes):
"""Tweak the public key by adding the generator multiplied with tweak32 to it.
:param tweak: A 32 bytes tweak.
"""
if not isinstance(tweak, bytes) or len(tweak) != 32:
raise ValueError('"tweak" must be an array of 32 bytes')
out_pubkey = ffi.new('secp256k1_pubkey *')
res = lib.secp256k1_xonly_pubkey_tweak_add(self.context.ctx, out_pubkey, self.xonly_pubkey, tweak)
if not res:
raise ValueError('Resulting public key would be invalid')
pk_parity = ffi.new('int *')
res = lib.secp256k1_xonly_pubkey_from_pubkey(self.context.ctx, self.xonly_pubkey, pk_parity, out_pubkey)
assert res and pk_parity[0] in (0, 1), 'Must always return 1 and a boolean parity'
self.parity = bool(pk_parity[0])
def __eq__(self, other) -> bool:
res = lib.secp256k1_xonly_pubkey_cmp(self.context.ctx, self.xonly_pubkey, other.xonly_pubkey)
return res == 0

3
tests/samples.py

@ -48,3 +48,6 @@ RECOVERABLE_SIGNATURE = (
b'\x92G\x80&8\x1cVz%2\xb0\x8a\xd0l\x0b4\x9c~\x93\x18\xad'
b'\xe4J\x9c-\n\x00'
)
X_ONLY_PUBKEY = b"Ncx\x00\xf1_'BV\x9ac\x0b\xec)\x0eH\xdf\xebc\xa9\\\x85\x19:\xf9L{B\xe6\x14\xfe\xa8"
X_ONLY_PUBKEY_INVALID = bytes(32)

61
tests/test_keys.py

@ -4,7 +4,7 @@ from os import urandom
import pytest
from coincurve.ecdsa import deserialize_recoverable, recover
from coincurve.keys import PrivateKey, PublicKey
from coincurve.keys import PrivateKey, PublicKey, XonlyPublicKey
from coincurve.utils import bytes_to_int, int_to_bytes_padded, verify_signature
from .samples import (
@ -20,6 +20,8 @@ from .samples import (
PUBLIC_KEY_Y,
RECOVERABLE_SIGNATURE,
SIGNATURE,
X_ONLY_PUBKEY,
X_ONLY_PUBKEY_INVALID,
)
G = PublicKey(
@ -35,6 +37,9 @@ class TestPrivateKey:
def test_public_key(self):
assert PrivateKey(PRIVATE_KEY_BYTES).public_key.format() == PUBLIC_KEY_COMPRESSED
def test_xonly_pubkey(self):
assert PrivateKey(PRIVATE_KEY_BYTES).xonly_pubkey.format() == PUBLIC_KEY_COMPRESSED[1:]
def test_signature_correct(self):
private_key = PrivateKey()
public_key = private_key.public_key
@ -59,6 +64,22 @@ class TestPrivateKey:
== PublicKey(recover(MESSAGE, deserialize_recoverable(private_key.sign_recoverable(MESSAGE)))).format()
)
def test_schnorr_signature(self):
private_key = PrivateKey()
message = urandom(32)
# Message must be 32 bytes
with pytest.raises(ValueError):
private_key.sign_schnorr(message + b'\x01')
# We can provide supplementary randomness
sig = private_key.sign_schnorr(message, urandom(32))
assert private_key.xonly_pubkey.verify(sig, message)
# Or not
sig = private_key.sign_schnorr(message)
assert private_key.xonly_pubkey.verify(sig, message)
def test_to_hex(self):
assert PrivateKey(PRIVATE_KEY_BYTES).to_hex() == PRIVATE_KEY_HEX
@ -146,3 +167,41 @@ class TestPublicKey:
b = PrivateKey().public_key
assert PublicKey.combine_keys([a, b]) == a.combine([b])
class TestXonlyPubKey:
def test_parse_invalid(self):
# Must be 32 bytes
with pytest.raises(ValueError):
XonlyPublicKey(bytes(33))
# Must be an x coordinate for a valid point
with pytest.raises(ValueError):
XonlyPublicKey(X_ONLY_PUBKEY_INVALID)
def test_roundtrip(self):
assert XonlyPublicKey(X_ONLY_PUBKEY).format() == X_ONLY_PUBKEY
assert XonlyPublicKey(PUBLIC_KEY_COMPRESSED[1:]).format() == PUBLIC_KEY_COMPRESSED[1:]
# Test __eq__
assert XonlyPublicKey(X_ONLY_PUBKEY) == XonlyPublicKey(X_ONLY_PUBKEY)
def test_tweak(self):
# Taken from BIP341 test vectors.
# See github.com/bitcoin/bips/blob/6545b81022212a9f1c814f6ce1673e84bc02c910/bip-0341/wallet-test-vectors.json
pubkey = XonlyPublicKey(bytes.fromhex('d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d'))
pubkey.tweak_add(bytes.fromhex('b86e7be8f39bab32a6f2c0443abbc210f0edac0e2c53d501b36b64437d9c6c70'))
assert pubkey.format() == bytes.fromhex('53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343')
def test_parity(self):
# Taken from BIP341 test vectors.
# See github.com/bitcoin/bips/blob/6545b81022212a9f1c814f6ce1673e84bc02c910/bip-0341/wallet-test-vectors.json
pubkey = XonlyPublicKey(bytes.fromhex('187791b6f712a8ea41c8ecdd0ee77fab3e85263b37e1ec18a3651926b3a6cf27'))
pubkey.tweak_add(bytes.fromhex('cbd8679ba636c1110ea247542cfbd964131a6be84f873f7f3b62a777528ed001'))
assert pubkey.format() == bytes.fromhex('147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3')
assert pubkey.parity
pubkey = XonlyPublicKey(bytes.fromhex('93478e9488f956df2396be2ce6c5cced75f900dfa18e7dabd2428aae78451820'))
pubkey.tweak_add(bytes.fromhex('6af9e28dbf9d6aaf027696e2598a5b3d056f5fd2355a7fd5a37a0e5008132d30'))
assert pubkey.format() == bytes.fromhex('e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e')
assert not pubkey.parity

Loading…
Cancel
Save