Digital Signatures in Python: ECDSA vs EdDSA vs RSA-PSS, Nonce Reuse Attacks, and RFC 6979

ECDSA vs EdDSA vs RSA-PSS explained with theory and Python: hash-and-sign, EUF-CMA, the ECDSA nonce reuse attack (PlayStation 3), RFC 6979 deterministic nonces, Ed25519, and PKCS#1 v1.5 vs PSS. Includes a runnable from-scratch ECDSA sign/verify and a cryptography-library example (Ed25519PrivateKey, padding.PSS).

Why Learn Digital Signatures?

A digital signature gives you three guarantees at once: authenticity (the message really came from this sender), integrity (it was not modified in transit), and non-repudiation (the signer cannot later deny signing). TLS server certificates, JWTs (ES256 / RS256), SSH host-key authentication, signed Git commits, Bitcoin and Ethereum transactions, and OS code signing all rest on signatures. Almost all trust in modern infrastructure is built on top of them.

Signatures are an application of public-key cryptography. They lean on the same hard problems as RSA (integer factorization) and elliptic curve cryptography (ECC) (the elliptic curve discrete logarithm problem, ECDLP), but now to produce a tag that only the private-key holder can create and anyone can verify with the public key. This article lays out the general signature model and the security notion (EUF-CMA), compares the three workhorse schemes — ECDSA, EdDSA (Ed25519), and RSA-PSS — with equations, and then implements a from-scratch educational ECDSA in Python that we actually run to confirm sign → verify → tamper detection. For a map of the whole field, see the Cryptography Roadmap .

The Signature Model

A signature scheme is a triple of algorithms \((\mathsf{Gen}, \mathsf{Sign}, \mathsf{Verify})\) .

  1. Key generation \(\mathsf{Gen}\) : produce a key pair \((sk, pk)\) . The signing key \(sk\) is secret; the verification key \(pk\) is public.
  2. Signing \(\mathsf{Sign}(sk, m) \to \sigma\) : produce a signature \(\sigma\) over message \(m\) .
  3. Verification \(\mathsf{Verify}(pk, m, \sigma) \to \{0, 1\}\) : check whether \((m, \sigma)\) is valid under \(pk\) .

Correctness requires that honestly generated signatures always verify:

\[ \Pr[\mathsf{Verify}(pk, m, \mathsf{Sign}(sk, m)) = 1] = 1 \tag{1} \]

The hash-and-sign paradigm

Real schemes do not sign the message \(m\) directly. They first compress it with a cryptographic hash \(H\) into a fixed-length digest \(e = H(m)\) , then sign that. This is hash-and-sign:

\[ \sigma = \mathsf{Sign}(sk, H(m)) \tag{2} \]

It buys three things:

  • Arbitrary-length messages: the signature math only handles a fixed-size input (an integer below \(N\) for RSA, below the order \(n\) for ECDSA); the hash absorbs any length.
  • Performance: even a huge file needs just one hash and one signing operation.
  • Security separation: if \(H\) is collision-resistant, the scheme’s security reduces to signing a short digest. Conversely, breaking \(H\) breaks the signature — as in the 2008 rogue-CA attack that exploited MD5 collisions.

SHA-256 and SHA-512 are today’s defaults. You can inspect digests with the hash generator tool .

Signatures vs MACs — Non-Repudiation

A MAC (Message Authentication Code) such as HMAC — also used in AES / ChaCha20 AEAD — also protects integrity. But a MAC and a digital signature differ in a fundamental way.

AspectMAC (e.g. HMAC)Digital signature
KeyShared secret (symmetric)Sign with private, verify with public
Who can verifyOnly holders of the shared keyAnyone with the public key
Non-repudiationNo (both parties hold the key)Yes (only the private-key holder signs)
Typical useIn-session integrity/authCertificates, contracts, public proof

The decisive difference is non-repudiation. Because the sender and receiver share the same HMAC key, the receiver can forge the same tag — so they cannot prove to a third party that the sender produced it. With a digital signature, only the private-key holder can sign, and anyone with the public key can verify. That lets you prove “this party signed this” to a court or any third party, which is exactly why PKI certificates and e-contracts use signatures. See also zero trust security and the security certifications post .

The Security Notion: EUF-CMA

What “secure” means for a signature scheme is pinned down by EUF-CMA (Existential Unforgeability under Chosen-Message Attack). Consider this game against an adversary \(\mathcal{A}\) :

  1. The challenger runs \(\mathsf{Gen}\) and hands \(pk\) to \(\mathcal{A}\) .
  2. \(\mathcal{A}\) may query a signing oracle on any messages \(m_1, m_2, \dots\) of its choice and receive \(\sigma_i = \mathsf{Sign}(sk, m_i)\) (this is the chosen-message part).
  3. Finally \(\mathcal{A}\) tries to output a valid signature \(\sigma^\*\) on a fresh message \(m^\*\) it never queried.
\[ \Pr[\mathsf{Verify}(pk, m^\*, \sigma^\*) = 1 \; \land \; m^\* \notin \{m_i\}] \le \mathsf{negl}(\lambda) \tag{3} \]

If this probability is negligible, the scheme is EUF-CMA secure. “Existential” is the strongest bar: the adversary loses even if it forges any one fresh signature, not a specific chosen message. RSA-PSS, ECDSA, and Ed25519 are all provably EUF-CMA secure under appropriate assumptions (the RSA assumption, ECDLP, and the random-oracle model). What breaks that guarantee in practice is implementation error — like the ECDSA nonce reuse below, which violates the proof’s premises and enables forgery.

ECDSA: Elliptic Curve Digital Signature Algorithm

ECDSA is the signature scheme over ECC , used across TLS certificates, SSH, Bitcoin, and Ethereum. Fix the domain parameters: prime field \(\mathbb{F}_p\) , base point \(G\) , order \(n\) .

Key generation:

Signing — for message digest \(e = H(m)\) :

  1. Pick a nonce \(k \in [1, n-1]\) .
  2. Compute \(R = kG\) and \(r = R_x \bmod n\) (retry if \(r = 0\) ).
  3. Compute the following (retry if \(s = 0\) ). The signature is \((r, s)\) .
\[ s = k^{-1}(e + r d) \bmod n \tag{4} \]

Verification — check \((r, s) \in [1, n-1]\) and:

\[ w = s^{-1} \bmod n, \quad u_1 = e w \bmod n, \quad u_2 = r w \bmod n \tag{5} \] \[ (x, y) = u_1 G + u_2 Q, \qquad x \bmod n \overset{?}{=} r \tag{6} \]

Correctness follows by substitution. From \(s = k^{-1}(e + rd)\) we get \(k = w e + w r d = u_1 + u_2 d\) , so

\[ u_1 G + u_2 Q = u_1 G + u_2 d G = (u_1 + u_2 d) G = k G = R \tag{7} \]

and the \(x\) -coordinate matches \(r\) .

Nonce reuse and bias are fatal

The single biggest pitfall in ECDSA is the nonce \(k\) . It must be uniformly random and secret per signature; any leakage recovers the private key instantly.

(a) Reusing k: sign two different messages \(m_1, m_2\) with the same \(k\) and \(r\) is shared:

\[ s_1 = k^{-1}(e_1 + rd), \quad s_2 = k^{-1}(e_2 + rd) \tag{8} \]

Subtract to get \(s_1 - s_2 = k^{-1}(e_1 - e_2)\) , hence

\[ k = \frac{e_1 - e_2}{s_1 - s_2} \bmod n, \qquad d = \frac{s_1 k - e_1}{r} \bmod n \tag{9} \]

so both the nonce \(k\) and the private key \(d\) are fully exposed. In 2010, Sony’s PlayStation 3 used a constant \(k\) for its ECDSA code signing; fail0verflow / geohot extracted the signing key and could forge valid signatures for arbitrary code.

(b) Biased k: you do not even need full reuse. If the top bits of \(k\) are biased or partially known, a lattice attack (solving the Hidden Number Problem with LLL / BKZ) recovers \(d\) from a handful of signatures. Weak RNGs and imperfect constant-time code have leaked keys this way repeatedly.

RFC 6979: deterministic nonces

RFC 6979 eliminates this structural risk with deterministic ECDSA: derive \(k\) from the private key \(d\) and the digest \(H(m)\) via HMAC-DRBG instead of an RNG.

\[ k = \mathrm{HMAC\text{-}DRBG}(d, H(m)) \tag{10} \]

Now (1) the same \((d, m)\) always gives the same \(k\) while different messages get cryptographically independent \(k\) — no reuse, no bias; and (2) security no longer depends on RNG quality, so even low-entropy embedded devices are safe. Signatures become reproducible, which also simplifies testing. Modern ECDSA (OpenSSL, cryptography, Bitcoin libraries) uses RFC 6979. “Hedged” variants additionally mix in randomness to resist fault injection.

EdDSA / Ed25519

EdDSA (Edwards-curve Digital Signature Algorithm), and specifically Ed25519 (over the twisted Edwards form of Curve25519 with SHA-512), fixes ECDSA’s weaknesses by design. It is the default signature for SSH, Signal, WireGuard, TLS 1.3, and Git.

Key generation: hash the secret seed \(sk\) with SHA-512; clamp the first 32 bytes into the scalar \(a\) and keep the second 32 bytes as a “prefix” \(\mathrm{pre}\) . Public key \(A = aB\) (with base point \(B\) ).

Signing — for message \(M\) :

\[ r = H(\mathrm{pre} \,\|\, M) \bmod \ell, \quad R = rB \tag{11} \] \[ S = (r + H(R \,\|\, A \,\|\, M) \cdot a) \bmod \ell \tag{12} \]

The signature is \((R, S)\) — 64 bytes total. Verification: check \(8 S B \overset{?}{=} 8 R + 8 H(R \| A \| M) A\) .

The crucial point: Ed25519’s \(r\) — the analogue of ECDSA’s nonce \(k\) — is derived deterministically from the secret prefix and the message (Eq. 11), with no RNG involved at all. Consequently:

  • Nonce reuse and bias are structurally impossible — RFC 6979’s idea is baked into the scheme itself.
  • High misuse resistance — Curve25519’s constant-time Edwards arithmetic resists side channels, and invalid-curve attacks are ruled out by design.
  • Fast — friendly to batch verification and fast fixed-base multiplication; often faster than ECDSA at equal security.
AspectECDSAEd25519 (EdDSA)
NonceRandom (made deterministic by RFC 6979)Always deterministic (built in)
Misuse resistanceLow (frequent nonce disasters)High (misuse-resistant)
CurveP-256 / secp256k1, variableCurve25519, fixed
HashAny (e.g. SHA-256)SHA-512, fixed
Signature size~64–72 bytes (DER)64 bytes, fixed
SpeedBaselineGenerally faster

For a curve-based signature in any new protocol, Ed25519 is the 2026 default. ECDSA remains where you need existing compatibility — TLS certificate chains, Bitcoin’s secp256k1, and so on.

RSA Signatures: PKCS#1 v1.5 vs RSA-PSS

RSA can sign too. Naively, apply the private exponent \(d\) to the digest (the “decrypt” operation) and undo it with the public exponent \(e\) :

\[ \sigma = (H(m))^{d} \bmod N, \qquad \sigma^{e} \bmod N \overset{?}{=} H(m) \tag{13} \]

But “textbook RSA signatures” are forgeable, so the padding scheme is what actually matters. There are two standards.

PKCS#1 v1.5

RSA PKCS#1 v1.5 signatures (as in JWT RS256) prepend a fixed padding string and a hash-algorithm identifier (an ASN.1 DigestInfo) before exponentiating. It is deterministic and simple, but because the padding is structured, sloppy verifiers have allowed the Bleichenbacher signature forgery (BB'06): with public exponent \(e=3\) and a verifier that does not strictly check padding, an attacker could fabricate a signature just by taking a cube root. It also has no tight security reduction, so it is “not formally guaranteed secure.” Yet it is still everywhere — JWT RS256, older TLS certificates, and countless legacy systems.

RSA-PSS

RSA-PSS (Probabilistic Signature Scheme) (JWT PS256) is the Bellare–Rogaway probabilistic padding: it mixes in a random salt so each signature differs, stretches the hash with MGF1, applies EMSA-PSS encoding, and then exponentiates.

\[ \mathrm{EM} = \mathrm{EMSA\text{-}PSS\text{-}ENCODE}(H(m), \text{salt}), \qquad \sigma = \mathrm{EM}^{d} \bmod N \tag{14} \]

Why PSS is preferred:

  • Provable security: a tight EUF-CMA reduction to the RSA assumption in the random-oracle model. v1.5 has no such guarantee.
  • Robustness via salt: being probabilistic, the same message yields a different signature each time, closing off attacks that target deterministic padding.
  • Standards direction: NIST FIPS 186-5, TLS 1.3, and modern PKI recommend or require PSS.

For any new RSA signature, choose PSS. PKCS#1 v1.5 should be treated as legacy, kept only for backward compatibility.

Comparing the Three Schemes

ItemRSA-PSS (3072-bit)ECDSA (P-256)Ed25519
Hard problemFactoring / RSA assumptionECDLPECDLP (Curve25519)
Public key size384 bytes33 bytes (compressed)32 bytes
Signature size384 bytes~64–72 bytes (DER)64 bytes
Signing speedSlowFastVery fast
Verification speedVery fastBaselineFast (batch verifiable)
Implementation riskMedium (padding matters)High (nonce disasters)Low (misuse-resistant)
DeterministicNo (PSS uses a salt)Random (deterministic via RFC 6979)Yes (built in)
Typical useLegacy TLS certs, JWTTLS, SSH, crypto, JWTSSH, Signal, WireGuard, Git

Rules of thumb:

  • New projects: prefer Ed25519. If you need curve compatibility or existing PKI, use ECDSA (P-256).
  • Where RSA is mandatory (HSMs, old CAs): use RSA-PSS, avoid v1.5.
  • Verification-heavy workloads (clients validating many certs): RSA verification is a cheap \(e = 65537\) exponentiation and can win.
  • Cryptocurrency: Bitcoin and Ethereum sign with ECDSA over secp256k1 (Bitcoin also adopted Schnorr with Taproot).

As a concrete case, OAuth 2.0 / OIDC JWTs use the algorithm identifiers RS256 (RSA v1.5), PS256 (RSA-PSS), ES256 (P-256 ECDSA), and EdDSA (Ed25519).

Python Implementation

1. From-scratch educational ECDSA (verified by running it)

We implement ECDSA over the tiny curve \(y^2 = x^3 + 2x + 2 \pmod{17}\) with base point \(G = (5, 1)\) (order \(n = 19\) ). It transcribes Eqs. (4)–(6) directly and uses double-and-add for scalar multiplication. This code was actually run to confirm sign → verify → tamper detection, plus private-key recovery from nonce reuse.

import hashlib

# --- curve over a small prime field: y^2 = x^3 + 2x + 2 over F_17 ---
p = 17
a, b = 2, 2
G = (5, 1)   # base point
n = 19       # order of G (this point has order 19)
O = None     # point at infinity


def ec_add(P, Q):
    """point addition on the curve (inverse via pow(x, -1, p))"""
    if P is O:
        return Q
    if Q is O:
        return P
    x1, y1 = P
    x2, y2 = Q
    if x1 == x2 and (y1 + y2) % p == 0:
        return O  # P + (-P) = O
    if P == Q:
        lam = (3 * x1 * x1 + a) * pow(2 * y1, -1, p) % p  # tangent
    else:
        lam = (y2 - y1) * pow(x2 - x1, -1, p) % p         # chord
    x3 = (lam * lam - x1 - x2) % p
    y3 = (lam * (x1 - x3) - y1) % p
    return (x3, y3)


def scalar_mult(k, P):
    """double-and-add: kP in O(log k) point operations"""
    R, Q = O, P
    while k:
        if k & 1:
            R = ec_add(R, Q)
        Q = ec_add(Q, Q)
        k >>= 1
    return R


def hash_to_int(msg):
    return int.from_bytes(hashlib.sha256(msg).digest(), "big") % n


def sign(d, msg, k):
    """signing: s = k^{-1}(e + rd) mod n  ... Eq. (4)"""
    e = hash_to_int(msg)
    r = scalar_mult(k, G)[0] % n
    assert r != 0
    s = pow(k, -1, n) * (e + r * d) % n
    assert s != 0
    return (r, s)


def verify(Q, msg, sig):
    """verification: x-coord of u1 G + u2 Q equals r  ... Eqs. (5), (6)"""
    r, s = sig
    if not (1 <= r < n and 1 <= s < n):
        return False
    e = hash_to_int(msg)
    w = pow(s, -1, n)
    u1, u2 = e * w % n, r * w % n
    X = ec_add(scalar_mult(u1, G), scalar_mult(u2, Q))
    return X is not O and X[0] % n == r


# --- key generation ---
d = 7                       # private key
Q = scalar_mult(d, G)       # public key Q = dG
assert scalar_mult(n, G) is O   # confirm G has order n

# --- sign and verify ---
msg = b"transfer 1 coin to Bob"
sig = sign(d, msg, k=11)
print("public key Q  =", Q)
print("signature     =", sig)
print("valid verify  :", verify(Q, msg, sig))                        # True
print("tamper detect :", verify(Q, b"transfer 1 coin to Eve", sig))  # False

# --- nonce reuse -> private-key recovery (Eq. (9)) ---
msg2 = b"transfer 2 coin to Bob"
sig2 = sign(d, msg2, k=11)  # SAME k reused (dangerous!)
r, s1 = sig
_, s2 = sig2
e1, e2 = hash_to_int(msg), hash_to_int(msg2)
k_rec = (e1 - e2) * pow(s1 - s2, -1, n) % n
d_rec = (s1 * k_rec - e1) * pow(r, -1, n) % n
print("recovered nonce k =", k_rec, "(true 11)")
print("recovered priv  d =", d_rec, "(true 7)")

Actual output:

public key Q  = (0, 6)
signature     = (13, 14)
valid verify  : True
tamper detect : False
recovered nonce k = 11 (true 11)
recovered priv  d = 7 (true 7)

The valid signature is accepted, a one-character edit is rejected, and reusing nonce \(k = 11\) just twice lets Eq. (9) recover the private key \(d = 7\) completely — the exact mechanism behind the PlayStation 3 break. This code is educational: it omits the huge real-world prime, constant-time arithmetic, DER encoding, and proper nonce generation.

The geometric reason for the leak is simple. Line one of signing, r = scalar_mult(k, G)[0] % n, shows that \(r\) is just the \(x\) -coordinate of the point \(R = kG\) , which depends on \(k\) alone. Reuse the same \(k\) and \(R\) (hence \(r\) ) is identical regardless of the message. The figure below plots all 18 points of our toy curve over \(\mathbb{F}_{17}\) , marking the base point \(G\) , the public key \(Q = dG\) , and the point \(R = kG\) shared by both signatures. Because \(R\) collapses to a single point, the two equations in Eq. (8) become linearly dependent in \(k\) — a subtraction is all it takes to solve for \(k\) , then \(d\) .

All 18 points of the elliptic curve over F_17. Base point G, public key Q = dG, and the point R = kG shared by two signatures that reused nonce k = 11. R landing on the same point is the geometric root cause of the key leak

2. Production library: ECDSA / Ed25519 / RSA-PSS with cryptography (verified by running it)

In production, use a vetted library. Below are all three schemes with cryptography (v49.0), actually run in a pip install cryptography environment, confirming that all three schemes sign and verify correctly.

# Install with pip install cryptography. Actually run below; output confirmed.
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
from cryptography.hazmat.primitives import hashes
from cryptography.exceptions import InvalidSignature

msg = b"contract: pay 100 USD"

# --- (a) ECDSA (P-256): nonce generated safely (RFC 6979 style) inside ---
ec_key = ec.generate_private_key(ec.SECP256R1())
ec_sig = ec_key.sign(msg, ec.ECDSA(hashes.SHA256()))
ec_key.public_key().verify(ec_sig, msg, ec.ECDSA(hashes.SHA256()))

# --- (b) Ed25519: deterministic, minimal API ---
ed_key = ed25519.Ed25519PrivateKey.generate()
ed_sig = ed_key.sign(msg)                       # no hash to choose
ed_key.public_key().verify(ed_sig, msg)         # raises InvalidSignature on failure

# --- (c) RSA-PSS: use PSS + MGF1, not v1.5 ---
rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
pss = padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                  salt_length=padding.PSS.MAX_LENGTH)
rsa_sig = rsa_key.sign(msg, pss, hashes.SHA256())

try:
    rsa_key.public_key().verify(rsa_sig, msg, pss, hashes.SHA256())
    print("all signatures verified")
except InvalidSignature:
    print("verification failed")

Actual output:

all signatures verified

All three schemes were actually confirmed sign → verify successfully (signature sizes: ECDSA 71 bytes DER over P-256, Ed25519 64 bytes, RSA-PSS 3072-bit 384 bytes). Key points: (a) the library’s ECDSA handles nonce generation safely, giving the caller no chance to mishandle \(k\) ; (b) Ed25519 offers no choice of hash, curve, or nonce — just sign(msg) / verify(sig, msg), a “hard to misuse” minimal API; (c) with RSA, spell out padding.PSS(...) and avoid the legacy padding.PKCS1v15() for new work. The library absorbs the landmines that from-scratch code steps on (nonce disasters, padding oracles), which is the main reason to use it in production.

3. RSA-PSS’s salt produces a different signature every time (verified by running it)

As discussed above, RSA-PSS mixes in a random salt on every signing operation, so the same message under the same key yields a different signature byte string each time — a decisive difference from PKCS#1 v1.5. The code below signs the same message three times with PSS and three times with PKCS#1 v1.5, and actually compares the byte strings.

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

msg = b"contract: pay 100 USD"
key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
pss = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH)
pkcs = padding.PKCS1v15()

pss_sigs = [key.sign(msg, pss, hashes.SHA256()) for _ in range(3)]
pkcs_sigs = [key.sign(msg, pkcs, hashes.SHA256()) for _ in range(3)]

print("PSS: sig #1 == sig #2?         =", pss_sigs[0] == pss_sigs[1])
print("PSS: sig #2 == sig #3?         =", pss_sigs[1] == pss_sigs[2])
for i, s in enumerate(pss_sigs):
    print(f"PSS sig #{i+1} first 16 bytes    =", s[:16].hex())
print("PKCS#1v1.5: all 3 identical?   =", pkcs_sigs[0] == pkcs_sigs[1] == pkcs_sigs[2])
print("PKCS#1v1.5 sig first 16 bytes  =", pkcs_sigs[0][:16].hex())

pub = key.public_key()
for s in pss_sigs:
    pub.verify(s, msg, pss, hashes.SHA256())
print("all 3 PSS signatures verify    = True")

Actual output:

PSS: sig #1 == sig #2?         = False
PSS: sig #2 == sig #3?         = False
PSS sig #1 first 16 bytes    = 8cdb06bda9d86ba627170766e2a8f18c
PSS sig #2 first 16 bytes    = 64700c4da2807f7118e925c1d3beda94
PSS sig #3 first 16 bytes    = 231abcedf07f9e46e73337903a07107f
PKCS#1v1.5: all 3 identical?   = True
PKCS#1v1.5 sig first 16 bytes  = 71585006d01054e887ceb8dd1f42f458

Despite the same private key and the same message, the three PSS signatures differ completely from the very first byte. PKCS#1 v1.5, in contrast, produces byte-for-byte identical output all three times (expected for deterministic padding — and exactly the property that structured-padding attacks exploit). All three PSS signatures still verify successfully, empirically confirming PSS’s probabilistic security property: the shape changes every time, yet every instance is accepted as a valid signature.

4. Benchmarking sign/verify speed across all three schemes (verified by running it)

Finally, we measure sign and verify time for RSA-PSS (3072-bit), ECDSA (P-256), and Ed25519 on the same message (time.perf_counter(), median of 200 runs after 20 warm-up iterations, Python 3.14 / cryptography 49.0; absolute numbers are hardware-dependent since this ran on a single machine).

import time, statistics
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
from cryptography.hazmat.primitives import hashes

msg = b"contract: pay 100 USD"
N, WARMUP = 200, 20

def bench(sign_fn, verify_fn):
    for _ in range(WARMUP):
        verify_fn(sign_fn())
    t_sign = []
    sig = None
    for _ in range(N):
        t0 = time.perf_counter()
        sig = sign_fn()
        t_sign.append(time.perf_counter() - t0)
    t_verify = []
    for _ in range(N):
        t0 = time.perf_counter()
        verify_fn(sig)
        t_verify.append(time.perf_counter() - t0)
    return statistics.median(t_sign) * 1000, statistics.median(t_verify) * 1000

rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
pss = padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH)
rsa_s, rsa_v = bench(lambda: rsa_key.sign(msg, pss, hashes.SHA256()),
                     lambda sig: rsa_key.public_key().verify(sig, msg, pss, hashes.SHA256()))

ec_key = ec.generate_private_key(ec.SECP256R1())
ec_s, ec_v = bench(lambda: ec_key.sign(msg, ec.ECDSA(hashes.SHA256())),
                   lambda sig: ec_key.public_key().verify(sig, msg, ec.ECDSA(hashes.SHA256())))

ed_key = ed25519.Ed25519PrivateKey.generate()
ed_s, ed_v = bench(lambda: ed_key.sign(msg), lambda sig: ed_key.public_key().verify(sig, msg))

print(f"RSA-PSS(3072): sign={rsa_s:.4f} ms  verify={rsa_v:.4f} ms")
print(f"ECDSA(P-256):  sign={ec_s:.4f} ms  verify={ec_v:.4f} ms")
print(f"Ed25519:       sign={ed_s:.4f} ms  verify={ed_v:.4f} ms")

Actual output:

RSA-PSS(3072): sign=2.6224 ms  verify=0.1067 ms
ECDSA(P-256):  sign=0.0256 ms  verify=0.0701 ms
Ed25519:       sign=0.0919 ms  verify=0.2025 ms

Median signing and verification time for RSA-PSS (3072-bit), ECDSA (P-256), and Ed25519 (log scale, N=200). RSA-PSS signing is roughly two orders of magnitude slower than the others but verification is near the fastest; ECDSA and Ed25519 sign fast but verify somewhat slower

The rule of thumb from the comparison table — “RSA signs slowly but verifies quickly; ECDSA/Ed25519 sign quickly” — held up under this measurement. RSA-PSS signing (2.62 ms) was roughly 30–100x slower than ECDSA (0.026 ms) or Ed25519 (0.092 ms), while its verification (0.107 ms) was near the fastest of the three. Note that the relative ordering of ECDSA and Ed25519 depends heavily on implementation, CPU, and OpenSSL version. In this environment (cryptography’s OpenSSL backend), P-256 signing came out slightly faster than Ed25519 — because OpenSSL ships highly optimized assembly for P-256 — so “Ed25519 is always theoretically faster than ECDSA” is not a claim you can make unconditionally. In practice, prioritize the misuse-resistance and determinism properties discussed in this article over raw benchmark numbers, which are a secondary consideration.

Recent Research (2024–2025)

The ECDSA nonce problem is not merely theoretical — it continues to surface as real vulnerabilities and active attack research in 2024–2025.

  • CVE-2024-31497 (Bäumer & Brinkmann, 2024): Researchers at Ruhr University Bochum found that the SSH client PuTTY (v0.68–0.80) generated NIST P-521 ECDSA nonces with the top 9 bits always zero. With roughly 60 signatures — obtainable from signed Git commits or communication with a malicious server — an attacker can fully recover the private key. The same flawed nonce-generation code also affected FileZilla, WinSCP, TortoiseGit, and TortoiseSVN. This is a real 2024 instance of the “(b) biased k” scenario discussed above, not just theory. PuTTY 0.81 fixed it by switching to RFC 6979.
  • Gao, Wang, Hu, & He (2024): “Attacking ECDSA with Nonce Leakage by Lattice Sieving” (ASIACRYPT 2024) combines Fourier-analysis-based attacks on the Hidden Number Problem with lattice sieving, recovering private keys from fewer leaked bits and fewer signatures than prior techniques. This is recent research substantiating this article’s discussion of lattice attacks.
  • Dinu (2025): “Migration to Post-Quantum Cryptography: From ECDSA to ML-DSA” (IACR ePrint 2025/2025) discusses the implementation and side-channel challenges of migrating ECDSA signatures — widely used in secure boot, TPMs, and disk encryption — to NIST’s standardized post-quantum signature scheme ML-DSA (FIPS 204). NIST formally published ML-DSA (FIPS 204) and SLH-DSA (FIPS 205) as post-quantum digital signature standards in August 2024, and migrating away from ECDSA / Ed25519 / RSA-PSS will be a major theme in cryptographic engineering for years to come.

All three findings reinforce this article’s central claim — that a signature scheme’s security lives or dies on the randomness or determinism of its nonce — and suggest the same implementation discipline (deterministic derivation, constant-time arithmetic, side-channel hardening) will keep mattering after the PQC transition.

Summary

  • A digital signature is the triple \((\mathsf{Gen}, \mathsf{Sign}, \mathsf{Verify})\) ; hash-and-sign compresses any-length messages to a fixed digest before signing.
  • Unlike a MAC, it provides non-repudiation (only the private-key holder can sign; anyone can verify with the public key). The security bar is EUF-CMA.
  • ECDSA works via Eqs. (4)–(6), but nonce reuse or bias leaks the private key instantly (PlayStation 3, lattice attacks, and 2024’s CVE-2024-31497). RFC 6979 deterministic nonces prevent this structurally.
  • Ed25519 (EdDSA) bakes deterministic nonces into the scheme and beats ECDSA on misuse resistance (our benchmark shows sign/verify speed ranking is implementation-dependent) — the default for new protocols.
  • For RSA signatures, prefer provably secure RSA-PSS over PKCS#1 v1.5 — we confirmed by running it that the salt makes every signature of the same message different.
  • Choose among RSA-PSS / ECDSA / Ed25519 by use case; see the comparison table for TLS, JWT, SSH, and cryptocurrency examples. Migrating from ECDSA to post-quantum ML-DSA is an emerging concern.
  • In Python, learn with the from-scratch ECDSA (run and verified, including private-key recovery from nonce reuse) and ship with cryptography’s ec / ed25519 / padding.PSS (signing, verification, and benchmarking all verified by running them).

References

  • Johnson, D., Menezes, A., & Vanstone, S. (2001). “The Elliptic Curve Digital Signature Algorithm (ECDSA)”. International Journal of Information Security, 1(1), 36–63.
  • Bernstein, D. J., Duif, N., Lange, T., Schwabe, P., & Yang, B.-Y. (2012). “High-speed high-security signatures”. Journal of Cryptographic Engineering, 2(2), 77–89.
  • Josefsson, S., & Liusvaara, I. (2017). “Edwards-Curve Digital Signature Algorithm (EdDSA)”. RFC 8032.
  • Pornin, T. (2013). “Deterministic Usage of DSA and ECDSA”. RFC 6979.
  • Bellare, M., & Rogaway, P. (1996). “The Exact Security of Digital Signatures — How to Sign with RSA and Rabin”. EUROCRYPT ‘96.
  • Moriarty, K. et al. (2016). “PKCS #1: RSA Cryptography Specifications Version 2.2”. RFC 8017.
  • NIST (2023). “Digital Signature Standard (DSS)”. FIPS 186-5.
  • Bäumer, F., & Brinkmann, M. (2024). “CVE-2024-31497: Secret Key Recovery of NIST P-521 Private Keys Through Biased ECDSA Nonces in PuTTY Client”. Ruhr-Universität Bochum.
  • Gao, Y., Wang, J., Hu, H., & He, B. (2024). “Attacking ECDSA with Nonce Leakage by Lattice Sieving: Bridging the Gap with Fourier Analysis-Based Attacks”. ASIACRYPT 2024.
  • Dinu, D. (2025). “Migration to Post-Quantum Cryptography: From ECDSA to ML-DSA”. Cryptology ePrint Archive, Paper 2025/2025.
  • NIST (2024). “Module-Lattice-Based Digital Signature Standard”. FIPS 204.