Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ matrix:
- python: "3.6"
- python: "3.7"
- python: "3.8"
- python: "3.9-dev"
- python: "pypy2.7-6.0"
- python: "pypy3.5-6.0"
allow_failures:
- python: "3.9-dev"
- python: "3.9"
- python: "3.10"
- python: "3.11"
- python: "3.12"
- python: "3.13"
- python: "pypy2.7-7.3.9"
- python: "pypy3.10"

script:
- python -m unittest discover
40 changes: 30 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@

We tried other Python libraries such as [python-ecdsa], [fast-ecdsa] and other less famous ones, but we didn't find anything that suited our needs. The first one was pure Python, but it was too slow. The second one mixed Python and C and it was really fast, but we were unable to use it in our current infrastructure, which required pure Python code.

For this reason, we decided to create something simple, compatible with OpenSSL and fast using elegant math such as Jacobian Coordinates to speed up the ECDSA. Starkbank-ECDSA is fully compatible with Python2 and Python3.
For this reason, we decided to create something simple, compatible with OpenSSL and fast using elegant math such as Jacobian Coordinates to speed up the ECDSA. Starkbank-ECDSA is fully compatible with Python 2.7 and Python 3.

### Security

starkbank-ecdsa includes the following security features:

- **Hedged RFC 6979 nonces**: Deterministic k derivation with fresh random entropy mixed into K-init (RFC 6979 §3.6), eliminating the catastrophic risk of nonce reuse that leaks private keys while preserving protection even if the RNG fails
- **Low-S signature normalization**: Prevents signature malleability (BIP-62)
- **Public key on-curve validation**: Blocks invalid-curve attacks during verification
- **Montgomery ladder scalar multiplication**: Constant-operation point multiplication to mitigate timing side channels
- **Hash truncation**: Correctly handles hash functions larger than the curve order (e.g. SHA-512 with secp256k1)
- **Extended Euclidean modular inverse**: Implemented in pure Python for portability (Python 2.7+ and 3.x); transparently uses the C-level `pow(x, -1, n)` fast path on CPython 3.8+ for a roughly order-of-magnitude speedup over Fermat's little theorem on 256-bit operands

### Installation

Expand All @@ -16,19 +27,21 @@ pip install starkbank-ecdsa

### Curves

We currently support `secp256k1`, but you can add more curves to the project. You just need to use the curve.add() function.
We currently support `secp256k1` and `prime256v1` (P-256), but you can add more curves to the project. You just need to use the curve.add() function.

### Speed

We ran a test on a MAC Pro i7 2017. The libraries were run 100 times and the averages displayed bellow were obtained:
We ran a test on an Apple Silicon Mac with Python 3.14. The libraries were run 500 times on secp256k1 with SHA-256 and deterministic (RFC 6979) nonces, and the averages displayed below were obtained:

| Library | sign | verify |
|-----------------|:------:|:------:|
| [python-ecdsa] | ~1.0ms | ~3.6ms |
| [fast-ecdsa] | ~1.0ms | ~1.3ms |
| starkbank-ecdsa | ~0.6ms | ~1.7ms |

| Library | sign | verify |
| ------------------ |:-------------:| -------:|
| [python-ecdsa] | 121.3ms | 65.1ms |
| [fast-ecdsa] | 0.1ms | 0.2ms |
| starkbank-ecdsa | 4.1ms | 7.8ms |
Our pure Python code cannot compete with C-based libraries backed by GMP's hand-tuned assembly, but it matches the fastest pure-Python implementation on signing and is roughly `30%` faster on verification.

Our pure Python code cannot compete with C based libraries, but it's `6x faster` to verify and `23x faster` to sign than other pure Python libraries.
Performance is driven by Jacobian coordinates, a branch-balanced Montgomery ladder for variable-base scalar multiplication, a precomputed affine table of powers-of-two multiples of the generator (`[G, 2G, 4G, …, 2ⁿG]`) combined with a width-2 NAF of the scalar to eliminate doublings during signing, a mixed affine+Jacobian addition fast path, curve-specific shortcuts in point doubling (A=0 for secp256k1, A=-3 for prime256v1), the secp256k1 GLV endomorphism to split 256-bit scalars into two ~128-bit halves for a 4-scalar simultaneous multi-exponentiation during verification, Shamir's trick with Joint Sparse Form as the fallback path for curves without an efficient endomorphism, and the extended Euclidean algorithm for modular inversion.

### Sample Code

Expand Down Expand Up @@ -219,7 +232,14 @@ python3 -m unittest discover
python2 -m unittest discover
```

### Run benchmark

```
python3 benchmark.py
python2 benchmark.py
```


[python-ecdsa]: https://github.com/warner/python-ecdsa
[python-ecdsa]: https://github.com/tlsfuzzer/python-ecdsa
[fast-ecdsa]: https://github.com/AntonKueltz/fastecdsa
[Stark Bank]: https://starkbank.com
102 changes: 102 additions & 0 deletions benchmark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import time
from hashlib import sha256
from ellipticcurve import Ecdsa, PrivateKey


ROUNDS = 500
MESSAGE = "This is a benchmark test message"


def benchmarkStarkbank():
privateKey = PrivateKey()
publicKey = privateKey.publicKey()

sig = Ecdsa.sign(MESSAGE, privateKey)
Ecdsa.verify(MESSAGE, sig, publicKey)

start = time.time()
for _ in range(ROUNDS):
sig = Ecdsa.sign(MESSAGE, privateKey)
signTime = (time.time() - start) / ROUNDS * 1000

start = time.time()
for _ in range(ROUNDS):
Ecdsa.verify(MESSAGE, sig, publicKey)
verifyTime = (time.time() - start) / ROUNDS * 1000

return signTime, verifyTime


def benchmarkPythonEcdsa():
try:
from ecdsa import SigningKey, SECP256k1
except ImportError:
return None, None

sk = SigningKey.generate(curve=SECP256k1)
vk = sk.verifying_key
data = MESSAGE.encode()

sig = sk.sign_deterministic(data, hashfunc=sha256)
vk.verify(sig, data, hashfunc=sha256)

start = time.time()
for _ in range(ROUNDS):
sig = sk.sign_deterministic(data, hashfunc=sha256)
signTime = (time.time() - start) / ROUNDS * 1000

start = time.time()
for _ in range(ROUNDS):
vk.verify(sig, data, hashfunc=sha256)
verifyTime = (time.time() - start) / ROUNDS * 1000

return signTime, verifyTime


def benchmarkFastEcdsa():
try:
from fastecdsa import curve, ecdsa, keys
except ImportError:
return None, None

privateKey, publicKey = keys.gen_keypair(curve.secp256k1)

r, s = ecdsa.sign(MESSAGE, privateKey, curve=curve.secp256k1)
ecdsa.verify((r, s), MESSAGE, publicKey, curve=curve.secp256k1)

start = time.time()
for _ in range(ROUNDS):
r, s = ecdsa.sign(MESSAGE, privateKey, curve=curve.secp256k1)
signTime = (time.time() - start) / ROUNDS * 1000

start = time.time()
for _ in range(ROUNDS):
ecdsa.verify((r, s), MESSAGE, publicKey, curve=curve.secp256k1)
verifyTime = (time.time() - start) / ROUNDS * 1000

return signTime, verifyTime


def formatTime(ms):
return "n/a" if ms is None else "{:.1f}ms".format(ms)


def main():
results = [
("python-ecdsa", benchmarkPythonEcdsa()),
("fast-ecdsa", benchmarkFastEcdsa()),
("starkbank-ecdsa", benchmarkStarkbank()),
]

print("")
print("ECDSA benchmark on secp256k1 ({} rounds)".format(ROUNDS))
print("-" * 48)
print("{:<20} {:>12} {:>12}".format("library", "sign", "verify"))
print("-" * 48)
for name, (signMs, verifyMs) in results:
print("{:<20} {:>12} {:>12}".format(name, formatTime(signMs), formatTime(verifyMs)))
print("")


if __name__ == "__main__":
main()
20 changes: 18 additions & 2 deletions ellipticcurve/curve.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# coding: utf-8
#
# Elliptic Curve Equation
#
Expand All @@ -9,15 +10,19 @@

class CurveFp:

def __init__(self, A, B, P, N, Gx, Gy, name, oid, nistName=None):
def __init__(self, A, B, P, N, Gx, Gy, name, oid, nistName=None, glvParams=None):
self.A = A
self.B = B
self.P = P
self.N = N
self.nBitLength = N.bit_length()
self.G = Point(Gx, Gy)
self.name = name
self.nistName = nistName
self.oid = oid # ASN.1 Object Identifier
# GLV endomorphism parameters (only for curves that support one,
# e.g. secp256k1). None means no endomorphism; fall back to Shamir+JSF.
self.glvParams = glvParams

def contains(self, p):
"""
Expand Down Expand Up @@ -69,7 +74,18 @@ def getByOid(oid):
N=0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141,
Gx=0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,
Gy=0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8,
oid=[1, 3, 132, 0, 10]
oid=[1, 3, 132, 0, 10],
# GLV endomorphism φ((x,y)) = (β·x, y), equivalent to λ·P.
# Basis vectors from Gauss reduction; used to split a 256-bit scalar k
# into two ~128-bit scalars (k1, k2) with k ≡ k1 + k2·λ (mod N).
glvParams={
"beta": 0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee,
"lambda": 0x5363ad4cc05c30e0a5261c028812645a122e22ea20816678df02967c1b23bd72,
"a1": 0x3086d221a7d46bcde86c90e49284eb15,
"b1": -0xe4437ed6010e88286f547fa90abfe4c3,
"a2": 0x114ca50f7a8e2f3f657c1108d9d44cfd8,
"b2": 0x3086d221a7d46bcde86c90e49284eb15,
},
)

prime256v1 = CurveFp(
Expand Down
29 changes: 19 additions & 10 deletions ellipticcurve/ecdsa.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,46 @@ class Ecdsa:

@classmethod
def sign(cls, message, privateKey, hashfunc=sha256):
byteMessage = hashfunc(toBytes(message)).digest()
numberMessage = numberFromByteString(byteMessage)
curve = privateKey.curve
byteMessage = hashfunc(toBytes(message)).digest()
numberMessage = numberFromByteString(byteMessage, curve.nBitLength)

r, s, randSignPoint = 0, 0, None
kIterator = RandomInteger.rfc6979(byteMessage, privateKey.secret, curve, hashfunc)
while r == 0 or s == 0:
randNum = RandomInteger.between(1, curve.N - 1)
randSignPoint = Math.multiply(curve.G, n=randNum, A=curve.A, P=curve.P, N=curve.N)
randNum = next(kIterator)
randSignPoint = Math.multiplyGenerator(curve, randNum)
r = randSignPoint.x % curve.N
s = ((numberMessage + r * privateKey.secret) * (Math.inv(randNum, curve.N))) % curve.N
recoveryId = randSignPoint.y & 1
if randSignPoint.y > curve.N:
if randSignPoint.x >= curve.N:
recoveryId += 2
if s > curve.N // 2:
s = curve.N - s
recoveryId ^= 1

return Signature(r=r, s=s, recoveryId=recoveryId)

@classmethod
def verify(cls, message, signature, publicKey, hashfunc=sha256):
byteMessage = hashfunc(toBytes(message)).digest()
numberMessage = numberFromByteString(byteMessage)
curve = publicKey.curve
byteMessage = hashfunc(toBytes(message)).digest()
numberMessage = numberFromByteString(byteMessage, curve.nBitLength)
r = signature.r
s = signature.s

if not 1 <= r <= curve.N - 1:
return False
if not 1 <= s <= curve.N - 1:
return False
if not curve.contains(publicKey.point):
return False
inv = Math.inv(s, curve.N)
u1 = Math.multiply(curve.G, n=(numberMessage * inv) % curve.N, N=curve.N, A=curve.A, P=curve.P)
u2 = Math.multiply(publicKey.point, n=(r * inv) % curve.N, N=curve.N, A=curve.A, P=curve.P)
v = Math.add(u1, u2, A=curve.A, P=curve.P)
v = Math.multiplyAndAdd(
curve.G, (numberMessage * inv) % curve.N,
publicKey.point, (r * inv) % curve.N,
curve=curve,
)
if v.isAtInfinity():
return False
return v.x % curve.N == r
Loading
Loading