From 3050bb6b25c0c20b62e2fc1a23276a09d50d151b Mon Sep 17 00:00:00 2001 From: Sebastian Falbesoner Date: Thu, 15 Jan 2026 00:43:29 +0100 Subject: [PATCH 1/5] Squashed 'bip-0374/secp256k1lab/' content from commit 44dc4bd git-subtree-dir: bip-0374/secp256k1lab git-subtree-split: 44dc4bd893b8f03e621585e3bf255253e0e0fbfb --- .github/workflows/main.yml | 17 ++ .python-version | 1 + CHANGELOG.md | 10 + COPYING | 23 ++ README.md | 13 + pyproject.toml | 34 +++ src/secp256k1lab/__init__.py | 0 src/secp256k1lab/bip340.py | 73 ++++++ src/secp256k1lab/ecdh.py | 16 ++ src/secp256k1lab/keys.py | 15 ++ src/secp256k1lab/py.typed | 0 src/secp256k1lab/secp256k1.py | 454 ++++++++++++++++++++++++++++++++++ src/secp256k1lab/util.py | 24 ++ 13 files changed, 680 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .python-version create mode 100644 CHANGELOG.md create mode 100644 COPYING create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/secp256k1lab/__init__.py create mode 100644 src/secp256k1lab/bip340.py create mode 100644 src/secp256k1lab/ecdh.py create mode 100644 src/secp256k1lab/keys.py create mode 100644 src/secp256k1lab/py.typed create mode 100644 src/secp256k1lab/secp256k1.py create mode 100644 src/secp256k1lab/util.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000000..4950b96550 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,17 @@ +name: Tests +on: [push, pull_request] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v5 + - run: uvx ruff check . + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v5 + - run: uvx mypy . diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..bd28b9c5c2 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..15779717c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-03-31 + +Initial release. diff --git a/COPYING b/COPYING new file mode 100644 index 0000000000..e8f2163641 --- /dev/null +++ b/COPYING @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2009-2024 The Bitcoin Core developers +Copyright (c) 2009-2024 Bitcoin Developers +Copyright (c) 2025- The secp256k1lab Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..dbc9dbd04c --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +secp256k1lab +============ + +![Dependencies: None](https://img.shields.io/badge/dependencies-none-success) + +An INSECURE implementation of the secp256k1 elliptic curve and related cryptographic schemes written in Python, intended for prototyping, experimentation and education. + +Features: +* Low-level secp256k1 field and group arithmetic. +* Schnorr signing/verification and key generation according to [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki). +* ECDH key exchange. + +WARNING: The code in this library is slow and trivially vulnerable to side channel attacks. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..a0bdd19f42 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "secp256k1lab" +version = "1.0.0" +description = "An INSECURE implementation of the secp256k1 elliptic curve and related cryptographic schemes, intended for prototyping, experimentation and education" +readme = "README.md" +authors = [ + { name = "Pieter Wuille", email = "pieter@wuille.net" }, + { name = "Tim Ruffing", email = "me@real-or-random.org" }, + { name = "Jonas Nick", email = "jonasd.nick@gmail.com" }, + { name = "Sebastian Falbesoner", email = "sebastian.falbesoner@gmail.com" } +] +maintainers = [ + { name = "Tim Ruffing", email = "me@real-or-random.org" }, + { name = "Jonas Nick", email = "jonasd.nick@gmail.com" }, + { name = "Sebastian Falbesoner", email = "sebastian.falbesoner@gmail.com" } +] +requires-python = ">=3.9" +license = "MIT" +license-files = ["COPYING"] +keywords = ["secp256k1", "elliptic curves", "cryptography", "Bitcoin"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Topic :: Security :: Cryptography", +] +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/src/secp256k1lab/__init__.py b/src/secp256k1lab/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/secp256k1lab/bip340.py b/src/secp256k1lab/bip340.py new file mode 100644 index 0000000000..ba839d16e1 --- /dev/null +++ b/src/secp256k1lab/bip340.py @@ -0,0 +1,73 @@ +# The following functions are based on the BIP 340 reference implementation: +# https://github.com/bitcoin/bips/blob/master/bip-0340/reference.py + +from .secp256k1 import FE, GE, G +from .util import int_from_bytes, bytes_from_int, xor_bytes, tagged_hash + + +def pubkey_gen(seckey: bytes) -> bytes: + d0 = int_from_bytes(seckey) + if not (1 <= d0 <= GE.ORDER - 1): + raise ValueError("The secret key must be an integer in the range 1..n-1.") + P = d0 * G + assert not P.infinity + return P.to_bytes_xonly() + + +def schnorr_sign( + msg: bytes, seckey: bytes, aux_rand: bytes, tag_prefix: str = "BIP0340" +) -> bytes: + d0 = int_from_bytes(seckey) + if not (1 <= d0 <= GE.ORDER - 1): + raise ValueError("The secret key must be an integer in the range 1..n-1.") + if len(aux_rand) != 32: + raise ValueError("aux_rand must be 32 bytes instead of %i." % len(aux_rand)) + P = d0 * G + assert not P.infinity + d = d0 if P.has_even_y() else GE.ORDER - d0 + t = xor_bytes(bytes_from_int(d), tagged_hash(tag_prefix + "/aux", aux_rand)) + k0 = ( + int_from_bytes(tagged_hash(tag_prefix + "/nonce", t + P.to_bytes_xonly() + msg)) + % GE.ORDER + ) + if k0 == 0: + raise RuntimeError("Failure. This happens only with negligible probability.") + R = k0 * G + assert not R.infinity + k = k0 if R.has_even_y() else GE.ORDER - k0 + e = ( + int_from_bytes( + tagged_hash( + tag_prefix + "/challenge", R.to_bytes_xonly() + P.to_bytes_xonly() + msg + ) + ) + % GE.ORDER + ) + sig = R.to_bytes_xonly() + bytes_from_int((k + e * d) % GE.ORDER) + assert schnorr_verify(msg, P.to_bytes_xonly(), sig, tag_prefix=tag_prefix) + return sig + + +def schnorr_verify( + msg: bytes, pubkey: bytes, sig: bytes, tag_prefix: str = "BIP0340" +) -> bool: + if len(pubkey) != 32: + raise ValueError("The public key must be a 32-byte array.") + if len(sig) != 64: + raise ValueError("The signature must be a 64-byte array.") + try: + P = GE.from_bytes_xonly(pubkey) + except ValueError: + return False + r = int_from_bytes(sig[0:32]) + s = int_from_bytes(sig[32:64]) + if (r >= FE.SIZE) or (s >= GE.ORDER): + return False + e = ( + int_from_bytes(tagged_hash(tag_prefix + "/challenge", sig[0:32] + pubkey + msg)) + % GE.ORDER + ) + R = s * G - e * P + if R.infinity or (not R.has_even_y()) or (R.x != r): + return False + return True diff --git a/src/secp256k1lab/ecdh.py b/src/secp256k1lab/ecdh.py new file mode 100644 index 0000000000..73f47fa1a7 --- /dev/null +++ b/src/secp256k1lab/ecdh.py @@ -0,0 +1,16 @@ +import hashlib + +from .secp256k1 import GE, Scalar + + +def ecdh_compressed_in_raw_out(seckey: bytes, pubkey: bytes) -> GE: + """TODO""" + shared_secret = Scalar.from_bytes_checked(seckey) * GE.from_bytes_compressed(pubkey) + assert not shared_secret.infinity # prime-order group + return shared_secret + + +def ecdh_libsecp256k1(seckey: bytes, pubkey: bytes) -> bytes: + """TODO""" + shared_secret = ecdh_compressed_in_raw_out(seckey, pubkey) + return hashlib.sha256(shared_secret.to_bytes_compressed()).digest() diff --git a/src/secp256k1lab/keys.py b/src/secp256k1lab/keys.py new file mode 100644 index 0000000000..3e28897e99 --- /dev/null +++ b/src/secp256k1lab/keys.py @@ -0,0 +1,15 @@ +from .secp256k1 import GE, G +from .util import int_from_bytes + +# The following function is based on the BIP 327 reference implementation +# https://github.com/bitcoin/bips/blob/master/bip-0327/reference.py + + +# Return the plain public key corresponding to a given secret key +def pubkey_gen_plain(seckey: bytes) -> bytes: + d0 = int_from_bytes(seckey) + if not (1 <= d0 <= GE.ORDER - 1): + raise ValueError("The secret key must be an integer in the range 1..n-1.") + P = d0 * G + assert not P.infinity + return P.to_bytes_compressed() diff --git a/src/secp256k1lab/py.typed b/src/secp256k1lab/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/secp256k1lab/secp256k1.py b/src/secp256k1lab/secp256k1.py new file mode 100644 index 0000000000..6e262bf51e --- /dev/null +++ b/src/secp256k1lab/secp256k1.py @@ -0,0 +1,454 @@ +# Copyright (c) 2022-2023 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Test-only implementation of low-level secp256k1 field and group arithmetic + +It is designed for ease of understanding, not performance. + +WARNING: This code is slow and trivially vulnerable to side channel attacks. Do not use for +anything but tests. + +Exports: +* FE: class for secp256k1 field elements +* GE: class for secp256k1 group elements +* G: the secp256k1 generator point +""" + +# TODO Docstrings of methods still say "field element" +class APrimeFE: + """Objects of this class represent elements of a prime field. + + They are represented internally in numerator / denominator form, in order to delay inversions. + """ + + # The size of the field (also its modulus and characteristic). + SIZE: int + + def __init__(self, a=0, b=1): + """Initialize a field element a/b; both a and b can be ints or field elements.""" + if isinstance(a, type(self)): + num = a._num + den = a._den + else: + num = a % self.SIZE + den = 1 + if isinstance(b, type(self)): + den = (den * b._num) % self.SIZE + num = (num * b._den) % self.SIZE + else: + den = (den * b) % self.SIZE + assert den != 0 + if num == 0: + den = 1 + self._num = num + self._den = den + + def __add__(self, a): + """Compute the sum of two field elements (second may be int).""" + if isinstance(a, type(self)): + return type(self)(self._num * a._den + self._den * a._num, self._den * a._den) + if isinstance(a, int): + return type(self)(self._num + self._den * a, self._den) + return NotImplemented + + def __radd__(self, a): + """Compute the sum of an integer and a field element.""" + return type(self)(a) + self + + @classmethod + # REVIEW This should be + # def sum(cls, *es: Iterable[Self]) -> Self: + # but Self needs the typing_extension package on Python <= 3.12. + def sum(cls, *es): + """Compute the sum of field elements. + + sum(a, b, c, ...) is identical to (0 + a + b + c + ...).""" + return sum(es, start=cls(0)) + + def __sub__(self, a): + """Compute the difference of two field elements (second may be int).""" + if isinstance(a, type(self)): + return type(self)(self._num * a._den - self._den * a._num, self._den * a._den) + if isinstance(a, int): + return type(self)(self._num - self._den * a, self._den) + return NotImplemented + + def __rsub__(self, a): + """Compute the difference of an integer and a field element.""" + return type(self)(a) - self + + def __mul__(self, a): + """Compute the product of two field elements (second may be int).""" + if isinstance(a, type(self)): + return type(self)(self._num * a._num, self._den * a._den) + if isinstance(a, int): + return type(self)(self._num * a, self._den) + return NotImplemented + + def __rmul__(self, a): + """Compute the product of an integer with a field element.""" + return type(self)(a) * self + + def __truediv__(self, a): + """Compute the ratio of two field elements (second may be int).""" + if isinstance(a, type(self)) or isinstance(a, int): + return type(self)(self, a) + return NotImplemented + + def __pow__(self, a): + """Raise a field element to an integer power.""" + return type(self)(pow(self._num, a, self.SIZE), pow(self._den, a, self.SIZE)) + + def __neg__(self): + """Negate a field element.""" + return type(self)(-self._num, self._den) + + def __int__(self): + """Convert a field element to an integer in range 0..SIZE-1. The result is cached.""" + if self._den != 1: + self._num = (self._num * pow(self._den, -1, self.SIZE)) % self.SIZE + self._den = 1 + return self._num + + def sqrt(self): + """Compute the square root of a field element if it exists (None otherwise).""" + raise NotImplementedError + + def is_square(self): + """Determine if this field element has a square root.""" + # A more efficient algorithm is possible here (Jacobi symbol). + return self.sqrt() is not None + + def is_even(self): + """Determine whether this field element, represented as integer in 0..SIZE-1, is even.""" + return int(self) & 1 == 0 + + def __eq__(self, a): + """Check whether two field elements are equal (second may be an int).""" + if isinstance(a, type(self)): + return (self._num * a._den - self._den * a._num) % self.SIZE == 0 + return (self._num - self._den * a) % self.SIZE == 0 + + def to_bytes(self): + """Convert a field element to a 32-byte array (BE byte order).""" + return int(self).to_bytes(32, 'big') + + @classmethod + def from_int_checked(cls, v): + """Convert an integer to a field element (no overflow allowed).""" + if v >= cls.SIZE: + raise ValueError + return cls(v) + + @classmethod + def from_int_wrapping(cls, v): + """Convert an integer to a field element (reduced modulo SIZE).""" + return cls(v % cls.SIZE) + + @classmethod + def from_bytes_checked(cls, b): + """Convert a 32-byte array to a field element (BE byte order, no overflow allowed).""" + v = int.from_bytes(b, 'big') + return cls.from_int_checked(v) + + @classmethod + def from_bytes_wrapping(cls, b): + """Convert a 32-byte array to a field element (BE byte order, reduced modulo SIZE).""" + v = int.from_bytes(b, 'big') + return cls.from_int_wrapping(v) + + def __str__(self): + """Convert this field element to a 64 character hex string.""" + return f"{int(self):064x}" + + def __repr__(self): + """Get a string representation of this field element.""" + return f"{type(self).__qualname__}(0x{int(self):x})" + + +class FE(APrimeFE): + SIZE = 2**256 - 2**32 - 977 + + def sqrt(self): + # Due to the fact that our modulus p is of the form (p % 4) == 3, the Tonelli-Shanks + # algorithm (https://en.wikipedia.org/wiki/Tonelli-Shanks_algorithm) is simply + # raising the argument to the power (p + 1) / 4. + + # To see why: (p-1) % 2 = 0, so 2 divides the order of the multiplicative group, + # and thus only half of the non-zero field elements are squares. An element a is + # a (nonzero) square when Euler's criterion, a^((p-1)/2) = 1 (mod p), holds. We're + # looking for x such that x^2 = a (mod p). Given a^((p-1)/2) = 1, that is equivalent + # to x^2 = a^(1 + (p-1)/2) mod p. As (1 + (p-1)/2) is even, this is equivalent to + # x = a^((1 + (p-1)/2)/2) mod p, or x = a^((p+1)/4) mod p. + v = int(self) + s = pow(v, (self.SIZE + 1) // 4, self.SIZE) + if s**2 % self.SIZE == v: + return type(self)(s) + return None + + +class Scalar(APrimeFE): + """TODO Docstring""" + SIZE = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + +class GE: + """Objects of this class represent secp256k1 group elements (curve points or infinity) + + GE objects are immutable. + + Normal points on the curve have fields: + * x: the x coordinate (a field element) + * y: the y coordinate (a field element, satisfying y^2 = x^3 + 7) + * infinity: False + + The point at infinity has field: + * infinity: True + """ + + # TODO The following two class attributes should probably be just getters as + # classmethods to enforce immutability. Unfortunately Python makes it hard + # to create "classproperties". `G` could then also be just a classmethod. + + # Order of the group (number of points on the curve, plus 1 for infinity) + ORDER = Scalar.SIZE + + # Number of valid distinct x coordinates on the curve. + ORDER_HALF = ORDER // 2 + + @property + def infinity(self): + """Whether the group element is the point at infinity.""" + return self._infinity + + @property + def x(self): + """The x coordinate (a field element) of a non-infinite group element.""" + assert not self.infinity + return self._x + + @property + def y(self): + """The y coordinate (a field element) of a non-infinite group element.""" + assert not self.infinity + return self._y + + def __init__(self, x=None, y=None): + """Initialize a group element with specified x and y coordinates, or infinity.""" + if x is None: + # Initialize as infinity. + assert y is None + self._infinity = True + else: + # Initialize as point on the curve (and check that it is). + fx = FE(x) + fy = FE(y) + assert fy**2 == fx**3 + 7 + self._infinity = False + self._x = fx + self._y = fy + + def __add__(self, a): + """Add two group elements together.""" + # Deal with infinity: a + infinity == infinity + a == a. + if self.infinity: + return a + if a.infinity: + return self + if self.x == a.x: + if self.y != a.y: + # A point added to its own negation is infinity. + assert self.y + a.y == 0 + return GE() + else: + # For identical inputs, use the tangent (doubling formula). + lam = (3 * self.x**2) / (2 * self.y) + else: + # For distinct inputs, use the line through both points (adding formula). + lam = (self.y - a.y) / (self.x - a.x) + # Determine point opposite to the intersection of that line with the curve. + x = lam**2 - (self.x + a.x) + y = lam * (self.x - x) - self.y + return GE(x, y) + + @staticmethod + def sum(*ps): + """Compute the sum of group elements. + + GE.sum(a, b, c, ...) is identical to (GE() + a + b + c + ...).""" + return sum(ps, start=GE()) + + @staticmethod + def batch_mul(*aps): + """Compute a (batch) scalar group element multiplication. + + GE.batch_mul((a1, p1), (a2, p2), (a3, p3)) is identical to a1*p1 + a2*p2 + a3*p3, + but more efficient.""" + # Reduce all the scalars modulo order first (so we can deal with negatives etc). + naps = [(int(a), p) for a, p in aps] + # Start with point at infinity. + r = GE() + # Iterate over all bit positions, from high to low. + for i in range(255, -1, -1): + # Double what we have so far. + r = r + r + # Add then add the points for which the corresponding scalar bit is set. + for (a, p) in naps: + if (a >> i) & 1: + r += p + return r + + def __rmul__(self, a): + """Multiply an integer with a group element.""" + if self == G: + return FAST_G.mul(Scalar(a)) + return GE.batch_mul((Scalar(a), self)) + + def __neg__(self): + """Compute the negation of a group element.""" + if self.infinity: + return self + return GE(self.x, -self.y) + + def __sub__(self, a): + """Subtract a group element from another.""" + return self + (-a) + + def __eq__(self, a): + """Check if two group elements are equal.""" + return (self - a).infinity + + def has_even_y(self): + """Determine whether a non-infinity group element has an even y coordinate.""" + assert not self.infinity + return self.y.is_even() + + def to_bytes_compressed(self): + """Convert a non-infinite group element to 33-byte compressed encoding.""" + assert not self.infinity + return bytes([3 - self.y.is_even()]) + self.x.to_bytes() + + def to_bytes_compressed_with_infinity(self): + """Convert a group element to 33-byte compressed encoding, mapping infinity to zeros.""" + if self.infinity: + return 33 * b"\x00" + return self.to_bytes_compressed() + + def to_bytes_uncompressed(self): + """Convert a non-infinite group element to 65-byte uncompressed encoding.""" + assert not self.infinity + return b'\x04' + self.x.to_bytes() + self.y.to_bytes() + + def to_bytes_xonly(self): + """Convert (the x coordinate of) a non-infinite group element to 32-byte xonly encoding.""" + assert not self.infinity + return self.x.to_bytes() + + @staticmethod + def lift_x(x): + """Return group element with specified field element as x coordinate (and even y).""" + y = (FE(x)**3 + 7).sqrt() + if y is None: + raise ValueError + if not y.is_even(): + y = -y + return GE(x, y) + + @staticmethod + def from_bytes_compressed(b): + """Convert a compressed to a group element.""" + assert len(b) == 33 + if b[0] != 2 and b[0] != 3: + raise ValueError + x = FE.from_bytes_checked(b[1:]) + r = GE.lift_x(x) + if b[0] == 3: + r = -r + return r + + @staticmethod + def from_bytes_uncompressed(b): + """Convert an uncompressed to a group element.""" + assert len(b) == 65 + if b[0] != 4: + raise ValueError + x = FE.from_bytes_checked(b[1:33]) + y = FE.from_bytes_checked(b[33:]) + if y**2 != x**3 + 7: + raise ValueError + return GE(x, y) + + @staticmethod + def from_bytes(b): + """Convert a compressed or uncompressed encoding to a group element.""" + assert len(b) in (33, 65) + if len(b) == 33: + return GE.from_bytes_compressed(b) + else: + return GE.from_bytes_uncompressed(b) + + @staticmethod + def from_bytes_xonly(b): + """Convert a point given in xonly encoding to a group element.""" + assert len(b) == 32 + x = FE.from_bytes_checked(b) + r = GE.lift_x(x) + return r + + @staticmethod + def is_valid_x(x): + """Determine whether the provided field element is a valid X coordinate.""" + return (FE(x)**3 + 7).is_square() + + def __str__(self): + """Convert this group element to a string.""" + if self.infinity: + return "(inf)" + return f"({self.x},{self.y})" + + def __repr__(self): + """Get a string representation for this group element.""" + if self.infinity: + return "GE()" + return f"GE(0x{int(self.x):x},0x{int(self.y):x})" + + def __hash__(self): + """Compute a non-cryptographic hash of the group element.""" + if self.infinity: + return 0 # 0 is not a valid x coordinate + return int(self.x) + + +# The secp256k1 generator point +G = GE.lift_x(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798) + + +class FastGEMul: + """Table for fast multiplication with a constant group element. + + Speed up scalar multiplication with a fixed point P by using a precomputed lookup table with + its powers of 2: + + table = [P, 2*P, 4*P, (2^3)*P, (2^4)*P, ..., (2^255)*P] + + During multiplication, the points corresponding to each bit set in the scalar are added up, + i.e. on average ~128 point additions take place. + """ + + def __init__(self, p): + self.table = [p] # table[i] = (2^i) * p + for _ in range(255): + p = p + p + self.table.append(p) + + def mul(self, a): + result = GE() + a = int(a) + for bit in range(a.bit_length()): + if a & (1 << bit): + result += self.table[bit] + return result + +# Precomputed table with multiples of G for fast multiplication +FAST_G = FastGEMul(G) diff --git a/src/secp256k1lab/util.py b/src/secp256k1lab/util.py new file mode 100644 index 0000000000..d8c744b795 --- /dev/null +++ b/src/secp256k1lab/util.py @@ -0,0 +1,24 @@ +import hashlib + + +# This implementation can be sped up by storing the midstate after hashing +# tag_hash instead of rehashing it all the time. +def tagged_hash(tag: str, msg: bytes) -> bytes: + tag_hash = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(tag_hash + tag_hash + msg).digest() + + +def bytes_from_int(x: int) -> bytes: + return x.to_bytes(32, byteorder="big") + + +def xor_bytes(b0: bytes, b1: bytes) -> bytes: + return bytes(x ^ y for (x, y) in zip(b0, b1)) + + +def int_from_bytes(b: bytes) -> int: + return int.from_bytes(b, byteorder="big") + + +def hash_sha256(b: bytes) -> bytes: + return hashlib.sha256(b).digest() From 4e18ee641b80cb63b56d59bac2fe102dcc1fbe25 Mon Sep 17 00:00:00 2001 From: Sebastian Falbesoner Date: Tue, 20 May 2025 21:49:11 +0200 Subject: [PATCH 2/5] BIP-374: avoid using sys.path[0] to find current working directory This approach is incompatible with the sys.path extension approach in the next commit which is used to to find the vendored copy of secp256k1lab, so use __file__ instead which works as well. --- bip-0374/gen_test_vectors.py | 6 +++--- bip-0374/run_test_vectors.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bip-0374/gen_test_vectors.py b/bip-0374/gen_test_vectors.py index 792a59a45b..6fc40f2ce4 100755 --- a/bip-0374/gen_test_vectors.py +++ b/bip-0374/gen_test_vectors.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Generate the BIP-0374 test vectors.""" import csv -import os +from pathlib import Path import sys from reference import ( TaggedHash, @@ -14,8 +14,8 @@ NUM_SUCCESS_TEST_VECTORS = 8 DLEQ_TAG_TESTVECTORS_RNG = "BIP0374/testvectors_rng" -FILENAME_GENERATE_PROOF_TEST = os.path.join(sys.path[0], 'test_vectors_generate_proof.csv') -FILENAME_VERIFY_PROOF_TEST = os.path.join(sys.path[0], 'test_vectors_verify_proof.csv') +FILENAME_GENERATE_PROOF_TEST = Path(__file__).parent / 'test_vectors_generate_proof.csv' +FILENAME_VERIFY_PROOF_TEST = Path(__file__).parent / 'test_vectors_verify_proof.csv' def random_scalar_int(vector_i, purpose): diff --git a/bip-0374/run_test_vectors.py b/bip-0374/run_test_vectors.py index 4831fbe20b..cb8e24c16e 100755 --- a/bip-0374/run_test_vectors.py +++ b/bip-0374/run_test_vectors.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Run the BIP-DLEQ test vectors.""" import csv -import os +from pathlib import Path import sys from reference import ( dleq_generate_proof, @@ -10,8 +10,8 @@ from secp256k1 import GE -FILENAME_GENERATE_PROOF_TEST = os.path.join(sys.path[0], 'test_vectors_generate_proof.csv') -FILENAME_VERIFY_PROOF_TEST = os.path.join(sys.path[0], 'test_vectors_verify_proof.csv') +FILENAME_GENERATE_PROOF_TEST = Path(__file__).parent / 'test_vectors_generate_proof.csv' +FILENAME_VERIFY_PROOF_TEST = Path(__file__).parent / 'test_vectors_verify_proof.csv' all_passed = True From 459d977d9b78d28332cf9db0b16b57c20a8b3e4d Mon Sep 17 00:00:00 2001 From: Sebastian Falbesoner Date: Tue, 20 May 2025 22:05:28 +0200 Subject: [PATCH 3/5] BIP-374: replace secp256k1.py with vendored copy of secp256k1lab --- bip-0374/gen_test_vectors.py | 2 +- bip-0374/reference.py | 6 +- bip-0374/run_test_vectors.py | 2 +- bip-0374/secp256k1.py | 360 ----------------------------------- 4 files changed, 7 insertions(+), 363 deletions(-) delete mode 100755 bip-0374/secp256k1.py diff --git a/bip-0374/gen_test_vectors.py b/bip-0374/gen_test_vectors.py index 6fc40f2ce4..bbd874666a 100755 --- a/bip-0374/gen_test_vectors.py +++ b/bip-0374/gen_test_vectors.py @@ -8,7 +8,7 @@ dleq_generate_proof, dleq_verify_proof, ) -from secp256k1 import G as GENERATOR, GE +from secp256k1lab.secp256k1 import G as GENERATOR, GE NUM_SUCCESS_TEST_VECTORS = 8 diff --git a/bip-0374/reference.py b/bip-0374/reference.py index e0fcbeaf97..ff9f22eb38 100755 --- a/bip-0374/reference.py +++ b/bip-0374/reference.py @@ -3,11 +3,15 @@ """Reference implementation of DLEQ BIP for secp256k1 with unit tests.""" from hashlib import sha256 +from pathlib import Path import random -from secp256k1 import G, GE import sys import unittest +# Prefer the vendored copy of secp256k1lab +sys.path.insert(0, str(Path(__file__).parent / "secp256k1lab/src")) +from secp256k1lab.secp256k1 import G, GE + DLEQ_TAG_AUX = "BIP0374/aux" DLEQ_TAG_NONCE = "BIP0374/nonce" diff --git a/bip-0374/run_test_vectors.py b/bip-0374/run_test_vectors.py index cb8e24c16e..54349aed48 100755 --- a/bip-0374/run_test_vectors.py +++ b/bip-0374/run_test_vectors.py @@ -7,7 +7,7 @@ dleq_generate_proof, dleq_verify_proof, ) -from secp256k1 import GE +from secp256k1lab.secp256k1 import GE FILENAME_GENERATE_PROOF_TEST = Path(__file__).parent / 'test_vectors_generate_proof.csv' diff --git a/bip-0374/secp256k1.py b/bip-0374/secp256k1.py deleted file mode 100755 index b83d028f92..0000000000 --- a/bip-0374/secp256k1.py +++ /dev/null @@ -1,360 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright (c) 2022-2023 The Bitcoin Core developers -# Distributed under the MIT software license, see the accompanying -# file COPYING or http://www.opensource.org/licenses/mit-license.php. - -"""Test-only implementation of low-level secp256k1 field and group arithmetic - -It is designed for ease of understanding, not performance. - -WARNING: This code is slow and trivially vulnerable to side channel attacks. Do not use for -anything but tests. - -Exports: -* FE: class for secp256k1 field elements -* GE: class for secp256k1 group elements -* G: the secp256k1 generator point -""" - -import unittest -from hashlib import sha256 - -class FE: - """Objects of this class represent elements of the field GF(2**256 - 2**32 - 977). - - They are represented internally in numerator / denominator form, in order to delay inversions. - """ - - # The size of the field (also its modulus and characteristic). - SIZE = 2**256 - 2**32 - 977 - - def __init__(self, a=0, b=1): - """Initialize a field element a/b; both a and b can be ints or field elements.""" - if isinstance(a, FE): - num = a._num - den = a._den - else: - num = a % FE.SIZE - den = 1 - if isinstance(b, FE): - den = (den * b._num) % FE.SIZE - num = (num * b._den) % FE.SIZE - else: - den = (den * b) % FE.SIZE - assert den != 0 - if num == 0: - den = 1 - self._num = num - self._den = den - - def __add__(self, a): - """Compute the sum of two field elements (second may be int).""" - if isinstance(a, FE): - return FE(self._num * a._den + self._den * a._num, self._den * a._den) - return FE(self._num + self._den * a, self._den) - - def __radd__(self, a): - """Compute the sum of an integer and a field element.""" - return FE(a) + self - - def __sub__(self, a): - """Compute the difference of two field elements (second may be int).""" - if isinstance(a, FE): - return FE(self._num * a._den - self._den * a._num, self._den * a._den) - return FE(self._num - self._den * a, self._den) - - def __rsub__(self, a): - """Compute the difference of an integer and a field element.""" - return FE(a) - self - - def __mul__(self, a): - """Compute the product of two field elements (second may be int).""" - if isinstance(a, FE): - return FE(self._num * a._num, self._den * a._den) - return FE(self._num * a, self._den) - - def __rmul__(self, a): - """Compute the product of an integer with a field element.""" - return FE(a) * self - - def __truediv__(self, a): - """Compute the ratio of two field elements (second may be int).""" - return FE(self, a) - - def __pow__(self, a): - """Raise a field element to an integer power.""" - return FE(pow(self._num, a, FE.SIZE), pow(self._den, a, FE.SIZE)) - - def __neg__(self): - """Negate a field element.""" - return FE(-self._num, self._den) - - def __int__(self): - """Convert a field element to an integer in range 0..p-1. The result is cached.""" - if self._den != 1: - self._num = (self._num * pow(self._den, -1, FE.SIZE)) % FE.SIZE - self._den = 1 - return self._num - - def sqrt(self): - """Compute the square root of a field element if it exists (None otherwise). - - Due to the fact that our modulus is of the form (p % 4) == 3, the Tonelli-Shanks - algorithm (https://en.wikipedia.org/wiki/Tonelli-Shanks_algorithm) is simply - raising the argument to the power (p + 1) / 4. - - To see why: (p-1) % 2 = 0, so 2 divides the order of the multiplicative group, - and thus only half of the non-zero field elements are squares. An element a is - a (nonzero) square when Euler's criterion, a^((p-1)/2) = 1 (mod p), holds. We're - looking for x such that x^2 = a (mod p). Given a^((p-1)/2) = 1, that is equivalent - to x^2 = a^(1 + (p-1)/2) mod p. As (1 + (p-1)/2) is even, this is equivalent to - x = a^((1 + (p-1)/2)/2) mod p, or x = a^((p+1)/4) mod p.""" - v = int(self) - s = pow(v, (FE.SIZE + 1) // 4, FE.SIZE) - if s**2 % FE.SIZE == v: - return FE(s) - return None - - def is_square(self): - """Determine if this field element has a square root.""" - # A more efficient algorithm is possible here (Jacobi symbol). - return self.sqrt() is not None - - def is_even(self): - """Determine whether this field element, represented as integer in 0..p-1, is even.""" - return int(self) & 1 == 0 - - def __eq__(self, a): - """Check whether two field elements are equal (second may be an int).""" - if isinstance(a, FE): - return (self._num * a._den - self._den * a._num) % FE.SIZE == 0 - return (self._num - self._den * a) % FE.SIZE == 0 - - def to_bytes(self): - """Convert a field element to a 32-byte array (BE byte order).""" - return int(self).to_bytes(32, 'big') - - @staticmethod - def from_bytes(b): - """Convert a 32-byte array to a field element (BE byte order, no overflow allowed).""" - v = int.from_bytes(b, 'big') - if v >= FE.SIZE: - return None - return FE(v) - - def __str__(self): - """Convert this field element to a 64 character hex string.""" - return f"{int(self):064x}" - - def __repr__(self): - """Get a string representation of this field element.""" - return f"FE(0x{int(self):x})" - - -class GE: - """Objects of this class represent secp256k1 group elements (curve points or infinity) - - Normal points on the curve have fields: - * x: the x coordinate (a field element) - * y: the y coordinate (a field element, satisfying y^2 = x^3 + 7) - * infinity: False - - The point at infinity has field: - * infinity: True - """ - - # Order of the group (number of points on the curve, plus 1 for infinity) - ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - - # Number of valid distinct x coordinates on the curve. - ORDER_HALF = ORDER // 2 - - def __init__(self, x=None, y=None): - """Initialize a group element with specified x and y coordinates, or infinity.""" - if x is None: - # Initialize as infinity. - assert y is None - self.infinity = True - else: - # Initialize as point on the curve (and check that it is). - fx = FE(x) - fy = FE(y) - assert fy**2 == fx**3 + 7 - self.infinity = False - self.x = fx - self.y = fy - - def __add__(self, a): - """Add two group elements together.""" - # Deal with infinity: a + infinity == infinity + a == a. - if self.infinity: - return a - if a.infinity: - return self - if self.x == a.x: - if self.y != a.y: - # A point added to its own negation is infinity. - assert self.y + a.y == 0 - return GE() - else: - # For identical inputs, use the tangent (doubling formula). - lam = (3 * self.x**2) / (2 * self.y) - else: - # For distinct inputs, use the line through both points (adding formula). - lam = (self.y - a.y) / (self.x - a.x) - # Determine point opposite to the intersection of that line with the curve. - x = lam**2 - (self.x + a.x) - y = lam * (self.x - x) - self.y - return GE(x, y) - - @staticmethod - def mul(*aps): - """Compute a (batch) scalar group element multiplication. - - GE.mul((a1, p1), (a2, p2), (a3, p3)) is identical to a1*p1 + a2*p2 + a3*p3, - but more efficient.""" - # Reduce all the scalars modulo order first (so we can deal with negatives etc). - naps = [(a % GE.ORDER, p) for a, p in aps] - # Start with point at infinity. - r = GE() - # Iterate over all bit positions, from high to low. - for i in range(255, -1, -1): - # Double what we have so far. - r = r + r - # Add then add the points for which the corresponding scalar bit is set. - for (a, p) in naps: - if (a >> i) & 1: - r += p - return r - - def __rmul__(self, a): - """Multiply an integer with a group element.""" - if self == G: - return FAST_G.mul(a) - return GE.mul((a, self)) - - def __neg__(self): - """Compute the negation of a group element.""" - if self.infinity: - return self - return GE(self.x, -self.y) - - def __sub__(self, a): - """Subtract a group element from another.""" - return self + (-a) - - def to_bytes_compressed(self): - """Convert a non-infinite group element to 33-byte compressed encoding.""" - assert not self.infinity - return bytes([3 - self.y.is_even()]) + self.x.to_bytes() - - def to_bytes_uncompressed(self): - """Convert a non-infinite group element to 65-byte uncompressed encoding.""" - assert not self.infinity - return b'\x04' + self.x.to_bytes() + self.y.to_bytes() - - def to_bytes_xonly(self): - """Convert (the x coordinate of) a non-infinite group element to 32-byte xonly encoding.""" - assert not self.infinity - return self.x.to_bytes() - - @staticmethod - def lift_x(x): - """Return group element with specified field element as x coordinate (and even y).""" - y = (FE(x)**3 + 7).sqrt() - if y is None: - return None - if not y.is_even(): - y = -y - return GE(x, y) - - @staticmethod - def from_bytes(b): - """Convert a compressed or uncompressed encoding to a group element.""" - assert len(b) in (33, 65) - if len(b) == 33: - if b[0] != 2 and b[0] != 3: - return None - x = FE.from_bytes(b[1:]) - if x is None: - return None - r = GE.lift_x(x) - if r is None: - return None - if b[0] == 3: - r = -r - return r - else: - if b[0] != 4: - return None - x = FE.from_bytes(b[1:33]) - y = FE.from_bytes(b[33:]) - if y**2 != x**3 + 7: - return None - return GE(x, y) - - @staticmethod - def from_bytes_xonly(b): - """Convert a point given in xonly encoding to a group element.""" - assert len(b) == 32 - x = FE.from_bytes(b) - if x is None: - return None - return GE.lift_x(x) - - @staticmethod - def is_valid_x(x): - """Determine whether the provided field element is a valid X coordinate.""" - return (FE(x)**3 + 7).is_square() - - def __str__(self): - """Convert this group element to a string.""" - if self.infinity: - return "(inf)" - return f"({self.x},{self.y})" - - def __repr__(self): - """Get a string representation for this group element.""" - if self.infinity: - return "GE()" - return f"GE(0x{int(self.x):x},0x{int(self.y):x})" - -# The secp256k1 generator point -G = GE.lift_x(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798) - - -class FastGEMul: - """Table for fast multiplication with a constant group element. - - Speed up scalar multiplication with a fixed point P by using a precomputed lookup table with - its powers of 2: - - table = [P, 2*P, 4*P, (2^3)*P, (2^4)*P, ..., (2^255)*P] - - During multiplication, the points corresponding to each bit set in the scalar are added up, - i.e. on average ~128 point additions take place. - """ - - def __init__(self, p): - self.table = [p] # table[i] = (2^i) * p - for _ in range(255): - p = p + p - self.table.append(p) - - def mul(self, a): - result = GE() - a = a % GE.ORDER - for bit in range(a.bit_length()): - if a & (1 << bit): - result += self.table[bit] - return result - -# Precomputed table with multiples of G for fast multiplication -FAST_G = FastGEMul(G) - -class TestFrameworkSecp256k1(unittest.TestCase): - def test_H(self): - H = sha256(G.to_bytes_uncompressed()).digest() - assert GE.lift_x(FE.from_bytes(H)) is not None - self.assertEqual(H.hex(), "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0") From 436a3dd1faaf088cbc190271b05164384b3c047e Mon Sep 17 00:00:00 2001 From: Sebastian Falbesoner Date: Thu, 15 Jan 2026 00:56:13 +0100 Subject: [PATCH 4/5] BIP-374: use `tagged_hash` and `xor_bytes` routines from secp256k1lab --- bip-0374/gen_test_vectors.py | 7 +++---- bip-0374/reference.py | 20 ++++---------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/bip-0374/gen_test_vectors.py b/bip-0374/gen_test_vectors.py index bbd874666a..a828074e4a 100755 --- a/bip-0374/gen_test_vectors.py +++ b/bip-0374/gen_test_vectors.py @@ -2,13 +2,12 @@ """Generate the BIP-0374 test vectors.""" import csv from pathlib import Path -import sys from reference import ( - TaggedHash, dleq_generate_proof, dleq_verify_proof, ) from secp256k1lab.secp256k1 import G as GENERATOR, GE +from secp256k1lab.util import tagged_hash NUM_SUCCESS_TEST_VECTORS = 8 @@ -19,12 +18,12 @@ def random_scalar_int(vector_i, purpose): - rng_out = TaggedHash(DLEQ_TAG_TESTVECTORS_RNG, purpose.encode() + vector_i.to_bytes(4, 'little')) + rng_out = tagged_hash(DLEQ_TAG_TESTVECTORS_RNG, purpose.encode() + vector_i.to_bytes(4, 'little')) return int.from_bytes(rng_out, 'big') % GE.ORDER def random_bytes(vector_i, purpose): - rng_out = TaggedHash(DLEQ_TAG_TESTVECTORS_RNG, purpose.encode() + vector_i.to_bytes(4, 'little')) + rng_out = tagged_hash(DLEQ_TAG_TESTVECTORS_RNG, purpose.encode() + vector_i.to_bytes(4, 'little')) return rng_out diff --git a/bip-0374/reference.py b/bip-0374/reference.py index ff9f22eb38..8068b9c25c 100755 --- a/bip-0374/reference.py +++ b/bip-0374/reference.py @@ -2,7 +2,6 @@ """Reference implementation of DLEQ BIP for secp256k1 with unit tests.""" -from hashlib import sha256 from pathlib import Path import random import sys @@ -11,6 +10,7 @@ # Prefer the vendored copy of secp256k1lab sys.path.insert(0, str(Path(__file__).parent / "secp256k1lab/src")) from secp256k1lab.secp256k1 import G, GE +from secp256k1lab.util import tagged_hash, xor_bytes DLEQ_TAG_AUX = "BIP0374/aux" @@ -18,18 +18,6 @@ DLEQ_TAG_CHALLENGE = "BIP0374/challenge" -def TaggedHash(tag: str, data: bytes) -> bytes: - ss = sha256(tag.encode()).digest() - ss += ss - ss += data - return sha256(ss).digest() - - -def xor_bytes(lhs: bytes, rhs: bytes) -> bytes: - assert len(lhs) == len(rhs) - return bytes([lhs[i] ^ rhs[i] for i in range(len(lhs))]) - - def dleq_challenge( A: GE, B: GE, C: GE, R1: GE, R2: GE, m: bytes | None, G: GE, ) -> int: @@ -37,7 +25,7 @@ def dleq_challenge( assert len(m) == 32 m = bytes([]) if m is None else m return int.from_bytes( - TaggedHash( + tagged_hash( DLEQ_TAG_CHALLENGE, A.to_bytes_compressed() + B.to_bytes_compressed() @@ -63,9 +51,9 @@ def dleq_generate_proof( assert len(m) == 32 A = a * G C = a * B - t = xor_bytes(a.to_bytes(32, "big"), TaggedHash(DLEQ_TAG_AUX, r)) + t = xor_bytes(a.to_bytes(32, "big"), tagged_hash(DLEQ_TAG_AUX, r)) m_prime = bytes([]) if m is None else m - rand = TaggedHash( + rand = tagged_hash( DLEQ_TAG_NONCE, t + A.to_bytes_compressed() + C.to_bytes_compressed() + m_prime ) k = int.from_bytes(rand, "big") % GE.ORDER From 2b7f07986b5f62753486e5fd9db59c189eed946e Mon Sep 17 00:00:00 2001 From: Sebastian Falbesoner Date: Thu, 15 Jan 2026 01:51:12 +0100 Subject: [PATCH 5/5] BIP-374: mention secp256k1lab in BIP text --- bip-0374.mediawiki | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bip-0374.mediawiki b/bip-0374.mediawiki index 840bbf815d..a8588b96e8 100644 --- a/bip-0374.mediawiki +++ b/bip-0374.mediawiki @@ -116,6 +116,10 @@ This proposal is compatible with all older clients. == Test Vectors and Reference Code == A reference python implementation is included [https://github.com/bitcoin/bips/blob/master/bip-0374/reference.py here]. +It uses a vendored copy of the [https://github.com/secp256k1lab/secp256k1lab/ secp256k1lab] library at version 1.0.0 +(commit [https://github.com/secp256k1lab/secp256k1lab/commit/44dc4bd893b8f03e621585e3bf255253e0e0fbfb +44dc4bd893b8f03e621585e3bf255253e0e0fbfb]). + Test vectors can be generated by running ./bip-0374/gen_test_vectors.py which will produce a CSV file of random test vectors for both generating and verifying proofs. These can be run against the reference implementation with ./bip-0374/run_test_vectors.py. == Changelog ==