A truly minimal Python secp256k1 ECDSA? I wrote one.
Ever wondered how Bitcoin or Ethereum signatures work? I demystified secp256k1 ECDSA by building a minimal, pure Python version from scratch. Let's learn!
Alex Kaczmarek
A software engineer and cryptography enthusiast passionate about demystifying complex technologies.
If you've ever touched the world of cryptocurrencies like Bitcoin or Ethereum, you've undoubtedly encountered the term secp256k1 ECDSA. It's the cryptographic engine under the hood, the magic that lets you sign transactions and prove ownership of your assets without revealing your private key. For most of us, it’s a black box. We import a library, call `sign()` or `verify()`, and trust that it all works.
But what's really going on inside that box? I found myself asking this question one too many times. The documentation for standard libraries is excellent for application, but not always for fundamental understanding. I wanted to peel back the layers and see the raw mechanics. So, I did what any curious developer would do: I decided to build it myself. From scratch. In pure Python, with no cryptographic dependencies.
This post is the story of that journey. We'll walk through the creation of a truly minimal Python implementation of secp256k1 ECDSA. This isn't about creating a production-ready library; it's about demystifying the elegant mathematics behind it and turning a cryptographic black box into a clear glass one. Let's dive in.
What is Elliptic Curve Cryptography (ECC)? A 10,000-Foot View
At its heart, public-key cryptography relies on a "trapdoor" function: something that's easy to do in one direction but incredibly difficult to reverse. For RSA, this is integer factorization. For Elliptic Curve Cryptography (ECC), the trapdoor is the Elliptic Curve Discrete Logarithm Problem (ECDLP).
Imagine a set of points defined by a specific equation, like y² = x³ + ax + b
. ECC defines a special way to "add" two points on the curve to get a third point, also on the curve. Scalar multiplication is just repeated addition: `k * P = P + P + ... + P` (k times). The trapdoor is this: given a starting point `P` (the "generator") and a final point `Q`, it's easy to calculate `Q = k * P` if you know the integer `k`. However, it's computationally infeasible to find `k` if you only know `Q` and `P`. In this system, `k` is your private key, and `Q` is your public key.
Why secp256k1? The Bitcoin Curve
There are many different elliptic curves, each with unique properties. secp256k1 is the specific curve chosen by Satoshi Nakamoto for Bitcoin and later adopted by Ethereum and many others. The name breaks down like this:
- sec: Standards for Efficient Cryptography.
- p: The coordinates are in a prime field.
- 256: The prime is 256 bits in size.
- k: It's a Koblitz curve, which has special properties allowing for more efficient computation.
- 1: It was the first (and only) standardized curve of this type.
It was chosen for its high level of security and performance, providing strong cryptographic guarantees with relatively small key sizes compared to RSA.
The Building Blocks: Our Minimal Python Code
Our goal is clarity, not optimization. We'll implement the necessary functions to perform key generation, signing, and verification. We'll only use Python's built-in `hashlib` for hashing the message and `secrets` for generating a cryptographically secure nonce.
Step 1: The Finite Field and The Curve Equation
First, we define the parameters of the secp256k1 curve. The equation is y² ≡ x³ + 7 (mod p)
. We need to define the massive prime `p`, the generator point `G`, and the order of the group `n`.
# secp256k1 curve parameters
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
A = 0
B = 7
# Generator point G
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
G = (Gx, Gy)
# Order of the group
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
Step 2: Point Addition and Scalar Multiplication
This is the core of ECC. We need a function for point addition (`P1 + P2`) and a function for scalar multiplication (`k * P`). Point addition has a few cases: adding a point to the point at infinity (our identity element, `None`), adding a point to itself (point doubling), and adding two different points.
The math involves calculating slopes and performing modular arithmetic. A key helper function is the modular inverse, which we can calculate efficiently using Python 3.8+'s `pow(x, -1, p)`.
def modular_inverse(a, n):
return pow(a, -1, n)
def point_add(P, Q):
# ... (implementation of geometric point addition)
def scalar_multiply(k, P):
result = None # Point at infinity
current = P
while k > 0:
if k & 1: # If bit is 1
result = point_add(result, current)
current = point_add(current, current) # Double the point
k >>= 1 # Move to the next bit
return result
The `scalar_multiply` function uses the efficient double-and-add algorithm. This is our one-way function: easy to compute, hard to reverse.
Step 3: Generating Keys (Private and Public)
This step is surprisingly simple once you have scalar multiplication:
- Private Key (d): A cryptographically secure random integer between 1 and `N-1`.
- Public Key (Q): The result of scalar multiplication: `Q = d * G`.
import secrets
def generate_keypair():
private_key = secrets.randbelow(N - 1) + 1
public_key = scalar_multiply(private_key, G)
return private_key, public_key
That's it! `private_key` is just a big number, and `public_key` is a point `(x, y)` on the curve.
Step 4: The ECDSA Signing Algorithm
To sign a message, you need the message hash (`z`) and your private key (`d`). The process is as follows:
- Generate a secret, single-use random number `k` (the nonce). This must be truly random and never reused with the same private key!
- Calculate the point `R = k * G`. The x-coordinate of this point is `r`.
- Calculate `s = (z + r * d) * k⁻¹ (mod N)`.
- The signature is the pair `(r, s)`.
import hashlib
def sign(private_key, message):
z = int.from_bytes(hashlib.sha256(message).digest(), 'big')
k = secrets.randbelow(N - 1) + 1
R = scalar_multiply(k, G)
r = R[0] % N
k_inv = modular_inverse(k, N)
s = (k_inv * (z + r * private_key)) % N
return (r, s)
Step 5: The ECDSA Verification Algorithm
To verify a signature `(r, s)`, you need the message hash `z` and the public key `Q`. The magic of the math allows you to verify the signature without ever knowing the private key.
- Calculate `w = s⁻¹ (mod N)`.
- Calculate `u1 = z * w (mod N)`.
- Calculate `u2 = r * w (mod N)`.
- Calculate the point `P = u1 * G + u2 * Q`.
- The signature is valid if the x-coordinate of `P` is equal to `r`.
Why does this work? With a bit of algebraic substitution, you can see that if the signature is valid, `P` will always equal the original `R` point (`k * G`) calculated during signing.
def verify(public_key, message, signature):
z = int.from_bytes(hashlib.sha256(message).digest(), 'big')
r, s = signature
w = modular_inverse(s, N)
u1 = (z * w) % N
u2 = (r * w) % N
P1 = scalar_multiply(u1, G)
P2 = scalar_multiply(u2, public_key)
P = point_add(P1, P2)
return P[0] == r
Putting It All Together: A Complete Example
Let's combine everything into a simple script that generates a keypair, signs a message, and verifies it.
if __name__ == '__main__':
# 1. Generate keys
my_private_key, my_public_key = generate_keypair()
print(f"My Private Key: {hex(my_private_key)}")
print(f"My Public Key (x, y): ({hex(my_public_key[0])}, {hex(my_public_key[1])})")
# 2. Sign a message
message = b"A truly minimal Python secp256k1 ECDSA? I wrote one."
signature = sign(my_private_key, message)
print(f"\nSignature (r, s): ({hex(signature[0])}, {hex(signature[1])})")
# 3. Verify the signature
is_valid = verify(my_public_key, message, signature)
print(f"\nSignature valid? {is_valid}")
# 4. Tamper with the message and verify again
tampered_message = b"Someone else wrote this blog post."
is_tampered_valid = verify(my_public_key, tampered_message, signature)
print(f"Tampered message signature valid? {is_tampered_valid}")
Running this code, you'll see the signature successfully verifies for the original message but fails for the tampered one, proving our implementation works!
Why You Absolutely Shouldn't Use This in Production
I cannot stress this enough: this code is for educational purposes only. It is completely insecure for real-world applications. Production-grade libraries are incredibly complex for good reason. Here's a comparison:
Feature | Our Minimal Implementation | Production Library (e.g., `coincurve`) |
---|---|---|
Goal | Education & Understanding | Security & Performance |
Dependencies | Pure Python | Optimized C library (libsecp256k1) |
Timing Attacks | Vulnerable | Hardened with constant-time operations |
Nonce Generation (k) | Simple (`secrets` module) | RFC 6979 deterministic nonces to prevent reuse |
Code Size | ~100 lines | Thousands of lines of C and Python |
Use Case | Learning, Tinkering | Production Applications |
The most famous vulnerability related to a poor implementation is the Sony PS3 hack, which resulted from using a static value for the nonce `k`. This allowed hackers to calculate the console's private key directly from a single signature. Our code is better than that, but it's not hardened against the myriad of other side-channel attacks.
Conclusion: From Black Box to Clear Glass
Building secp256k1 ECDSA from the ground up was an incredibly rewarding experience. What once seemed like impenetrable cryptographic magic is now a logical, step-by-step process rooted in elegant mathematics. We've seen how a private key is just a number, a public key is a point on a curve, and how the properties of elliptic curve arithmetic allow us to create and verify digital signatures securely.
While we should always rely on battle-tested, peer-reviewed libraries for any real application, understanding the fundamentals is empowering. It transforms you from a mere user of technology to someone who grasps its core principles. I encourage you to play with the code, trace the functions, and truly see how the numbers flow. The black box is now open.