Recently, I have been following the awesome tutorial describing the internal details of zkSNARK system PLONK called PLONK by hand by Joshua Fitzgerald (Metastate team). The rust implementation that closely follows this is given by Adrià Massanet in PLONK by fingers.
For brevity, for rest of this document, PLONK by hand and PLONK by fingers will be abbreviated as PBH and PBF respectively. While PBF is a bigger implementation of the zkSNARK protocol described in PBH, what we concentrate on in this document is how in rust implementation of elliptic curves go.
The purpose of this document remains me describing as a non-cryptography expert the interpretations I draw from what I see. Things might be hand-wavy and could be wrong, but the point remains for this to be a good entry point for others who like to venture into similar territories, which may be slightly wrong at first. May this be a quick disclaimer for readers :P .
Elliptic curves are an important cryptographic primitive in modern times. What makes them valuable in where they are used are generally two properties (one optional, but used in PLONK).
See more properties of bilinear maps in appendix here.
FiniteField
For coding up the elliptic curve, we need to describe a field. This should be a trait since we are going to define functions that a concrete implementation should provide.
Setup a new project using cargo init --lib pbf
, make lib.rs
under src/
, add
Create elliptic_curve.rs
under src/
and add:
Running cargo build
works for now. Good start! Let's start adding some real function that we will use in real life w.r.t. Finite Fields.
Adding
makes the compiler unhappy and it returns:
What it means is to be able to wrap the type T
in Option<T>
, the amount of memory required by T
should be known at compile time. If we think closely, that is true for any finite field we may implement because finite fields are operated on modulo some large prime which is constant for the field, making the memory footprint of any field element known at compile time (as at maximum it may take as much memory as required to represent ).
So, we add:
telling that whatever implements FiniteField
has to have the Sized
trait. This solves current compilation issues. We add additional operations which we will use later such as mathematical operation like negation, addition, multiplication. We also add operations for raising element to some power, finding inverse etc.
When defining for traits such as std::ops::Add
, the binary operation will have a right hand side that you like to add with. Such type is the generic parameter that needs to be given (defaults to Self
). While generally the other element that needs to be added is of same type, in certain instances such an operation may not make sense. For example, only Duration
should be added to current time clock; a clock may not be added to a clock.
During instances where such weird behavior is not seen, Add<Self, Output=Self>
can just be written as Add
. The Output
is an associated type here. What it means is that only one implementation of trait Add<rhstype>
is expected for some rhstype
and no two traits Add<T, Output=K>
, Add<T, Output=P>
are allowed at the same time.
The trait From<u64>
is interesting here, it allows creation of the object from some u64
type, the function as_u64
is the complementary exporting function here.
A note on blanket implementation: If you hover over From<u64>
, you will see the following:
Another trait exists pub trait Into<T>: Sized
in rust core library. With From
implemented for a type, the following call is satisfied
With Into<T>
implemented, the following call is satisfied:
However, the default implementation of Into<T>
is provided by rust if From<U>
is defined. So while implementing From<u64>
makes both the above possible, with just Into<T>
, the former may not be possible.
We create traits for points on both the curves and . Since most operation is going to happen in and we use just to allow for pairing, we define the following traits:
Two associated types exist for G1Point
namely Field
and SubField
. (TODO: Describe why).
Similarly, we describe:
For pairing, we define the target groupt and a pairing trait as follows:
Much of the implementation for u64 field is simple to understand. We will build our actual curves , and on top of this concrete implementation later. Create u64_field.rs
under src/
What does it mean for an element to have an inverse in our u64 field? We deem this property to hold true for any non-zero element in the field: where is the multiplicative identity. Of course, all of this is done mod .
To find the inverse of some element , we resort to first find coefficients that satisfy Bézout's identity for which we need to deploy extended euclidean algorithm for element and field prime .
Bézout's identity: Let and be integers with greatest common divisor . Then there exist integers and such that . Moreover, the integers of the form are exactly the multiples of .
This excellent video describes how extended euclidean algorithm can be used to get these coefficients and .
Once we have the coefficients and , we see using following (assuming to be prime and hence and are coprimes): Hence we see is inverse of and we don't really need for calculating the inverse.
Let's begin by implementing this algorithm.
Base case: Let's say we are asked to find and for where , the following is seen: . Any GCD calculation always ends up at this base case for e.g.: . At the end, 2 is the gcd,
Recursive case: In case , we know if we decompose as , . Let's say there exists some "extended gcd" algorithm that replies a 3-tuple containing for some given ;
Hence, the algorithm can be written as (in extended_euclid.rs
under src/
):
Further optimizations can be done by making it iterative and not recursive available here in PBF.
We implement the first curve in PBH via what we will now call as F101G1PointAffine
representations. As the name suggests, we will use u64 field over prime 101
as coordinate domain, and present it in "affine" form i.e. have an explicit point at inifinity.
Create f101_g1.rs
under src/
with following:
Bilinear maps possess several important properties. Let's consider a bilinear map : × → , where V, W, and X are vector spaces over the same field. The properties of a bilinear map are as follows:
Linearity in the first argument: For any fixed w in W, the function v ↦ B(v, w) is a linear transformation from V to X. This means that it satisfies the following properties:
B(v1 + v2, w) = B(v1, w) + B(v2, w) (additivity) B(cv, w) = cB(v, w) (homogeneity), where v, v1, v2 are vectors in V, c is a scalar, and w is a fixed vector in W. Linearity in the second argument: For any fixed v in V, the function w ↦ B(v, w) is a linear transformation from W to X. This implies:
B(v, w1 + w2) = B(v, w1) + B(v, w2) (additivity) B(v, cw) = cB(v, w) (homogeneity), where w, w1, w2 are vectors in W, c is a scalar, and v is a fixed vector in V. Compatibility with scalar multiplication: The bilinear map satisfies the property:
B(cv, w) = B(v, cw) = cB(v, w), where v is a vector in V, w is a vector in W, and c is a scalar. Distributive property: Bilinear maps distribute over vector addition in both arguments:
B(v1 + v2, w1 + w2) = B(v1, w1) + B(v1, w2) + B(v2, w1) + B(v2, w2), where v1, v2 are vectors in V, w1, w2 are vectors in W. These properties ensure that the bilinear map behaves linearly with respect to each argument and respects the operations of vector addition and scalar multiplication.
Additionally, there are other properties specific to certain types of bilinear maps, such as symmetric bilinear maps (satisfying B(v, w) = B(w, v)) or alternating bilinear maps (satisfying B(v, v) = 0 for all v). These properties depend on the specific context and requirements of the application.
Bilinear maps are not necessarily one-to-one (injective) or onto (surjective). The properties of injectivity and surjectivity depend on the specific bilinear map and the vector spaces involved.
Injectivity: A bilinear map B: V × W → X is injective if distinct pairs of vectors in V and W always map to distinct elements in X. In other words, if B(v1, w1) = B(v2, w2), then it implies that (v1, w1) = (v2, w2). However, in general, bilinear maps can have non-trivial kernels, meaning that different pairs of vectors may map to the same element in X.
Surjectivity: A bilinear map B: V × W → X is surjective if every element in the target space X can be obtained as the result of applying the map to some pair of vectors from V and W. Surjectivity is also dependent on the specific bilinear map and the vector spaces involved. Not all bilinear maps are surjective onto their entire target space.
It's worth noting that when bilinear maps are used in the context of pairing-based cryptography, the bilinear maps involved in cryptographic pairings are carefully constructed to possess desired properties and security characteristics. These constructions often involve selecting appropriate elliptic curves and groups to ensure the desired properties of bilinearity, non-degeneracy, and computability, while also taking into account security considerations.