EcGFp5 is an elliptic curve defined over the field GF(p^5), for the prime p = 2^64 - 2^32 + 1. This curve was designed to efficiently support systems with a special compute model where operations in GF(p) are especially efficient. One such system is the Miden VM, which is used to produce and verify zero-knowledge proofs (of the STARK kind). EcGFp5 is not inherently tied to Miden, but that compute model served as inspiration for the curve choice.
-
The curve design and choice is explained in the whitepaper. The paper is now also on eprint.
-
A test implementation, in Python, is provided in the python directory. It emulates operations as they are meant to occur within the target compute model (the "VM") and counts costs, expressed in such operations.
-
A reference implementation in Rust (optimized and constant-time, but portable) is provided in the rust directory. It implements all operations on curve elements needed to implement, for instance, digital signatures.
EcGFp5 is a double-odd curve: it is best understood as a prime order group with efficient and constant-time group operations, as well as canonical encoding and decoding procedures.
Base modulus is p = 2^64 - 2^32 + 1 = 18446744069414584321
.
Field GF(p^5) is defined as polynomials in GF(p)[z], for a symbolic variable z, of degree at most 4, and all computations performed modulo the irreducible polynomial M = z^5 - 3.
The curve equation is: y^2 = x*(x^2 + a*x + b)
for the two constants
a = 2
and b = 263*z
. The total curve order is 2*n
for a big
319-bit prime n
:
n = 1067993516717146951041484916571792702745057740581727230159139685185762082554198619328292418486241
EcGFp5 itself is defined as a group of order exactly n
, consisting of
the points of the curve which are not points of n
-torsion. The
neutral element is the point N = (0, 0)
(on the curve, this is the
unique point of order 2). The sum of two points P
and Q
in the
group is defined as P + Q + N
on the curve. This yields a group with
all needed properties for defining cryptographic operations such as
key exchange or signatures:
-
The group has a prime order (no cofactor!).
-
Encoding and decoding of elements is canonical: each element can be encoded into a single sequence of bytes, and the decoding process succeeds only if provided that exact sequence of bytes.
-
The group law can be applied with complete formulas, with no special case; the formulas are furthermore efficient (generic addition in 10M, amortized per-doubling cost in 2M+5S).
The conventional generator G
is the unique point of the group such
that y/x = 4
(there are exactly two such points on the base curve,
but only one of them is in the prime-order group). Its coordinates
are:
x = 12883135586176881569
+ 4356519642755055268*z
+ 5248930565894896907*z^2
+ 2165973894480315022*z^3
+ 2448410071095648785*z^4
y = 4*x
As described in the double-odd curves,
there are several choices for internal coordinates. In general, the
fractional (x,u) coordinates are recommended: an element is
represented as the quadruplet (X:Z:U:T)
with x = X/Z
and
u = x/y = U/T
(with u = 0
for the neutral N
); elements Z
and
T
are never zero.
The rust directory contains a reference implementation of
ecGFp5. It is portable: the code uses only core
(not std
) and has no
architecture-dependent features (no assembly, intrinsics or anything
tagged unsafe
); it is nonetheless quite optimized. It does not require
the "nightly" compiler. The main types are GFp
(integers modulo the
64-bit integer p
), GFp5
(elements of the GF(p^5) field), Point
(group elements) and Scalar
(integers modulo the prime group order
n
). The structures implement the usual traits (e.g. Add
and
AddAssign
) so that arithmetic operators can be used on such values.
The implementation is considered secure (notably, everything is
constant-time, except the functions meant to directly support signature
verification, which nominally uses only public data). It shall be noted
that there is no attempt whatsoever at "wiping memory", an action is
believed in some circles to be an important security feature. At least,
being a core
-only implementation, there should be no dynamic memory
allocation except on the stack, so that a blanket stack-wiping strategy
after execution of curve-related code is feasible.
Compilation is straightforward (cargo build
and so on). It can be
beneficial to performance to add explicit compilation flags in order to
leverage some architecture-specific opcodes, e.g. mulx
on recent x86
CPUs (Intel Skylake and later); this is done by setting the RUSTFLAGS
environment variable with the value -C target-cpu=native
(but this, of
course, prevents execution of the resulting binary on older CPUs that do
not feature such opcodes).
There are benchmarks (cargo bench
). These benchmarks use the intrinsic
functions _mm_lfence()
and _rdtsc()
and will compile only on 64-bit
x86 architectures. Moreover, they use the CPU cycle counter, which is
known not to actually count cycles on CPUs that indulge in frequency
scaling (aka "TurboBoost"): if you run the benchmarks on a machine that
has TurboBoost enabled, the numbers you get are meaningless.
The provided implementations do not include signature primitives. The main reason is that defining a signature scheme entails choosing a hash function, and it is expected that in-VM implementations will want to use a specialized hash function such as Poseidon or Rescue.
Given the hash function H
with an output size of at least 320 bits,
the Schnorr signatures work as follows:
-
The private key is
d
, a non-zero scalar (integer modulon
). The public key isQ = d*G
. -
To sign a message
m
(which may itself be already a hash value):-
Generate a new random non-zero
k
modulon
. The choice must be uniform (or indinstinguishable from uniform) amoung all integers modulon
. If a secure random source is not available, derandomization techniques can be used, such as described in RFC 6979. In this case, it may be convenient to hash together the private key, the public key and the message, and interpret the hash output as an integer, which is then reduced modulon
(assuming that the hash output is large enough that the reduction does not induce any notable bias; a 384-bit output is enough, sincen
has size 319 bits). In the Rust implementation,Scalar::decode_reduce()
can perform the decoding and modular reduction. -
Compute point
R = k*G
. Since this uses the conventional generator, precomputed tables can be used to speed up the process; in the Rust code, usePoint::mulgen()
. -
Encode
R
into the 40-byte sequencerbuf
(usePoint::encode()
to encode the pointR
into an element of GF(p^5), then `GFp5::encode() to obtain 40 bytes). -
Hash (with
H
) the concatenation ofrbuf
, the encoding ofQ
, and the messagem
. The hash output is interpreted as an integer (e.g. with little-endian convention) which is reduced modulon
, yielding the scalare
. -
Compute the scalar
s = k + d*e
(all computations modulon
; in Rust, use the arithmetic operations onScalar
instances). The scalars
is encoded into the 40-byte valuesbuf
(withScalar::encode()
). -
The signature is the concatenation of
rbuf
andsbuf
.
-
-
To verify a signature over a message
m
, against a public keyQ
:-
Check that the signature has length exactly 80 bytes; split it into
rbuf
andsbuf
. -
Decode
rbuf
into the pointR
(GFp5::decode()
, thenPoint::decode()
). Each decoding may fail, in case the value is invalid; a failed decoding implies that the signature is to be rejected. -
Decode
sbuf
into the scalars
(withScalar::decode()
, which may also fail ifsbuf
is not in the proper range). -
Hash the concatenation of
rbuf
, the encoding ofQ
, and the messagem
, and reduce the output modulon
. This recomputes the valuee
as in step 4 of the signature generation. -
Check that
s*G - e*Q = R
. In the Rust code, the functionPoint::verify_muladd_vartime()
can be used (with scalarss
and-e
, and pointR
): this function applies an optimized process to speed up this operation. As the name indicate, this function is not constant-time, which is normally not a problem for signature verification, which uses only public data.
-