Chapter 33: ECC in Practice — ECDH, ECDSA, and Attacks#
33.1 Introduction#
Elliptic-curve cryptography (ECC) builds public-key protocols on top of the elliptic-curve discrete-logarithm problem (ECDLP):
Given points \(P\) and \(Q = [k]P\) on an elliptic curve \(E(\mathbb{F}_p)\), find the scalar \(k\).
The best generic algorithms for ECDLP run in \(O(\sqrt{n})\) time, which means a 256-bit elliptic-curve group offers roughly the same security as a 3072-bit RSA modulus. This chapter implements the two most widely deployed ECC protocols — ECDH (key exchange) and ECDSA (digital signatures) — and then examines attacks that exploit weak curve parameters.
All arithmetic is performed over small prime fields so that every step is transparent. We rely only on NumPy (integer arrays, modular helpers) and matplotlib (visualisation).
Prerequisites
Chapter 31 (finite-field arithmetic on curves) and Chapter 32 (group structure and the ECDLP) are assumed.
import numpy as np
import math
# ── Core elliptic-curve arithmetic over F_p ──
def mod_inv(a, p):
"""Modular inverse via extended Euclidean algorithm."""
if a % p == 0:
raise ZeroDivisionError(f'{a} has no inverse mod {p}')
g, x, _ = _extended_gcd(a % p, p)
assert g == 1
return x % p
def _extended_gcd(a, b):
if a == 0:
return b, 0, 1
g, x1, y1 = _extended_gcd(b % a, a)
return g, y1 - (b // a) * x1, x1
INF = 'O' # point at infinity
def ec_add(P, Q, a, p):
"""Add two points on y^2 = x^3 + ax + b (mod p)."""
if P == INF: return Q
if Q == INF: return P
x1, y1 = P
x2, y2 = Q
if x1 == x2 and (y1 + y2) % p == 0:
return INF
if P == Q:
lam = (3 * x1 * x1 + a) * mod_inv(2 * y1, p) % p
else:
lam = (y2 - y1) * mod_inv(x2 - x1, p) % p
x3 = (lam * lam - x1 - x2) % p
y3 = (lam * (x1 - x3) - y1) % p
return (x3, y3)
def ec_mul(k, P, a, p):
"""Scalar multiplication [k]P using double-and-add."""
if k == 0 or P == INF:
return INF
if k < 0:
return ec_mul(-k, ec_neg(P, p), a, p)
R = INF
Q = P
while k > 0:
if k & 1:
R = ec_add(R, Q, a, p)
Q = ec_add(Q, Q, a, p)
k >>= 1
return R
def ec_neg(P, p):
"""Negate a point."""
if P == INF: return INF
return (P[0], (-P[1]) % p)
def ec_order(P, a, p, max_order=None):
"""Compute order of point P by brute iteration (small groups only)."""
if P == INF: return 1
if max_order is None:
max_order = 2 * p # Hasse bound
Q = P
for n in range(2, max_order + 2):
Q = ec_add(Q, P, a, p)
if Q == INF:
return n
raise ValueError('Order not found within bound')
def curve_points(a, b, p):
"""Enumerate all affine points on y^2 = x^3 + ax + b (mod p)."""
pts = []
for x in range(p):
rhs = (x*x*x + a*x + b) % p
for y in range(p):
if (y*y) % p == rhs:
pts.append((x, y))
return pts
print('Elliptic-curve arithmetic loaded.')
Elliptic-curve arithmetic loaded.
33.2 ECDH — Elliptic-Curve Diffie–Hellman#
The Elliptic-Curve Diffie–Hellman protocol is the curve analogue of classical DH key exchange. Two parties, Alice and Bob, agree on public parameters \((E, P, n)\) where \(P\) is a base point of order \(n\).
Step |
Alice |
Bob |
|---|---|---|
1 |
Choose secret \(a\); publish \(A = [a]P\) |
Choose secret \(b\); publish \(B = [b]P\) |
2 |
Compute \(S = [a]B = [ab]P\) |
Compute \(S = [b]A = [ab]P\) |
Both parties arrive at the same shared secret \(S = [ab]P\) without ever transmitting \(a\) or \(b\). Security reduces to the Computational Diffie–Hellman (CDH) problem on the curve.
import numpy as np
import math
class ECDH:
"""Elliptic-Curve Diffie-Hellman key exchange."""
def __init__(self, a, b, p, G, n):
"""
Parameters
----------
a, b : int -- curve coefficients y^2 = x^3 + ax + b
p : int -- prime modulus
G : tuple -- base point (generator)
n : int -- order of G
"""
self.a, self.b, self.p = a, b, p
self.G, self.n = G, n
def key_gen(self, rng=None):
"""Return (private_key, public_key)."""
if rng is None:
rng = np.random.default_rng()
priv = int(rng.integers(1, self.n))
pub = ec_mul(priv, self.G, self.a, self.p)
return priv, pub
def compute_shared_secret(self, my_private, their_public):
"""Compute the shared point [my_private] * their_public."""
S = ec_mul(my_private, their_public, self.a, self.p)
if S == INF:
raise ValueError('Shared secret is the point at infinity')
return S
print('ECDH class defined.')
ECDH class defined.
import numpy as np
# ── Demo: ECDH on y^2 = x^3 + x + 4 over F_107 ──
p = 107; a_coeff = 1; b_coeff = 4
G = (0, 2) # generator (we will verify)
n = ec_order(G, a_coeff, p)
print(f'Curve: y^2 = x^3 + {a_coeff}x + {b_coeff} (mod {p})')
print(f'Generator G = {G}, order = {n}')
dh = ECDH(a_coeff, b_coeff, p, G, n)
# Alice
rng_a = np.random.default_rng(42)
a_priv, A_pub = dh.key_gen(rng_a)
print(f'\nAlice: private = {a_priv}, public A = {A_pub}')
# Bob
rng_b = np.random.default_rng(99)
b_priv, B_pub = dh.key_gen(rng_b)
print(f'Bob: private = {b_priv}, public B = {B_pub}')
# Shared secret
S_alice = dh.compute_shared_secret(a_priv, B_pub)
S_bob = dh.compute_shared_secret(b_priv, A_pub)
print(f'\nAlice computes S = {S_alice}')
print(f'Bob computes S = {S_bob}')
print(f'Shared secrets match: {S_alice == S_bob}')
Curve: y^2 = x^3 + 1x + 4 (mod 107)
Generator G = (0, 2), order = 107
Alice: private = 10, public A = (89, 57)
Bob: private = 102, public B = (104, 9)
Alice computes S = (38, 68)
Bob computes S = (38, 68)
Shared secrets match: True
Why ECDH beats classical DH
For 128-bit security, classical DH requires a 3072-bit prime, while ECDH needs only a 256-bit curve. Key generation and shared-secret computation are both faster and produce shorter keys.
33.3 ECDSA — Elliptic-Curve Digital Signature Algorithm#
ECDSA adapts the classical DSA scheme to elliptic curves. Public parameters are the same as for ECDH: \((E, P, n)\) with \(P\) of prime order \(n\).
Key generation. Choose private key \(d \in \{1,\dots,n-1\}\); publish \(Q = [d]P\).
Signing a message \(m\):
Compute hash \(e = H(m)\).
Pick random \(k \in \{1,\dots,n-1\}\).
Compute \(R = [k]P\); set \(r = x_R \bmod n\). If \(r = 0\), go to 2.
Compute \(s = k^{-1}(e + d \cdot r) \bmod n\). If \(s = 0\), go to 2.
Signature is \((r, s)\).
Verification of \((r, s)\) on message \(m\):
Compute \(e = H(m)\).
Compute \(w = s^{-1} \bmod n\).
Compute \(u_1 = e \cdot w \bmod n\), \(u_2 = r \cdot w \bmod n\).
Compute \(R' = [u_1]P + [u_2]Q\).
Accept iff \(x_{R'} \equiv r \pmod{n}\).
import numpy as np
import math
import hashlib
class ECDSA:
"""Elliptic-Curve Digital Signature Algorithm (toy implementation)."""
def __init__(self, a, b, p, G, n):
self.a, self.b, self.p = a, b, p
self.G, self.n = G, n
# ── hash: SHA-256 truncated to group order (toy-safe) ──
def _hash(self, m):
if isinstance(m, str):
m = m.encode()
if isinstance(m, int):
m = str(m).encode()
return int(hashlib.sha256(m).hexdigest(), 16) % self.n
def key_gen(self, rng=None):
"""Return (private_key d, public_key Q)."""
if rng is None:
rng = np.random.default_rng()
d = int(rng.integers(1, self.n))
Q = ec_mul(d, self.G, self.a, self.p)
return d, Q
def sign(self, m, d, rng=None):
"""Sign message m with private key d. Return (r, s)."""
if rng is None:
rng = np.random.default_rng()
e = self._hash(m)
while True:
k = int(rng.integers(1, self.n))
R = ec_mul(k, self.G, self.a, self.p)
if R == INF:
continue
r = R[0] % self.n
if r == 0:
continue
k_inv = mod_inv(k, self.n)
s = (k_inv * (e + d * r)) % self.n
if s == 0:
continue
return (r, s)
def verify(self, m, signature, Q):
"""Verify signature (r, s) on message m with public key Q."""
r, s = signature
if not (1 <= r < self.n and 1 <= s < self.n):
return False
e = self._hash(m)
w = mod_inv(s, self.n)
u1 = (e * w) % self.n
u2 = (r * w) % self.n
R_prime = ec_add(
ec_mul(u1, self.G, self.a, self.p),
ec_mul(u2, Q, self.a, self.p),
self.a, self.p
)
if R_prime == INF:
return False
return R_prime[0] % self.n == r
print('ECDSA class defined.')
ECDSA class defined.
import numpy as np
# ── Demo: ECDSA on y^2 = x^3 + x + 4 over F_107 (prime order!) ──
p = 107; a_coeff = 1; b_coeff = 4
G = (0, 2)
n = ec_order(G, a_coeff, p)
print(f'Curve: y^2 = x^3 + {b_coeff} over F_{p}')
print(f'Generator G = {G}, order n = {n}')
dsa = ECDSA(a_coeff, b_coeff, p, G, n)
# Key generation
rng = np.random.default_rng(2024)
d, Q = dsa.key_gen(rng)
print(f'\nPrivate key d = {d}')
print(f'Public key Q = {Q}')
# Sign
msg = 'hello'
sig = dsa.sign(msg, d, np.random.default_rng(7777))
print(f'\nMessage: "{msg}"')
print(f'Signature: (r={sig[0]}, s={sig[1]})')
# Verify
ok = dsa.verify(msg, sig, Q)
bad = dsa.verify('world', sig, Q)
print(f'\nVerify "{msg}": {ok}')
print(f'Verify "world": {bad}')
Curve: y^2 = x^3 + 4 over F_107
Generator G = (0, 2), order n = 107
Private key d = 26
Public key Q = (105, 84)
Message: "hello"
Signature: (r=43, s=93)
Verify "hello": True
Verify "world": False
Nonce reuse is catastrophic
If the same nonce \(k\) is used for two different messages \(m_1, m_2\), then \(r_1 = r_2\) and an attacker can recover the private key:
This is exactly the attack that compromised the PlayStation 3 ECDSA implementation in 2010.
import numpy as np
# ── Nonce-reuse attack demonstration ──
p = 107; a_coeff = 1; b_coeff = 4
G = (0, 2)
n = ec_order(G, a_coeff, p)
dsa = ECDSA(a_coeff, b_coeff, p, G, n)
rng_key = np.random.default_rng(1234)
d_secret, Q_pub = dsa.key_gen(rng_key)
# Alice signs two messages with THE SAME nonce (bad!)
m1, m2 = 'msg_one', 'msg_two'
same_seed = 5555
sig1 = dsa.sign(m1, d_secret, np.random.default_rng(same_seed))
sig2 = dsa.sign(m2, d_secret, np.random.default_rng(same_seed))
r1, s1 = sig1
r2, s2 = sig2
e1 = dsa._hash(m1)
e2 = dsa._hash(m2)
print(f'sig1: r={r1}, s={s1} (r values match: r1==r2 is {r1==r2})')
print(f'sig2: r={r2}, s={s2}')
# Recover private key
numerator = (s2 * e1 - s1 * e2) % n
denominator = (s1 * r2 - s2 * r1) % n
d_recovered = (numerator * mod_inv(denominator, n)) % n
print(f'\nTrue private key: {d_secret}')
print(f'Recovered private key: {d_recovered}')
print(f'Attack succeeded: {d_secret == d_recovered}')
sig1: r=3, s=45 (r values match: r1==r2 is True)
sig2: r=3, s=10
True private key: 104
Recovered private key: 104
Attack succeeded: True
33.4 Security#
Comparison: RSA vs. ECC Key Sizes
The following table (based on NIST SP 800-57 recommendations) compares the key lengths needed for equivalent security levels.
Show code cell source
import numpy as np
import matplotlib.pyplot as plt
# NIST SP 800-57 recommended key sizes
security_bits = np.array([80, 112, 128, 192, 256])
rsa_bits = np.array([1024, 2048, 3072, 7680, 15360])
ecc_bits = np.array([160, 224, 256, 384, 512])
fig, ax = plt.subplots(figsize=(8, 5))
x = np.arange(len(security_bits))
w = 0.35
bars1 = ax.bar(x - w/2, rsa_bits, w, label='RSA modulus', color='#e74c3c')
bars2 = ax.bar(x + w/2, ecc_bits, w, label='ECC key', color='#2ecc71')
ax.set_xlabel('Security level (bits)')
ax.set_ylabel('Key size (bits)')
ax.set_title('RSA vs. ECC Key Sizes for Equivalent Security')
ax.set_xticks(x)
ax.set_xticklabels([str(s) for s in security_bits])
ax.legend()
ax.set_yscale('log')
# Annotate bars
for bar in bars1:
h = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2, h * 1.1, str(int(h)),
ha='center', va='bottom', fontsize=8)
for bar in bars2:
h = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2, h * 1.1, str(int(h)),
ha='center', va='bottom', fontsize=8)
plt.tight_layout()
plt.savefig('fig_ch33_rsa_vs_ecc.png', dpi=150)
plt.show()
print('At 256-bit security, RSA needs 15 360 bits vs. ECC\'s 512 bits.')
At 256-bit security, RSA needs 15 360 bits vs. ECC's 512 bits.
33.5 Attacking the ECDLP#
The security of ECDH and ECDSA rests on the hardness of the ECDLP. We now study two classical attacks:
Pohlig–Hellman decomposition (exploits smooth group order).
Pollard’s rho algorithm (generic \(O(\sqrt{n})\) attack).
A third, more theoretical attack — the MOV attack — uses pairings to transfer the ECDLP into a finite-field DLP, where sub-exponential algorithms (index calculus) apply.
import numpy as np
import math
def bsgs_ec(G, Q, a_coeff, p, n):
"""Baby-step giant-step for ECDLP: find k s.t. [k]G = Q.
Parameters
----------
G : base point
Q : target point = [k]G
a_coeff : curve parameter a
p : field prime
n : order of G
"""
m = math.isqrt(n) + 1
# Baby steps: table[j*G] = j for j = 0..m-1
table = {}
R = INF
for j in range(m):
table[R] = j
R = ec_add(R, G, a_coeff, p)
# Giant step factor: -[m]G
factor = ec_neg(ec_mul(m, G, a_coeff, p), p)
# Giant steps
gamma = Q
for i in range(m):
if gamma in table:
k = (i * m + table[gamma]) % n
return k
gamma = ec_add(gamma, factor, a_coeff, p)
raise ValueError('BSGS failed')
# Quick test
p = 97; a_c = 2; b_c = 3; G = (0, 10) # (0,10) is on y^2 = x^3+2x+3 mod 97
n = ec_order(G, a_c, p)
k_test = 17
Q_test = ec_mul(k_test, G, a_c, p)
k_found = bsgs_ec(G, Q_test, a_c, p, n)
print(f'BSGS: [k]G = Q => k = {k_found} (expected {k_test})')
BSGS: [k]G = Q => k = 17 (expected 17)
33.5.1 Pohlig–Hellman on Elliptic Curves#
If the group order \(n = |\langle P \rangle|\) factors as \(n = \prod p_i^{e_i}\) with small prime powers, then we can:
For each prime power \(p_i^{e_i}\), project the ECDLP into a subgroup of order \(p_i^{e_i}\) and solve a much smaller DLP.
Combine the results via the Chinese Remainder Theorem.
The total cost is \(O\bigl(\sum e_i (\log n + \sqrt{p_i})\bigr)\), which is devastating when all \(p_i\) are small (“smooth” order).
import numpy as np
import math
def factorise_small(n):
"""Trial-division factorisation (sufficient for small n)."""
factors = {}
d = 2
while d * d <= n:
while n % d == 0:
factors[d] = factors.get(d, 0) + 1
n //= d
d += 1
if n > 1:
factors[n] = factors.get(n, 0) + 1
return factors
def crt(residues, moduli):
"""Chinese Remainder Theorem for pairwise coprime moduli."""
M = 1
for m in moduli:
M *= m
x = 0
for r, m in zip(residues, moduli):
Mi = M // m
x += r * Mi * mod_inv(Mi, m)
return x % M
def pohlig_hellman_ec(G, Q, a_coeff, p, n):
"""Pohlig-Hellman decomposition for ECDLP: find k s.t. [k]G = Q.
Works well when the group order n has only small prime factors.
"""
factors = factorise_small(n)
residues = []
moduli = []
for pi, ei in factors.items():
pe = pi ** ei
# Project into subgroup of order pi^ei
exp = n // pe
Gi = ec_mul(exp, G, a_coeff, p) # order pi^ei
Qi = ec_mul(exp, Q, a_coeff, p)
# Solve ECDLP in small subgroup using BSGS
ki = bsgs_ec(Gi, Qi, a_coeff, p, pe)
residues.append(ki)
moduli.append(pe)
return crt(residues, moduli)
print('pohlig_hellman_ec defined.')
pohlig_hellman_ec defined.
import numpy as np
# ── Pohlig-Hellman attack on a curve with smooth order ──
# Curve: y^2 = x^3 + x + 6 over F_107
p = 101; a_c = 1; b_c = 6
pts = curve_points(a_c, b_c, p)
group_order = len(pts) # = |E(F_p)| (including identity counted separately)
# Actually we count #E = #affine_points + 1 (point at infinity)
N = len(pts) + 1
print(f'Curve: y^2 = x^3 + x + 6 (mod {p})')
print(f'#E(F_{p}) = {N}')
print(f'Factorisation of {N}: {factorise_small(N)}')
# Find a generator of large-enough order
G = pts[1] # try first non-trivial point
n = ec_order(G, a_c, p)
print(f'\nGenerator G = {G}, ord(G) = {n}')
print(f'Factorisation of order: {factorise_small(n)}')
# Secret scalar
k_secret = 73
Q = ec_mul(k_secret, G, a_c, p)
print(f'\nSecret k = {k_secret}, Q = [k]G = {Q}')
# Attack
k_found = pohlig_hellman_ec(G, Q, a_c, p, n)
print(f'Pohlig-Hellman recovered k = {k_found}')
print(f'Verification: [{k_found}]G = {ec_mul(k_found, G, a_c, p)} == Q = {Q}')
print(f'Success: {ec_mul(k_found, G, a_c, p) == Q}')
Curve: y^2 = x^3 + x + 6 (mod 101)
#E(F_101) = 112
Factorisation of 112: {2: 4, 7: 1}
Generator G = (0, 62), ord(G) = 112
Factorisation of order: {2: 4, 7: 1}
Secret k = 73, Q = [k]G = (46, 23)
Pohlig-Hellman recovered k = 73
Verification: [73]G = (46, 23) == Q = (46, 23)
Success: True
Smooth-order curves are insecure
The Pohlig–Hellman attack shows why NIST and other standards require that the curve group order contain a large prime factor. In practice, \(n\) itself is usually chosen to be prime.
33.5.2 Pollard’s Rho for ECDLP#
Pollard’s rho is a generic \(O(\sqrt{n})\) algorithm that needs only \(O(1)\) memory. The idea is to define a pseudo-random walk on the group \(\langle P \rangle\) and detect a collision.
We partition the group into three subsets \(S_0, S_1, S_2\) (e.g., by \(x \bmod 3\)) and define the iteration:
Each \(R_i = [a_i]P + [b_i]Q\) with known \(a_i, b_i\). When a collision \(R_i = R_j\) occurs, we have \(a_i - a_j \equiv k(b_j - b_i) \pmod{n}\), which reveals \(k\).
import numpy as np
def pollard_rho_ec(G, Q, a_coeff, p, n):
"""Pollard rho for ECDLP: find k s.t. [k]G = Q."""
def step(R, aR, bR):
if R == INF:
x_mod = 0
else:
x_mod = R[0] % 3
if x_mod == 0:
R = ec_add(R, Q, a_coeff, p)
bR = (bR + 1) % n
elif x_mod == 1:
R = ec_add(R, R, a_coeff, p)
aR = (2 * aR) % n
bR = (2 * bR) % n
else:
R = ec_add(R, G, a_coeff, p)
aR = (aR + 1) % n
return R, aR, bR
# Floyd's cycle detection
# Tortoise
T, aT, bT = G, 1, 0
# Hare
H, aH, bH = G, 1, 0
for _ in range(3 * n):
T, aT, bT = step(T, aT, bT)
H, aH, bH = step(*step(H, aH, bH))
if T == H:
# aT + k*bT = aH + k*bH (mod n)
db = (bT - bH) % n
da = (aH - aT) % n
if db == 0:
# Degenerate collision, restart
continue
g = math.gcd(db, n)
if g == 1:
return (da * mod_inv(db, n)) % n
else:
# Try all solutions
base = (da // g * mod_inv(db // g, n // g)) % (n // g)
for j in range(g):
candidate = (base + j * (n // g)) % n
if ec_mul(candidate, G, a_coeff, p) == Q:
return candidate
# Reset if no solution found
T, aT, bT = G, 1, 0
H, aH, bH = G, 1, 0
raise ValueError('Pollard rho did not converge')
print('pollard_rho_ec defined.')
pollard_rho_ec defined.
import numpy as np
# ── Demo: Pollard rho on a small curve ──
p = 107; a_c = 1; b_c = 4
G = (0, 2)
n = ec_order(G, a_c, p)
k_secret = 41
Q = ec_mul(k_secret, G, a_c, p)
print(f'Curve: y^2 = x^3 + x + 4 (mod {p})')
print(f'G = {G}, ord(G) = {n}')
print(f'Q = {Q} (secret k = {k_secret})')
k_found = pollard_rho_ec(G, Q, a_c, p, n)
print(f'\nPollard rho found k = {k_found}')
print(f'Verification: [{k_found}]G = {ec_mul(k_found, G, a_c, p)}')
print(f'Match: {ec_mul(k_found, G, a_c, p) == Q}')
Curve: y^2 = x^3 + x + 4 (mod 107)
G = (0, 2), ord(G) = 107
Q = (88, 98) (secret k = 41)
Pollard rho found k = 41
Verification: [41]G = (88, 98)
Match: True
Show code cell source
import numpy as np
import matplotlib.pyplot as plt
import time, math
# ── Measure Pollard-rho runtime vs. group order ──
test_curves = [
# (a, b, p, G) -- small curves with verified on-curve generators
(2, 3, 23, (1, 11)),
(2, 3, 43, (1, 7)),
(2, 3, 67, (3, 6)),
(2, 3, 97, (0, 10)),
(1, 6, 101, (0, 39)),
(2, 3, 131, (5, 20)),
(2, 3, 157, (4, 46)),
(3, 7, 199, (0, 87)),
]
orders = []
times = []
for (a_c, b_c, pp, GG) in test_curves:
nn = ec_order(GG, a_c, pp)
if nn < 5:
continue
k_test = nn // 3 + 1
Qt = ec_mul(k_test, GG, a_c, pp)
t0 = time.perf_counter()
kk = pollard_rho_ec(GG, Qt, a_c, pp, nn)
elapsed = time.perf_counter() - t0
assert ec_mul(kk, GG, a_c, pp) == Qt
orders.append(nn)
times.append(elapsed)
orders = np.array(orders, dtype=float)
times = np.array(times)
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.scatter(np.sqrt(orders), times * 1000, c='#e74c3c', s=60, zorder=5)
ax.set_xlabel(r'$\sqrt{n}$ (group order)')
ax.set_ylabel('Time (ms)')
ax.set_title('Pollard Rho: Runtime vs. $\\sqrt{n}$')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('fig_ch33_pollard_rho_timing.png', dpi=150)
plt.show()
print('Timings collected for', len(orders), 'curves.')
Timings collected for 8 curves.
33.6 The MOV Attack (Conceptual)#
The Menezes–Okamoto–Vanstone (MOV) attack (1993) reduces the ECDLP to the ordinary discrete-logarithm problem in a finite field \(\mathbb{F}_{p^k}^*\) using the Weil pairing (or Tate pairing).
Key idea. Let \(P\) have order \(n\) on \(E(\mathbb{F}_p)\) and let \(Q = [d]P\). There exists a bilinear, non-degenerate map
(the Weil pairing), where \(k\) is the embedding degree — the smallest integer such that \(n \mid p^k - 1\).
Applying the pairing:
for a suitable second point \(R\). Now we need to solve \(\alpha^d = \beta\) in \(\mathbb{F}_{p^k}^*\) — a finite-field DLP vulnerable to index-calculus methods.
When is the MOV attack practical?
The attack is efficient only when the embedding degree \(k\) is small. For supersingular curves over \(\mathbb{F}_p\) with \(p > 3\), we have \(k \leq 6\). Secure curves are chosen so that \(k\) is astronomically large (e.g., \(k \approx n\) for random curves), making the transfer to \(\mathbb{F}_{p^k}^*\) infeasible.
import numpy as np
def embedding_degree(p, n, max_k=200):
"""Find smallest k such that n | p^k - 1."""
pk = 1
for k in range(1, max_k + 1):
pk = (pk * p) % n
if pk == 1:
return k
return None # not found within bound
# ── Compute embedding degrees for various small curves ──
print(f'{"p":>6s} {"#E":>6s} {"n":>6s} {"k":>4s} MOV risk')
print('-' * 45)
test_cases = [
(97, 2, 3),
(101, 1, 6),
(211, 0, 1), # supersingular-like
(233, 1, 1),
(263, 2, 7),
]
for (pp, aa, bb) in test_cases:
pts = curve_points(aa, bb, pp)
N_E = len(pts) + 1
# Find point of large prime order
best_G = pts[0]
best_n = ec_order(pts[0], aa, pp)
for pt in pts[1:min(10, len(pts))]:
nn = ec_order(pt, aa, pp)
if nn > best_n:
best_n = nn
best_G = pt
k = embedding_degree(pp, best_n)
risk = 'HIGH' if k is not None and k <= 6 else 'low'
k_str = str(k) if k is not None else '>200'
print(f'{int(pp):>6d} {int(N_E):>6d} {int(best_n):>6d} {k_str:>4s} {risk}')
p #E n k MOV risk
---------------------------------------------
97 100 50 20 low
101 112 112 12 low
211 228 114 18 low
233 237 237 78 low
263 276 138 22 low
Show code cell source
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
# ── Conceptual diagram: MOV attack via Weil pairing ──
fig, ax = plt.subplots(figsize=(9, 4))
ax.set_xlim(-0.5, 10.5)
ax.set_ylim(-1.5, 3.5)
ax.axis('off')
# Box 1: EC group
ec_box = mpatches.FancyBboxPatch((0.2, 0.5), 3.2, 2.2, boxstyle='round,pad=0.2',
facecolor='#ebf5fb', edgecolor='#2980b9', linewidth=2)
ax.add_patch(ec_box)
ax.text(1.8, 2.1, r'$E(\mathbb{F}_p)$', ha='center', fontsize=14, fontweight='bold')
ax.text(1.8, 1.2, r'ECDLP: $[d]P = Q$', ha='center', fontsize=11)
# Box 2: Finite field
ff_box = mpatches.FancyBboxPatch((6.8, 0.5), 3.2, 2.2, boxstyle='round,pad=0.2',
facecolor='#fdedec', edgecolor='#e74c3c', linewidth=2)
ax.add_patch(ff_box)
ax.text(8.4, 2.1, r'$\mathbb{F}_{p^k}^*$', ha='center', fontsize=14, fontweight='bold')
ax.text(8.4, 1.2, r'DLP: $\alpha^d = \beta$', ha='center', fontsize=11)
# Arrow: Weil pairing
ax.annotate('', xy=(6.7, 1.6), xytext=(3.5, 1.6),
arrowprops=dict(arrowstyle='->', lw=2.5, color='#8e44ad'))
ax.text(5.1, 2.1, 'Weil pairing $e$', ha='center', fontsize=11,
color='#8e44ad', fontweight='bold')
# Difficulty labels
ax.text(1.8, -0.3, r'$O(\sqrt{n})$ generic', ha='center', fontsize=10, color='#2980b9')
ax.text(8.4, -0.3, r'$L_{p^k}[1/3]$ index calculus', ha='center', fontsize=10, color='#e74c3c')
ax.text(5.1, -1.0, 'Attack is practical only when embedding degree $k$ is small',
ha='center', fontsize=10, style='italic', color='#7f8c8d')
ax.set_title('MOV Attack: Reducing ECDLP to Finite-Field DLP', fontsize=13, pad=15)
plt.tight_layout()
plt.savefig('fig_ch33_mov_attack.png', dpi=150)
plt.show()
33.7 End-to-End Protocol Demo#
We bring ECDH and ECDSA together in a realistic scenario: Alice and Bob establish a shared secret via ECDH, then Alice signs a message with ECDSA and Bob verifies it.
import numpy as np
# ── Curve parameters (toy-sized) ──
p = 107; a_c = 1; b_c = 4
G = (0, 2)
n = ec_order(G, a_c, p)
print(f'=== Curve: y^2 = x^3 + {a_c}x + {b_c} (mod {p}) ===')
print(f'Generator G = {G}, order n = {n}\n')
# ── Step 1: ECDH key exchange ──
dh = ECDH(a_c, b_c, p, G, n)
rng_a = np.random.default_rng(101)
rng_b = np.random.default_rng(202)
a_priv, A_pub = dh.key_gen(rng_a)
b_priv, B_pub = dh.key_gen(rng_b)
S_a = dh.compute_shared_secret(a_priv, B_pub)
S_b = dh.compute_shared_secret(b_priv, A_pub)
print('--- ECDH Key Exchange ---')
print(f'Alice: priv={a_priv}, pub={A_pub}')
print(f'Bob: priv={b_priv}, pub={B_pub}')
print(f'Shared secret (Alice): {S_a}')
print(f'Shared secret (Bob): {S_b}')
print(f'Match: {S_a == S_b}\n')
# ── Step 2: Alice signs a message ──
dsa = ECDSA(a_c, b_c, p, G, n)
d_sign, Q_sign = dsa.key_gen(np.random.default_rng(303))
msg = 'Transfer 100 coins to Bob'
sig = dsa.sign(msg, d_sign, np.random.default_rng(404))
print('--- ECDSA Signature ---')
print(f'Message: "{msg}"')
print(f'Signing key: d={d_sign}, Q={Q_sign}')
print(f'Signature: (r={sig[0]}, s={sig[1]})')
# ── Step 3: Bob verifies ──
valid = dsa.verify(msg, sig, Q_sign)
print(f'\n--- ECDSA Verification ---')
print(f'Signature valid: {valid}')
# Tampered message
tampered = 'Transfer 999 coins to Bob'
valid_t = dsa.verify(tampered, sig, Q_sign)
print(f'Tampered message valid: {valid_t}')
=== Curve: y^2 = x^3 + 1x + 4 (mod 107) ===
Generator G = (0, 2), order n = 107
--- ECDH Key Exchange ---
Alice: priv=33, pub=(85, 81)
Bob: priv=20, pub=(97, 8)
Shared secret (Alice): (28, 100)
Shared secret (Bob): (28, 100)
Match: True
--- ECDSA Signature ---
Message: "Transfer 100 coins to Bob"
Signing key: d=44, Q=(9, 10)
Signature: (r=37, s=76)
--- ECDSA Verification ---
Signature valid: True
Tampered message valid: False
Show code cell source
import numpy as np
import matplotlib.pyplot as plt
# ── Visualise ECDH on the curve ──
p = 97; a_c = 2; b_c = 3; G = (0, 10) # (0,10) is on y^2 = x^3+2x+3 mod 97
n = ec_order(G, a_c, p)
all_pts = curve_points(a_c, b_c, p)
xs = [pt[0] for pt in all_pts]
ys = [pt[1] for pt in all_pts]
# ECDH keys (reuse from previous demo)
a_priv_v = 11; b_priv_v = 7
A_pub_v = ec_mul(a_priv_v, G, a_c, p)
B_pub_v = ec_mul(b_priv_v, G, a_c, p)
S_v = ec_mul(a_priv_v, B_pub_v, a_c, p)
fig, ax = plt.subplots(figsize=(7, 6))
ax.scatter(xs, ys, c='#bdc3c7', s=15, label='Curve points', zorder=2)
ax.scatter(*G, c='#2ecc71', s=120, marker='D', zorder=5, label=f'G = {G}')
ax.scatter(*A_pub_v, c='#3498db', s=100, marker='^', zorder=5,
label=f'A = [{a_priv_v}]G = {A_pub_v}')
ax.scatter(*B_pub_v, c='#e74c3c', s=100, marker='v', zorder=5,
label=f'B = [{b_priv_v}]G = {B_pub_v}')
ax.scatter(*S_v, c='#f39c12', s=160, marker='*', zorder=5,
label=f'S = [{a_priv_v}]B = [{b_priv_v}]A = {S_v}')
ax.set_xlabel('x'); ax.set_ylabel('y')
ax.set_title(f'ECDH on $y^2 = x^3 + {a_c}x + {b_c}$ (mod {p})')
ax.legend(loc='upper left', fontsize=8)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('fig_ch33_ecdh_curve.png', dpi=150)
plt.show()
33.8 Attack#
Complexity Comparison
Attack |
Complexity |
Requirement |
|---|---|---|
Brute force |
\(O(n)\) |
None |
Baby-step giant-step |
\(O(\sqrt{n})\) time & space |
Deterministic |
Pollard’s rho |
\(O(\sqrt{n})\) time, \(O(1)\) space |
Probabilistic |
Pohlig–Hellman |
\(O(\sum e_i(\log n + \sqrt{p_i}))\) |
Smooth order |
MOV attack |
\(L_{p^k}[1/3]\) |
Small embedding degree |
For a well-chosen curve (prime order \(n \approx 2^{256}\), large embedding degree), the best known attack is Pollard’s rho with \(\approx 2^{128}\) group operations.
Show code cell source
import numpy as np
import matplotlib.pyplot as plt
import time
# ── Compare BSGS vs Pohlig-Hellman on curves with different smoothness ──
test_data = [
# (p, a, b, label)
(101, 1, 6, 'p=101'),
(97, 2, 3, 'p=97'),
(131, 2, 3, 'p=131'),
(157, 2, 3, 'p=157'),
(199, 3, 7, 'p=199'),
]
labels = []
bsgs_times = []
ph_times = []
smoothness = []
for (pp, aa, bb, lbl) in test_data:
pts = curve_points(aa, bb, pp)
if len(pts) < 3:
continue
GG = pts[1]
nn = ec_order(GG, aa, pp)
if nn < 5:
continue
k_test = nn // 2 + 1
Qt = ec_mul(k_test, GG, aa, pp)
# BSGS timing
t0 = time.perf_counter()
bsgs_ec(GG, Qt, aa, pp, nn)
bsgs_t = time.perf_counter() - t0
# Pohlig-Hellman timing
t0 = time.perf_counter()
pohlig_hellman_ec(GG, Qt, aa, pp, nn)
ph_t = time.perf_counter() - t0
factors = factorise_small(nn)
largest = max(factors.keys())
labels.append(f'{lbl}\nn={nn}\nmax_p={largest}')
bsgs_times.append(bsgs_t * 1000)
ph_times.append(ph_t * 1000)
smoothness.append(largest)
x = np.arange(len(labels))
w = 0.35
fig, ax = plt.subplots(figsize=(9, 5))
ax.bar(x - w/2, bsgs_times, w, label='BSGS', color='#3498db')
ax.bar(x + w/2, ph_times, w, label='Pohlig-Hellman', color='#e74c3c')
ax.set_xticks(x)
ax.set_xticklabels(labels, fontsize=8)
ax.set_ylabel('Time (ms)')
ax.set_title('BSGS vs. Pohlig-Hellman on Curves of Varying Smoothness')
ax.legend()
ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.savefig('fig_ch33_bsgs_vs_ph.png', dpi=150)
plt.show()
33.9 Exercises#
33.10 Summary#
This chapter implemented the two cornerstone ECC protocols and the main classes of attack:
Topic |
Key takeaway |
|---|---|
ECDH |
Shared secret from scalar multiplication; 256-bit ECC \(\approx\) 3072-bit RSA |
ECDSA |
Sign = random nonce + scalar arithmetic; verify = two-scalar multi-mult |
Nonce reuse |
Reusing \(k\) leaks the private key in two signatures |
Pohlig–Hellman |
Smooth group order \(\Rightarrow\) trivial ECDLP |
Pollard’s rho |
Generic \(O(\sqrt{n})\) attack; sets the security baseline |
MOV attack |
Pairing-based reduction to finite-field DLP; foiled by large embedding degree |
Secure parameter selection requires:
Prime (or near-prime) group order to defeat Pohlig–Hellman.
Large embedding degree to defeat MOV.
Resistance to specialised attacks (anomalous curves, etc.).
Efficient arithmetic (e.g., curves over Mersenne-like primes).
References#
V. S. Miller, “Use of elliptic curves in cryptography,” CRYPTO 1985, LNCS 218, pp. 417–426, 1986.
N. Koblitz, “Elliptic curve cryptosystems,” Mathematics of Computation, vol. 48, no. 177, pp. 203–209, 1987.
A. Menezes, T. Okamoto, S. Vanstone, “Reducing elliptic curve logarithms to logarithms in a finite field,” IEEE Trans. Inf. Theory, vol. 39, no. 5, pp. 1639–1646, 1993.
D. Hankerson, A. Menezes, S. Vanstone, Guide to Elliptic Curve Cryptography, Springer, 2004.
NIST, “Recommended elliptic curves for federal government use,” FIPS 186-4, 2013 (updated FIPS 186-5, 2023).
J. M. Pollard, “Monte Carlo methods for index computation (mod \(p\)),” Mathematics of Computation, vol. 32, no. 143, pp. 918–924, 1978.
S. Pohlig, M. Hellman, “An improved algorithm for computing logarithms over \(GF(p)\) and its cryptographic significance,” IEEE Trans. Inf. Theory, vol. 24, no. 1, pp. 106–110, 1978.