calculate weil-pairing from 0 to 1
To better understand the Weil pairing and the Miller algorithm, I implemented them from scratch in Python here.
The only dependencies used are the pow
function, math.inf
, and the deep copy function copy.deepcopy
. I also adopted the OOP programming paradigm, which I'm not entirely comfortable with.
Although after writing this, my understanding of both the Weil pairing and OOP has improved (
Starting from
In high school competitions, we learned about operations in
However, there's no need to revisit the extended Euclidean algorithm today, as a previous post has already explained its principles in detail. Here, I choose to directly use Python's syntactic sugar:
pow(x, -1, p)
to compute
Thus, for a fraction
Introducing Elliptic Curves
Here we introduce the protagonist of this post, an elliptic curve
Here, we use a class EpCurve
to abstract it. Currently, this class already includes methods for modular inversion and division. To reflect the characteristics of this curve, we can add a method to check whether a point
class EpCurve:
def __init__(self, a, b, p) -> None:
self.A = a
self.B = b
self.mod = p
def inverse(self, x:int) -> int:
return pow(x, -1, self.mod)
def frac(self, x:int, y:int) -> int:
return x * self.inverse(y) % self.mod
def isOnCurve(self, x, y) -> bool:
return y*y % self.mod == (x*x*x + self.A*x + self.B) % self.mod
And the corresponding instance curve = EpCurve(30, 34, 631)
.
Then, based on EpCurve
, we can abstract points on the elliptic curve with EpPoint
:
class EpPoint(EpCurve):
def __init__(self, x, y, curve) -> None:
super().__init__(curve.A, curve.B, curve.mod)
if not self.isOnCurve(x, y):
raise ValueError(f"Point ({x}, {y}) is not on the curve")
self.x = x % self.mod
self.y = y % self.mod
self.O = False
self.curve = curve
def __str__(self) -> str:
if self.O:
return 'O'
return f"({self.x}, {self.y})"
Here, self.x
and self.y
are the coordinates self.O
indicates whether it is the point at infinity __str__
method is a built-in magic method in Python, used to return the corresponding string when the instance is printed.
Point Operations
In the Applied Cryptography course during the second semester of my sophomore year, we systematically studied point operations on elliptic curves and learned how to perform point addition and point doubling:
For two points
Then we obtain the coordinates
Geometrically,
For the point at infinity
def calcLam(self, other) -> int:
x1, x2, y1, y2 = self.x, other.x, self.y, other.y
if x2 == x1 and y2 == y1:
# P == Q
if y1 == 0:
return math.inf
return self.frac(3*x1*x1 + self.A, 2*y1) # s = lambda
else:
# P != Q
if x2 == x1:
return math.inf
return self.frac(y2 - y1, x2 - x1)
def add(self, other):
if self.O: # is the point at infinity
# O + P = P
return EpPoint(other.x, other.y, self.curve)
if other.O:
# P + O = P
return EpPoint(self.x, self.y, self.curve)
s = self.calcLam(other)
if s == math.inf:
res = copy.deepcopy(self)
res.O = True
return res
x1, x2, y1, y2 = self.x, other.x, self.y, other.y
x3 = (s*s - x1 - x2) % self.mod
y3 = (s * (x1 - x3) - y1) % self.mod
return EpPoint(x3, y3, self.curve)
For point multiplication, we can of course use an algorithm similar to fast exponentiation. However, since the subsequent content is not heavily related to point multiplication, we simply use ordinary accumulation here for correctness checking during debugging:
def mul(self, x): # check only, no performance, calc x*P
res = self
for i in range(x - 1):
res = res.add(self)
return res
Calculation of Order
The principle is also quite simple: continuously add a point to itself, and if it becomes the point at infinity, the order (i.e., the number of additions) has been found.
def calcOrder(self) -> int:
back = self; cnt = 1
while True:
cnt += 1
back = back.add(self)
if back.O == 1:
return cnt
The content from the applied cryptography textbook ends here.
Miller's Algorithm
We first briefly introduce the concepts of rational functions and the divisor
For any univariate rational function:
According to the fundamental theorem of algebra, we can always factorize it in the complex plane as:
Here, the
For simplicity, we can use the divisor to simplify the expression:
Here,
Of course, the uppercase
e.g., Let
, , . Then
must lie on . Therefore: Here,
is easy to understand because . Although has no denominator, the multiplicity of the pole can be proven to be through projective transformation and infinitesimal analysis, which we omit here due to space constraints.
From the above example, we can conjecture some properties of
if and only if there exists a constant such that . - The sum of the expansion coefficients (degrees) is
. - If point addition is performed on these points, the result is
. - In particular, if there are no zeros or poles, then
is a constant.
The first is called the uniqueness theorem of divisors, and the latter three are called the principal divisor theorem. The conclusions are independent of the choice of the rational function
With the basics covered, we now introduce the line function
Clearly,
Returning to code implementation, since
def lineFunc(self, other, S) -> int: # P, Q, S. S is the f_p(S)
xp, xq, yp, yq = self.x, other.x, self.y, other.y
if self.calcLam(other) == math.inf:
return S.x - xp
lam = self.calcLam(other)
return self.frac(S.y - yp - lam*(S.x - xp), S.x + xp + xq - lam*lam)
Now we come to the main body of Miller's algorithm:
Let the binary representation of the positive integer
T = P, f = 1
for i from (n-2) to 0:
f = f^2 * g_{T,T}
T = 2T
if b[i] == 1:
f = f * g_{T,P}
T = T + P
return f
Proof by induction:
First, when
, the algorithm returns and . is a constant with no zeros or poles. Substituting into , all terms cancel out, so holds. Assume
. Let and hold. We need to prove the case for : Suppose
. Then the if branch is skipped, and the actual operation is . The new is: The result is correct, and
also meets the induction requirement. Therefore, the induction holds for the branch. Similarly, it can be proven for the branch. In conclusion, this algorithm generates a rational function
such that . Q.E.D.
In particular, if the point
Returning to code implementation, since Miller's algorithm is based on the line function
def miller(self, S) -> int: # P, S, calc f_P(S)
if self.O:
return 1
T = self; f = 1
n = bin(self.calcOrder())[3:] # 0b1 01001..101
for bit in n:
f = f * f * T.lineFunc(T, S) % self.mod
T = T.add(T)
if bit == '1':
f = f * T.lineFunc(self, S) % self.mod
T = T.add(self)
return f
Weil Pairing
Let rational functions
Apart from being independent of the choice of rational functions
- Root of unity:
- Bilinearity:
, - Non-degeneracy:
- Alternating:
, .
Since we have already computed
def weil(self, Q, S) -> int: # P, Q, S
negS = EpPoint(S.x, -S.y, self.curve) # -S
res1 = self.frac(self.miller(Q.add(S)), self.miller(S))
res2 = self.frac(Q.miller(self.add(negS)), Q.miller(negS))
return self.frac(res1, res2)
For example, take:
P = EpPoint(36, 60, curve) # Order 5
Q = EpPoint(121, 387, curve) # Order 5
S = EpPoint(0, 36, curve) # Order 130
Here,
print('P, Q\'s Weil pairing:', P.weil(Q, S))
# P, Q's Weil pairing: 242
print(242**5 % 631) # property 1 holds
# 1
print('Q, P\'s Weil pairing:', Q.weil(P, S))
# Q, P's Weil pairing: 279
print(242*279 % 631) # property 4 holds
# 1
Now consider another example:
P3 = P.mul(3)
Q4 = Q.mul(4)
print('P3, Q4\'s Weil pairing:', P3.weil(Q4, S))
# P3, Q4's Weil pairing: 512
print(242**12 % 631) # property 2 holds
# 512
Demonstrating property 3 is also straightforward:
O = P.mul(5)
print('P, O\'s Weil pairing:', P.weil(O, S)) # property 3 holds
# P, O's Weil pairing: 1
Tate Pairing
The difference between the Tate pairing and the Weil pairing is that the elliptic curve for the Tate pairing is defined over a finite field
where
It satisfies the two properties of the Weil pairing: being a root of unity and bilinearity.
It can be observed that the computational cost of the Tate pairing is half that of the Weil pairing, making it more favored in cryptography. For example, the variant of the Tate pairing, the ate pairing, is widely used in Ethereum.
def tate(self, Q, S) -> int:
q = self.mod; l = self.calcOrder()
if q % l != 1:
raise ValueError('q and l don\'t support q == 1 mod l')
tau = self.frac(self.miller(Q.add(S)), self.miller(S))
return pow(tau, self.frac(q-1, l), q)
Then we verify the properties:
print('P, Q\'s Tate pairing:', P.tate(Q, S))
# P, Q's Tate pairing: 279
print('Q, P\'s Tate pairing:', Q.tate(P, S))
# Q, P's Tate pairing: 228
Clearly,
Now, let's check the bilinearity:
P3 = P.mul(3)
Q2 = Q.mul(2)
print('P, Q\'s Tate pairing:', P.tate(Q, S))
# P, Q's Tate pairing: 279
print('3P, 2Q\'s Tate pairing:', P3.tate(Q2, S))
# 3P, 2Q's Tate pairing: 279
Since
Complete Code
# y^2 = x^3 + 30x + 34
import copy, math
class EpCurve:
def __init__(self, a, b, p) -> None:
self.A = a
self.B = b
self.mod = p
def inverse(self, x:int) -> int:
return pow(x, -1, self.mod)
def frac(self, x:int, y:int) -> int:
return x * self.inverse(y) % self.mod
def isOnCurve(self, x, y) -> bool:
return y*y % self.mod == (x*x*x + self.A*x + self.B) % self.mod
class EpPoint(EpCurve):
def __init__(self, x, y, curve) -> None:
super().__init__(curve.A, curve.B, curve.mod)
if not self.isOnCurve(x, y):
raise ValueError(f"Point ({x}, {y}) is not on the curve")
self.x = x % self.mod
self.y = y % self.mod
self.O = False
self.curve = curve
def __str__(self) -> str:
if self.O:
return 'O'
return f"({self.x}, {self.y})"
def calcLam(self, other) -> int:
x1, x2, y1, y2 = self.x, other.x, self.y, other.y
if x2 == x1 and y2 == y1:
if y1 == 0:
return math.inf
return self.frac(3*x1*x1 + self.A, 2*y1) # s = lambda
else:
if x2 == x1:
return math.inf
return self.frac(y2 - y1, x2 - x1)
def add(self, other):
if self.O:
return EpPoint(other.x, other.y, self.curve)
if other.O:
return EpPoint(self.x, self.y, self.curve)
s = self.calcLam(other)
if s == math.inf:
res = copy.deepcopy(self)
res.O = True
return res
x1, x2, y1, y2 = self.x, other.x, self.y, other.y
x3 = (s*s - x1 - x2) % self.mod
y3 = (s * (x1 - x3) - y1) % self.mod
return EpPoint(x3, y3, self.curve)
def mul(self, x): # check only, no performance
res = self
for i in range(x - 1):
res = res.add(self)
return res
def calcOrder(self) -> int:
back = self; cnt = 1
while True:
cnt += 1
back = back.add(self)
if back.O == 1:
return cnt
def lineFunc(self, other, S) -> int: # S is the f_p(S)
xp, xq, yp, yq = self.x, other.x, self.y, other.y
if self.calcLam(other) == math.inf:
return S.x - xp
lam = self.calcLam(other)
return self.frac(S.y - yp - lam*(S.x - xp), S.x + xp + xq - lam*lam)
def miller(self, S) -> int: # P, S
if self.O:
return 1
T = self; f = 1
n = bin(self.calcOrder())[3:]
for bit in n:
f = f * f * T.lineFunc(T, S) % self.mod
T = T.add(T)
if bit == '1':
f = f * T.lineFunc(self, S) % self.mod
T = T.add(self)
return f
def weil(self, Q, S) -> int: # P, Q, S
negS = EpPoint(S.x, -S.y, self.curve) # -S
res1 = self.frac(self.miller(Q.add(S)), self.miller(S))
res2 = self.frac(Q.miller(self.add(negS)), Q.miller(negS))
return self.frac(res1, res2)
def tate(self, Q, S) -> int:
q = self.mod; l = self.calcOrder()
if q % l != 1:
raise ValueError('q and l don\'t suuport q == 1 mod l')
tau = self.frac(self.miller(Q.add(S)), self.miller(S))
return pow(tau, self.frac(q-1, l), q)
curve = EpCurve(30, 34, 631)
P = EpPoint(36, 60, curve) # Order 5
Q = EpPoint(121, 387, curve)
S = EpPoint(0, 36, curve)
# print('P + Q =', P.add(Q))
# print('P + Q =', P.add(P))
# print('Q + S =', Q.add(S))
# print('P\'s order:', P.calcOrder())
# print('P\'s miller function related to S:', P.miller(S))
# print('P + Q\'s miller function related to S:', P.miller(Q.add(S)))
# print('P, Q\'s weil pairing:', P.weil(Q, S))
P3 = P.mul(3)
Q2 = Q.mul(2)
O = P.mul(5)
print('P, Q\'s tate pairing:', P.tate(Q, S))
print('3P, 2Q\'s tate pairing:', P3.tate(Q2, S))
References
- https://github.com/WTFAcademy/WTF-zk/tree/main
- https://crypto.stanford.edu/pbc/notes/ep/
- An Introduction to Mathematical Cryptography