cstef
/post/elliptic-curve
TOP

Elliptic Curve and Shenanigans for Sane People

A gentle introduction to elliptic curve cryptography and a few concepts around it, without the need for a PhD in mathematics.

21/11/2024 · 31 min read · sapling 🪴

Info

I am not a cryptographer, nor a mathematician. This article is the result of my own research and understanding of the subject. If you find any mistakes, please let me know!

The vast majority of what is written here is taken from various sources, which are listed at the end of this article. I highly recommend you to read them if you want to dive deeper into the subject.

Introduction

While chatting about math with a friend, he brought up modular arithmetic—a concept I hadn’t heard of but was instantly intrigued by. Imagine this: instead of dealing with endless numbers, what if we worked within a closed loop, where everything resets after hitting, say, 7979? Modular arithmetic does exactly that, turning numbers into a finite, repeating cycle that unlocks surprising properties and patterns.

Let’s take a simple equation to demonstrate this way of thinking:

f(x) = x^2 + x - 3f(x) = x^2 + x - 3

The graph associated with this equation looks like this:

#let f1(x) = calc.pow(x, 2) + x - 3 ;

#set text(s#let f1(x) = calc.pow(x, 2) + x - 3 ;

#set text(s

Now let’s instead consider:

g(x) = x^2 + x - 3 space (mod space 79)g(x) = x^2 + x - 3 space (mod space 79)

For x in NNx in NN:

#let g1(x) = calc.rem-euclid(calc.pow(x, 2) + x - #let g1(x) = calc.rem-euclid(calc.pow(x, 2) + x -

We can now see xx oscillating between [0;79[[0;79[ as the modulo acts as a “wrap-around” operation. Finite Fields (also known as Galois Fields) make extensive use of this property to ensure that every operation on two numbers a,b in Ea,b in E, e.g. a+ba+b, stays in EE. More generally, we say that any Finite Field FF_qFF_q with q = p^k | p in cal(P)q = p^k | p in cal(P) is isomorphic.

Even though I did not see any direct-application of this, I was intrigued by this new stuff, and began digging deeper into the subject, clicking Wikipedia links after Wikipedia links. This is how I stumbled upon Shamir’s Secret Sharing. But before we dive into this, let’s first understand underlying concepts.

Notations

Just so we’re all on the same page:

 x \| x < 1 x \| x < 1xx such that x < 1x < 1
a \|\| ba \|\| bDeterministic concatenation of aa and bb
FF_qFF_qFinite / Galois field of order qq
a dot Ga dot GMultiply aa by the generator point GG of an elliptic curve
x <- ZZ_nx <- ZZ_nSample xx from ZZ_nZZ_n

Polynomials

One of the most, if not the most important property of polynomials, is that each one of them can uniquely be described by n+1n+1 points for a polynomial of degree nn, noted P_n (x)P_n (x).

The most straightforward way of seeing this is by setting an equation for each point A_i (x_i, y_i)A_i (x_i, y_i) we have:

P_n (x) = u_0 + u_1 x + ... + u_n x^n = f(x) \

caP_n (x) = u_0 + u_1 x + ... + u_n x^n = f(x) \

ca

We can see that we have n+1n+1 equations for n+1n+1 factors of xx, noted u_i | 0 <= i <= nu_i | 0 <= i <= n, which could also be represented with the following Vandermonde matrix equation:

mat(
    1, x_0, x_0^2, dots.c, x_0^(n);
    1, x_mat(
    1, x_0, x_0^2, dots.c, x_0^(n);
    1, x_

Written as V u = yV u = y, we can solve this by inverting VV:

u = V^(-1) yu = V^(-1) y

Which could be done by applying the Jordan-Gauss algorithm to the augmented matrix V | IV | I, where II is the identity matrix (diagonal filled with 11s), until we only have pivots equal to 11:

mat(
    1, x_0, x_0^2, dots.c, x_0^(n) space, spamat(
    1, x_0, x_0^2, dots.c, x_0^(n) space, spa

If you prefer to see this in a graphical way, let’s consider the following example:

We have two points A_0 (1, 2)A_0 (1, 2) and A_1 (-2, -3)A_1 (-2, -3). We can represent them as follows:

#set text(size: 10pt)

#canvas({
    import draw: #set text(size: 10pt)

#canvas({
    import draw:

It is visually obvious that we can only draw a single line that goes through both points.

#set text(size: 10pt)

#canvas({
    import draw: #set text(size: 10pt)

#canvas({
    import draw:

A line is essentially just f(x) = a x + bf(x) = a x + b, which is of degree 11. We can guess and deduce we need at least n+1n+1 points to describe a polynomial P_nP_n.

The same goes if we add a third point A_2 (0, 4)A_2 (0, 4):

#set text(size: 10pt)
#let f(x) = -(11/6) * calc.p#set text(size: 10pt)
#let f(x) = -(11/6) * calc.p

If we did not add this third point and still tried to find a polynomial P_2 (x)P_2 (x), we would have an infinite number of solutions:

#set text(size: 10pt)
#let f(x, b) = ((3 * b - 5) #set text(size: 10pt)
#let f(x, b) = ((3 * b - 5)

But now the question is: how do we actually find the polynomial that goes through all the points? This is where Lagrange interpolation comes into play.

Lagrange Interpolation

Let’s take A_0 (0, 1)A_0 (0, 1), A_1 (1, 3)A_1 (1, 3), A_2 (2, 2)A_2 (2, 2) and A_3 (3, 4)A_3 (3, 4) to demonstrate this method.

#set text(size: 10pt)

#canvas({
    import draw: #set text(size: 10pt)

#canvas({
    import draw:

The main principle behind this is to split the wanted function into multiple sub-functions l_i (x)l_i (x), that each contribute to one given point, also called “node”.

We want to construct l_i (x)l_i (x) so that:

l_i (x) = cases(
    space 1 l_i (x) = cases(
    space 1

Making a function equal to 00 at a certain point x_jx_j is as simple as multiplying it by (x - x_j)(x - x_j). Based on this property, we can already “guess” l_1^* (x)l_1^* (x):

l_1^*(x) = (x - 0)(x - 2)(x - 3)l_1^*(x) = (x - 0)(x - 2)(x - 3)
#set text(size: 10pt)

#canvas({
    import draw: #set text(size: 10pt)

#canvas({
    import draw:
Why does multiplying by (x-x_j)(x-x_j) add a root ?

For a function f(x) = (x - x_j) dot g(x) | g(x) <- RR[x]f(x) = (x - x_j) dot g(x) | g(x) <- RR[x], when x = x_jx = x_j:

f(x_j) = (x_j - x_j) dot g(x) = 0 dot g(x) = 0f(x_j) = (x_j - x_j) dot g(x) = 0 dot g(x) = 0

That’s a good start! But we have a problem: l_1^* (x)l_1^* (x) is not equal to 11 at x = 1x = 1. We can fix this by dividing l_1^* (x)l_1^* (x) by l_1^* (1)l_1^* (1):

l_1 (x) &= (l_1^* (x)) / (l_1^* (1)) \
        &= l_1 (x) &= (l_1^* (x)) / (l_1^* (1)) \
        &=

And we effectively have l_1 (1) = 1l_1 (1) = 1. We can now repeat this process for l_0 (x)l_0 (x), l_2 (x)l_2 (x) and l_3 (x)l_3 (x):

#import #import

The polynomial P_3 (x)P_3 (x) is computed by summing all the l_i (x)l_i (x) together and multiplying them by the corresponding y_iy_i:

P_3 (x) &= y_0 l_0 (x) + y_1 l_1 (x) + y_2 l_2 (x)P_3 (x) &= y_0 l_0 (x) + y_1 l_1 (x) + y_2 l_2 (x)
#import #import

And we have our polynomial P_3 (x)P_3 (x):

#set text(size: 10pt)

#canvas({
    import draw: #set text(size: 10pt)

#canvas({
    import draw:

Which effectively goes through A_0A_0, A_1A_1, A_2A_2 and A_3A_3.

The general formula for P_n (x)P_n (x) is:

P_n (x) &= sum_(i=0)^n y_i l_i (x) \
        &= suP_n (x) &= sum_(i=0)^n y_i l_i (x) \
        &= su

How Shamir’s Secret Sharing Works

With the basics of polynomials and Lagrange interpolation in mind, let’s dive in!

The main idea behind this is to split a secret ss into nn parts, such that any kk parts can be used to reconstruct the secret, but any k-1k-1 parts are not enough to do so, and do not give any information about the secret.

Info

If you are not comfortable with the concept of Finite Fields, I recommend you to read some of the resources I listed at the end of this article.

It is supposed that we are working in FF_qFF_q when not specified.

Splitting the Secret

Let’s take the following example: we want to split the secret s = 42s = 42 into n = 5n = 5 parts, such that any k = 3k = 3 parts can be used to reconstruct the secret.

The first step is to sample a random polynomial f(x) = P_(k-1) (x) = a_0 + a_1 x + ... + a_(k-1) xf(x) = P_(k-1) (x) = a_0 + a_1 x + ... + a_(k-1) x of degree k-1k-1 such that a_0 = sa_0 = s. This gives us the property that f(0) = sf(0) = s.

f(x) = P_2 (x) = 42 + 5 x + 3 x^2f(x) = P_2 (x) = 42 + 5 x + 3 x^2

Our splits, also called shares, are in fact just points of our polynomial. We can generate them by evaluating f(x)f(x) for x in S = {1,2,3,4,5}x in S = {1,2,3,4,5}. Do not evaluate for x = 0x = 0 as this would obviously just give away the secret.

z_1 &= f(1) = 42 + 5 + 3    &=& 50 \
z_2 &= f(2) =z_1 &= f(1) = 42 + 5 + 3    &=& 50 \
z_2 &= f(2) =

So our shares are Z_1(1, 50)Z_1(1, 50), Z_2(2, 64)Z_2(2, 64), Z_3(3, 84)Z_3(3, 84), Z_4(4, 110)Z_4(4, 110) and Z_5(5, 142)Z_5(5, 142).

Let’s now plot the polynomial f(x)f(x):

#set text(size: 10pt)
#let f(x) = 42 + 5 * x + 3 *#set text(size: 10pt)
#let f(x) = 42 + 5 * x + 3 *

Reconstructing the Secret

Based on what we just learnt earlier and Lagrange interpolation, we know that n+1n+1 points Z_i (x_i, y_i) | i in SZ_i (x_i, y_i) | i in S will suffice to construct the polynonial P_n(x)P_n(x) of degree nn.

In our case, deg(f(x)) = k-1deg(f(x)) = k-1, so we need (k-1)+1 = k(k-1)+1 = k points to restore f(x)f(x), just as described in the beginning.

Based on the shares we generated earlier, let’s take Z_1(1, 50)Z_1(1, 50), Z_3(3, 84)Z_3(3, 84) and Z_5(5, 142)Z_5(5, 142) to reconstruct the polynomial f(x)f(x) using Lagrange interpolation:

#set text(size: 10pt)

#let l1(x) = ((x - 3) * (x #set text(size: 10pt)

#let l1(x) = ((x - 3) * (x

Commitments, Proofs and Verifications

In a perfect world where everyone is honest and where there are no transmission errors caused by cosmic rays, we could just send the shares to the participants and call it a day. But guess what? Sh*t happens.

We need a way to check that the share we receive as a shareholder after the secret has been split is actually a valid share. One could simply try to verify it by reconstructing the polynomial and checking if the secret is the same, but this clearly against the whole point of Shamir’s Secret Sharing.

Instead, let’s take advantage of the properties of elliptic curves to create a commitment scheme. After we have generated our polynomial f(x)f(x), we can take each coefficient a_ia_i and multiply it by the generator point GG of the curve. This gives us a few values C = {phi.alt_0, phi.alt_1, ..., phi.alt_(k-1)} | pC = {phi.alt_0, phi.alt_1, ..., phi.alt_(k-1)} | p that we can send to the shareholders.

Point operations in an elliptic curve

The trapdoor function (easy to do one way, hard the other) of an elliptic curve is the multiplication of a point PP by a scalar nn. This consists of adding the point PP to itself nn times. This operation is denoted as n dot Pn dot P.

Let’s start out by graphically representing the addition of two points GG and AA in an elliptic curve.

One easy way to think of the addition is to draw a line that goes through GG and AA, and find the third point of intersection with the curve. The symmetrical point is the result of the addition of GG and AA. This is because elliptic curve have the property that any line between two points intersects the curve at most one more time.

In the case where there is no third point of intersection (i.e. the line is vertical), we define the result of the addition as the point at infinity, denoted as cal(O)cal(O).

#set text(size: 10pt)

#let point(x) = (x, calc.sq#set text(size: 10pt)

#let point(x) = (x, calc.sq

The resulting point B = G + AB = G + A is then reflected to CC over the x-axis. This symmetry property is easily explained by the fact that the curve’s yy coordinates are squared, so for a given xx coordinate, there are two possible yy coordinates, yy and -y-y.

The operation can then be done over and over again to find subsequent points.

#set text(size: 10pt)

#let point(x) = (x, calc.sq#set text(size: 10pt)

#let point(x) = (x, calc.sq

In the case where G = AG = A, we can’t really draw a line between the two points, so we take the tangent to the curve at GG and find the third point of intersection. This is the result of the addition of GG and GG, denoted as 2 dot G2 dot G.

#set text(size: 10pt)

#let point(x) = (x, calc.sq#set text(size: 10pt)

#let point(x) = (x, calc.sq

To find the tangent at G(x_G, y_G)G(x_G, y_G), we need to find the slope m = (d y)/(d x) = y'(x)m = (d y)/(d x) = y'(x).

Differentiating both sides with respect to xx, y^2y^2, which actually depends on xx can be written as y(x)^2y(x)^2 for clarity:

(y(x)^2)' &= 2y(x) dot y'(x)(y(x)^2)' &= 2y(x) dot y'(x)

See this as if we were differentiating u^2u^2, where u = y(x)u = y(x).

The right side is just a function of xx, so we can differentiate it as we would with any other function:

(x^3 + 7)' = 3x^2 (x^3 + 7)' = 3x^2

Our differentiated equation is:

2y dot y'(x) = 3x^2 2y dot y'(x) = 3x^2

Solving for m = y'(x)m = y'(x):

y'(x) = (3x^2)/(2y) = m y'(x) = (3x^2)/(2y) = m

Notice that the tangent’s slope depends on both xx and yy, which makes sense because we’d have two different slopes otherwise:

(3x^2)/(2y) = plus.minus (3x^2)/(2sqrt(x^3 + 7))(3x^2)/(2y) = plus.minus (3x^2)/(2sqrt(x^3 + 7))

And we have our tangent equation for x_Gx_G and y_Gy_G:

t: y &= m(x - x_G) + y_G \
     &= (3x_G^2)/(2y_G)t: y &= m(x - x_G) + y_G \
     &= (3x_G^2)/(2y_G)

When a shareholder wants to verify their share Z_i = (i, f(i))Z_i = (i, f(i)), they can check with the following equation:

f(i) dot G &= sum_(j=0)^(k-1) (phi.alt_j dot i^j) f(i) dot G &= sum_(j=0)^(k-1) (phi.alt_j dot i^j)

You could see this procedure as computing the “public keys” of the coefficients. This method is also called “Feldman’s Verifiable Secret Sharing”.

One may argue that disclosing a_0 dot G = s dot Ga_0 dot G = s dot G could give information about the polynomial, but if we suppose that ss is an EC secret key, the public key s dot Gs dot G is supposed public and may be shared. Finding ss with s dot Gs dot G comes down to solving the discrete logarithm problem, which is supposed really hard here.

Another way the dealer could commit to the polynomial he generated without directly sharing s dot Gs dot G, is to add a so-called “blinding polynomial”, a pretty common concept in cryptography.

Let’s now instead take phi.alt_i = a_i dot G + b_i dot Hphi.alt_i = a_i dot G + b_i dot H where b_ib_i comes from a randomly generated polynomial g(x) = b_0 + b_1 x + ... + b_(k-1) x^(k-1)g(x) = b_0 + b_1 x + ... + b_(k-1) x^(k-1), our blinding polynomial. H != GH != G is just another generator point on the curve. The dealer will now needs to distribute slightly different shares Z_i = (i, f(i), g(i))Z_i = (i, f(i), g(i)).

Shareholders may now verify their shares with:

f(i) dot G + g(i) dot H &= sum_(j=0)^(k-1) (phi.alf(i) dot G + g(i) dot H &= sum_(j=0)^(k-1) (phi.al

This second method is known as “Pedersen’s Verifiable Secret Sharing”.

Bob just got hit by a bus, what now?

Our good old friend disappeared along with his share, and now other bearers are scared of losing too many shares until they can’t recover the secret. They could reiterate the dealing procedure by all sending their shares to a single person which then redistributes the new ones. However this is not feasible in the case where everyone distrusts each other. We need a multi-computational way of re-issuing a new share without someone ever recovering the secret.

We will need kk shareholders, denoted RR to re-issue a new share Z_ell = (ell, f(ell)) = (ell, z_ell)Z_ell = (ell, f(ell)) = (ell, z_ell).

Each shareholder begins by computing their Lagrange multiplier:

l_i (ell) = product_(j in R,j!=i)(ell-j)/(i-j)l_i (ell) = product_(j in R,j!=i)(ell-j)/(i-j)

After multiplying with their share’s z_i = f(i)z_i = f(i), they randomly split it into kk so-called Lagrange-parts partial_(i,j)partial_(i,j) in order to distribute them to other bearers jj:

z_i dot l_i &= partial_(i,1) + partial_(i,2) + ...z_i dot l_i &= partial_(i,1) + partial_(i,2) + ...

The exchange matrix EE can be represented as:

E_(k times k) = mat(
    partial_(1,1), partial_(1E_(k times k) = mat(
    partial_(1,1), partial_(1

Where the iith row corresponds to the Lagrange-parts that the shareholder ii will send and the jjth column to the Lagrange-parts the shareholder jj will receive.

Each shareholder jj computes the partial-share:

sigma_j = sum_(i in R) partial_(i,j)sigma_j = sum_(i in R) partial_(i,j)

Where partial_(i,j)partial_(i,j) is the jjth Lagrange-part of ii. They respectively send sigma_jsigma_j to the new bearer ellell, which finally computes his share:

z_ell &= sum_(i in R) sigma_j \z_ell &= sum_(i in R) sigma_j \

This can be rewritten as:

sum_(j in R) sigma_j &= sum_(j in R) sum_(i in R) sum_(j in R) sigma_j &= sum_(j in R) sum_(i in R)

Inception

Another angle to tackle this problem from is to re-use Secret Sharing inside our Secret Sharing scheme (Inception, anyone?).

Let’s say we have our recovery group RR, our new shareholders NN and A = R union NA = R union N for convenience.

  1. Each shareholder i in Ri in R generates a random polynomial g_i (x)g_i (x) of degree k-1k-1.
  2. They each compute the auxiliary shares d_(i,j) = g_i (j)d_(i,j) = g_i (j) for j in Aj in A.
  3. Every shareholder j in Aj in A receives the auxiliary shares d_(i,j)d_(i,j) from the recovery group.
  4. Each shareholder j in Rj in R computes the aggregated share H(j) = u_j = z_j + sum_(i in R) d_(i,j)H(j) = u_j = z_j + sum_(i in R) d_(i,j) and shares it to everyone in NN.
  5. Each future bearer j in Nj in N can interpolate the polynomial H(x)H(x) from the shares u_ju_j and compute their share z_j = H(j) - sum_(i in R) d_(i,j) = u_j - sum_(i iz_j = H(j) - sum_(i in R) d_(i,j) = u_j - sum_(i i

Let’s take a look at the math:

z_ell &= (H(ell) - sum_(i in R) d_(i, ell)) | ell z_ell &= (H(ell) - sum_(i in R) d_(i, ell)) | ell

But wait! We just learnt how to implement a secure, verifiable secret sharing scheme and we aren’t even using it!

Verifiable Inception

Let’s review the protocol, this time including relevant checks to ensure the data we’re receiving is genuine. We assume the original dealer was a good guy and already used Feldman’s VSS, providing the commitments C = {phi.alt_0, phi.alt_1, ..., phi.alt_(k-1)} | pC = {phi.alt_0, phi.alt_1, ..., phi.alt_(k-1)} | p for the OG polynomial f(x) = a_0 + a_1 x + ... + a_(k-1) x^(k-1)f(x) = a_0 + a_1 x + ... + a_(k-1) x^(k-1) to everyone.

  1. Each shareholder i in Ri in R generates a random polynomial g_i (x) = b_(i,0) + b_(i,1) x + ... + b_(i,k-1) x^g_i (x) = b_(i,0) + b_(i,1) x + ... + b_(i,k-1) x^ of degree k-1k-1.
  2. They also compute and share for j in Rj in R to see their commitments: Gamma_i = {psi_(i,0), psi_(i, 1), ..., psi_(i, k-1Gamma_i = {psi_(i,0), psi_(i, 1), ..., psi_(i, k-1
  3. They each compute the auxiliary shares d_(i,j) = g_i (j)d_(i,j) = g_i (j) for j in Aj in A, and shares them once they have received all other Gamma_i | i in RGamma_i | i in R
  4. Every shareholder j in Aj in A receives the auxiliary shares d_(i,j)d_(i,j) from i in Ri in R and checks whether they match Gamma_iGamma_i d_(i,j) dot G &= sum_(m=0)^(k-1) psi_(i,m) dot j^md_(i,j) dot G &= sum_(m=0)^(k-1) psi_(i,m) dot j^m
  5. Before computing anything else, each shareholder j in Rj in R commits to the upcoming H(x)H(x) polynomial and sends it to i in Ai in A: T = {theta_i}_(i in R) | theta_i = sum_(k in R) psT = {theta_i}_(i in R) | theta_i = sum_(k in R) ps
  6. Each shareholder j in Rj in R computes the aggregated share H(j) = u_j = z_j + sum_(i in R) d_(i,j)H(j) = u_j = z_j + sum_(i in R) d_(i,j).
  7. Each inductee i in Ni in N first waits to receive all TT, checks that they are the same from everyone, and then receives u_j | j in Ru_j | j in R.
  8. Each future shareholder i in Ni in N checks whether each given u_j | j in Ru_j | j in R is genuine: u_j dot G &= sum_(i=0)^(k-1) phi.alt_i + sum_(i inu_j dot G &= sum_(i=0)^(k-1) phi.alt_i + sum_(i in
  9. Each future bearer j in Nj in N can interpolate the polynomial H(x)H(x) from the shares u_ju_j and compute their share z_j = H(j) - sum_(i in R) d_(i,j) = u_j - sum_(i iz_j = H(j) - sum_(i in R) d_(i,j) = u_j - sum_(i i

Single share issuing

In the case where we only want to issue a single share, conduition proposed a clever way to remove the subtracting step at the end by adding a root at ell | {ell} = Nell | {ell} = N to the blinding polynomial. This way, we have:

g_i (x) = (x - ell) dot P_(k-2) (x) | i in S, spacg_i (x) = (x - ell) dot P_(k-2) (x) | i in S, spac

When interpolating H(ell)H(ell), the blinding polynomials g_i (ell) | i in Sg_i (ell) | i in S cancel out and we directly get z_ellz_ell:

z_ell &= H(ell) \
      &= sum_(i in S) (z_i + u_iz_ell &= H(ell) \
      &= sum_(i in S) (z_i + u_i

Note that we could try to add multiple roots: (x- ell_1)(x-ell_2) dot g_i (x)(x- ell_1)(x-ell_2) dot g_i (x), but then all the new shareholders would have access to the new shares.

You could also apply the same verifying scheme as before to this method.

Shared Secrets with Elliptic Curves

Sharing pre-defined secrets is cool, but what if we wanted to collectively generate one for nn people? Let’s say we have our good old Alice, Bob and Charlie trying to communicate with each other securely. Alice doesn’t trust Charlie to generate the secret locally and share it to everyone because he’s the type of guy to write his passwords on sticky notes. Bob doesn’t trust Alice either because her idea of a strong password is “password123” — used across five accounts.

In elliptic curves, we have the following property:

(a dot G) dot b = (b dot G) dot a(a dot G) dot b = (b dot G) dot a

While this may just seem like simple associativity, it’s actually pretty cool.

Let’s say we have n = 2n = 2 participants, Alice and Bob, each with their key pair (p_i, P_i)(p_i, P_i), where p_ip_i is the private key and P_iP_i is the public key. We can generate a shared secret SS by leveraging the property above:

  1. Alice generates a random scalar p_ap_a, her private key and sends P_a = p_a dot GP_a = p_a dot G to Bob.
  2. Bob also generates p_bp_b and sends back P_b = p_b dot GP_b = p_b dot G to Alice.
  3. Alice computes S = p_a dot P_b = p_a dot (p_b dot G)S = p_a dot P_b = p_a dot (p_b dot G).
  4. Bob computes S = p_b dot P_a = p_b dot (p_a dot G)S = p_b dot P_a = p_b dot (p_a dot G).

You can see that both Alice and Bob end up with the same shared secret SS without ever having to share their private keys.

This process can also be extended to nn participants, let’s take n = 3n = 3 with Alice, Bob and Charlie here for simplicity:

  1. Alice, Bob and Charlie generate their private keys p_ap_a, p_bp_b and p_cp_c respectively.
  2. They compute their public keys P_a = p_a dot GP_a = p_a dot G, P_b = p_b dot GP_b = p_b dot G and P_c = p_c dot GP_c = p_c dot G and share them with each other.
  3. Alice computes P_(b a) = P_a dot p_b = (p_a dot p_b) dot GP_(b a) = P_a dot p_b = (p_a dot p_b) dot G, P_(c a) = P_a dot p_c = (p_a dot p_c) dot GP_(c a) = P_a dot p_c = (p_a dot p_c) dot G and sends them to Charlie and Bob respectively.
  4. Bob computes P_(c b) = P_b dot p_c = (p_b dot p_c) dot GP_(c b) = P_b dot p_c = (p_b dot p_c) dot G and sends it to Alice, he can already compute S = P_(c a) dot p_b = (p_a dot p_c dot p_b) dot GS = P_(c a) dot p_b = (p_a dot p_c dot p_b) dot G.
  5. Charlie computes S = P_(b a) dot p_c = (p_a dot p_b dot p_c) dot GS = P_(b a) dot p_c = (p_a dot p_b dot p_c) dot G.
  6. Alice computes S = P_(c b) dot p_a = (p_b dot p_c dot p_a) dot GS = P_(c b) dot p_a = (p_b dot p_c dot p_a) dot G

And voilà! All participants now have the same shared secret SS. I’ll let figuring out the general case for nn participants as an exercise to the reader (not because I’m lazy, I swear).

This secret can now be used as a symmetric key for encryption, a seed for a PRNG, TOTP key, etc.

ECDSA Signatures

Elliptic curves may not write your emails, but they can help prove you sent them. Let’s take a look at the Elliptic Curve Digital Signature Algorithm (ECDSA). Our goal is to output a signature (r, s)(r, s) for a given message mm, so that the recipient can verify that the sender is authentic. The sender’s keys are (p, P)(p, P), with pp the private key, and PP the public one.

  1. Compute the hash h = H(m)h = H(m) where H(x)H(x) is any cryptographic hash function (e.g. SHA-256).
  2. Generate a random kk number in in the current subgroup.
  3. Calculate the associated random point on the curve K = k dot GK = k dot G, with GG a generator, with x_Kx_K the xx-coordinate of this point.
  4. Calculate the signature s = k^(-1) dot (h + x_K dot p)s = k^(-1) dot (h + x_K dot p), where k^(-1)k^(-1) is the modular inverse of kk.

By sending (r, s)(r, s), you confirm you know:

The verifier may follow this procedure to check if the message mm that he received along with (r,s)(r,s) is authentic:

  1. Compute the hash h = H(m)h = H(m) with the same cryptographic hash function defined before.
  2. Calculate the modular inverse of ss: S = s^(-1)S = s^(-1)
  3. Recover the random point used in the signature process: K = h S dot G + r S dot PK = h S dot G + r S dot P
  4. Check whether x_K = rx_K = r, if so, then the message is authentic.

To prove this works, let’s start with the definition of the signature ss:

    & s = k^(-1) dot (h + x_K dot p) \
<==>& s dot    & s = k^(-1) dot (h + x_K dot p) \
<==>& s dot

Incorporate (a)(a) into KK:

K &= h S dot G + r S dot P \
  &= h s^(-1) dot G +K &= h S dot G + r S dot P \
  &= h s^(-1) dot G +

Which is just our definition of KK when generating the signature.

Cool Kids Public Key Recovery

Let’s suppose you are talking through a Tin can telephone with your friend and every byte you send matters. You don’t want to send the public key PP along with the signature (r, s)(r, s) because that’s just too much data. Instead, you can recover the public key from the signature and the message.

Given x_Kx_K, there are typically two candidate points K'_iK'_i that fit. And since we know that K = h S dot G + r S dot PK = h S dot G + r S dot P, which we just verified works, it can be rearranged to isolate the public key PP:

    K'_i &= h S dot G + r S dot P_i \
<==> P_i &=     K'_i &= h S dot G + r S dot P_i \
<==> P_i &=

To choose which one is the correct one, we need to verify the signature with each P_iP_i:

K_i = h S dot G + r S dot P_i = (x_K, y_K) \
x_K =K_i = h S dot G + r S dot P_i = (x_K, y_K) \
x_K =

This ambiguity is often removed by adding a single bit b in {0,1}b in {0,1} into the signature message: (r, s, b)(r, s, b)

Schnorr Signatures

Schnorr signatures are a bit like ECDSA, but faster and simpler. We are going to use the same keys (p, P)(p, P) as before, with pp the private key and PP the public one. We’ll first see the procedure and then discuss the mathematical proof of why this works.

  1. Sample a random nonce r <- ZZ_nr <- ZZ_n
What the hell is ZZ_nZZ_n?

The set ZZ_nZZ_n is a cyclic group of integers, isomorphic to the quotient group ZZ slash n ZZZZ slash n ZZ. It is basically just the set of integers modulo nn.

ZZ_n = {0, 1, 2, ..., n-1}ZZ_n = {0, 1, 2, ..., n-1}

In our case, nn is the order (how many points are in) of the subgroup generated by GG. If the curve’s cofactor hh is 1, then nn is the order of the curve. If hh is not 1, the order is n/h = n/h =

  1. Multiply it by the generator: R = r GR = r G
  2. We can now compute the challenge e = H(R || P || m)e = H(R || P || m)
  3. And the signature: s = r + e ps = r + e p

The final signature is (R, s)(R, s).

Once the signature has been emitted, verifying it is as easy as checking:

s dot G = R + e Ps dot G = R + e P

To know ee, the verifier needs to know the message mm, the public key PP. In some cases, you might not need to include the public key into the challenge and simply hash e = H(R || m)e = H(R || m).

Why it Works

There isn’t really a proof needed for the verifying step as it’s just factoring out GG, but here you are:

    & s dot G = R + e P \
<==>& s dot G = underbra    & s dot G = R + e P \
<==>& s dot G = underbra

One trickier part is to explain why we are adding a random nonce rr to both the signature and the challenge. This value has to be sampled randomly and must not be reused. If it is, the private key can be recovered. Let’s first take the case where no rr is used:

    & s = e p \
<==>& p = s^(-1) e    & s = e p \
<==>& p = s^(-1) e

Recovering the private key is as simple as multiplying the challenge ee by the modular inverse of ss. Not good.

Now, let’s take the case where rr is reused, with two messages m_1m_1 and m_2m_2, along with their respective signatures (R, s_1)(R, s_1) and (R, s_2)(R, s_2):

cases(
    space s_1 = r + e_1 p <==> r = (s_1 - ecases(
    space s_1 = r + e_1 p <==> r = (s_1 - e

Combining (a)(a) and (b)(b):

    & r = (s_1 - e_1 p) = (s_2 - e_2 p) \
<==>& e_    & r = (s_1 - e_1 p) = (s_2 - e_2 p) \
<==>& e_

You may see rr as an additional unknown variable that is used to prevent the linear equation system from being solved, because for nn messages, you have nn equations and n+1n+1 unknowns, which is unsolvable in our case.

Aggregating Signatures

Schnorr signatures have the nice property that they can be aggregated, this means that a group of people can sign a message mm together and the signature can be verified as if it was signed by a single person. Let’s consider our signing group S = {a,b,c}S = {a,b,c} for Alice, Bob and Charlie respectively.

&underline(&underline(

The aggregated nonce is simply the sum of all nonces:

R &= sum_(i in S) R_i \
  &= R_a + R_b + R_c \
  &R &= sum_(i in S) R_i \
  &= R_a + R_b + R_c \
  &

Likewise for the public key:

P &= sum_(i in S) P_i \
  &= P_a + P_b + P_c \
  &P &= sum_(i in S) P_i \
  &= P_a + P_b + P_c \
  &

Each of them computes the challenge ee along with the final signature s_is_i with the parameters just agreed upon:

e = H(R || P || m) \
s_i = e p_i + r_ie = H(R || P || m) \
s_i = e p_i + r_i

Aggregate again:

s = sum_(i in S) s_is = sum_(i in S) s_i

The final signature that can be sent to others is (R, s)(R, s).

Because we are just adding signatures parts together, we can group the nonces and the private keys in our final signature:

s &= sum_(i in S) s_i \
  &= sum_(i in S) (e p_i +s &= sum_(i in S) s_i \
  &= sum_(i in S) (e p_i +

In the verifying step, multiplying each side by GG:

s G &= sum_(i in S) r_i dot G + e dot sum_(i in S)s G &= sum_(i in S) r_i dot G + e dot sum_(i in S)

But wait!

At no point in this procedure, we ever check if the nonces or the public keys provided are honest. What if Charlie provided a malicious key P^*_cP^*_c in the sharing step ? Because everyone doesn’t send their public key at the exact same time, Carol could wait for everyone to send theirs, and compute:

P^*_c = P_c - P_a - P_b \P^*_c = P_c - P_a - P_b \

The aggregated key will look like:

P &= sum_(i in S) P_i \
  &= P_a + P_b + P^*_c \
 P &= sum_(i in S) P_i \
  &= P_a + P_b + P^*_c \

Carol just wiped everyone else from the signing key, and is now in full control of the signature. How can we prevent that ?

Multi-Signatures, don’t trust, verify

The most common way to prevent this is to force everyone to provide a proof that their public key is honest. This is done by providing a Proof of Knowledge for the private key, proving that they know the private key associated to the public key they provided.

Let’s take the case of a prover Patricia and a verifier Victor. Patricia wants to prove that she knows the private key pp associated to the public key P = p dot GP = p dot G. The proof is done in four steps:

  1. Patricia samples r <- ZZ_nr <- ZZ_n at random and sends R = r dot GR = r dot G to Victor.
  2. Victor sends a challenge c <- ZZ_nc <- ZZ_n to Patricia.
  3. Patricia computes z = r + c pz = r + c p and sends it to Victor.
  4. Victor verifies that z dot G = R + c Pz dot G = R + c P.

This works because:

z       &= r + c p \
z dot G &= (r + c p) dot G \
z       &= r + c p \
z dot G &= (r + c p) dot G \

But what if Patricia doesn’t know the associated private key but still wants to prove that her key is honest ? Remember commitment schemes ? We can use them here. In our case, every participant will commit to their public key before disclosing it. Think of it as putting your public key in a box, sealing it and waiting for everyone to do the same before opening it. Let’s get back to our group S = {a,b,c}S = {a,b,c}:

  1. Each participant ii hashes their public key P_iP_i and sends H(P_i)H(P_i) to everyone.
  2. Once everyone has sent their hash, they disclose their public key P_iP_i.
  3. Everyone verifies that the hash they received matches the public key.
  4. The signing process continues as usual.

This way, everyone can be sure that the public keys are honest and that no one is trying to pull a fast one.

There’s more!

Random nonces are also aggregated, and at no point we are verifying that they are authentic. The exploit method is a bit trickier, I recommend you to read this article by conduition on the subject if you want to know the details.

Rust Implementation

I’ve been using Rust for a while now (even though I feel like I’m a complete beginner and still can’t manage to fully understand lifetimes), so I thought it would be a good idea to implement Shamir’s Secret Sharing and other algorithms in Rust.

Warning

Disclaimer: Be aware that this is not suited for production use. Consider using a battle-tested cryptography instead.

Dependencies

Because working with elliptic curves is a bit tricky, we’ll use bls12_381_plus, which is a Rust implementation of the BLS12-381 curve. This abstracts a lot of the complexity and allows us to focus on the actual implementation of the algorithm.

#set text(size: 10pt)

#canvas({
    import draw: #set text(size: 10pt)

#canvas({
    import draw:
Representation of the BLS12-381 curve

You could also have used any other elliptic curve, such as Secp256k1.

Little side note on the Secp256k1 curve

Secp256k1, popular in cryptocurrencies like Bitcoin, is chosen not only for its efficiency but for its simplicity and transparency—qualities that help to prevent hidden vulnerabilities.

A cautionary tale here is the Dual_EC_DRBG scandal, where it was discovered that a backdoor had likely been embedded into a cryptographic algorithm, making it insecure for sensitive use. Secp256k1’s constants were selected in a predictable way, which significantly reduces the possibility that the curve’s creator inserted any sort of backdoor into the curve.

The curve is defined by the equation y^2 = x^3 + 7y^2 = x^3 + 7, represented below, even though in reality it just looks like a bunch of points scattered around because it is taken over a finite field FF_q | q = 2^256 - 2^32 - 2^9 - 2^8 - 2^7 - 2^6 - FF_q | q = 2^256 - 2^32 - 2^9 - 2^8 - 2^7 - 2^6 - (yes, qq is prime, I know, it’s weird).

#set text(size: 10pt)

#canvas({
    import draw: #set text(size: 10pt)

#canvas({
    import draw:
Representation of the Secp256k1 curve

I also made my own Polynomial struct to handle the polynomial operations, such as addition, multiplication, evaluation, etc.

polynomial.rs
use crate::types::{Point, Scalar, GENERATOR, GENERATOR_BLINDING};
use elliptic_curve::Field;
use std::ops::Mul;
/// Helper struct to represent a polynomial and evaluate it
#[derive(Clone, Debug)]
pub struct Polynomial {
    pub coefficients: Vec<Scalar>,
}
 
impl Polynomial {
    pub fn evaluate(&self, x: Scalar) -> Scalar {
        if self.coefficients.is_empty() {
            return Scalar::ZERO;
        }
        let degree = self.coefficients.len() - 1;
        let mut out = self.coefficients[degree];
        for i in (0..degree).rev() {
            out *= x;
            out += self.coefficients[i];
        }
        out
    }
 
    pub fn random<D>(degree: D) -> Self
    where
        D: Into<usize>,
    {
        let mut rng = rand::thread_rng();
        let mut coefficients = vec![];
        for _ in 0..=degree.into() {
            coefficients.push(Scalar::random(&mut rng));
        }
        Self { coefficients }
    }
 
    pub fn fill<U>(&mut self, degree: U)
    where
        U: Into<usize>,
    {
        let mut rng = rand::thread_rng();
        for _ in self.coefficients.len()..=degree.into() {
            self.coefficients.push(Scalar::random(&mut rng));
        }
    }
 
    pub fn with_fill<U>(mut self, degree: U) -> Self
    where
        U: Into<usize>,
    {
        self.fill(degree);
        self
    }
 
    pub fn commit(&self) -> Vec<Point> {
        self.coefficients.iter().map(|e| GENERATOR * e).collect()
    }
 
    pub fn zip_commit(&self, other: &Self) -> Vec<Point> {
        self.coefficients
            .iter()
            .zip(other.coefficients.clone())
            .map(|(a, b)| a * GENERATOR + b * *GENERATOR_BLINDING)
            .collect()
    }
 
    /// Add a root at c by multiplying the polynomial by (x - c)
    pub fn add_root(&mut self, c: Scalar) {
        *self *= Polynomial::new(vec![c, -Scalar::ONE]);
    }
 
    pub fn with_root(mut self, c: Scalar) -> Self {
        self.add_root(c);
        self
    }
 
    pub fn new(coefficients: Vec<Scalar>) -> Self {
        Self { coefficients }
    }
 
    pub fn empty() -> Self {
        Self {
            coefficients: vec![],
        }
    }
 
    pub fn degree(&self) -> usize {
        if self.coefficients.is_empty() {
            return 0;
        }
        self.coefficients.len() - 1
    }
 
    pub fn lagrange(points: Vec<(Scalar, Scalar)>) -> Self {
        if points.is_empty() {
            return Self {
                coefficients: vec![],
            };
        }
 
        let degree = points.len() - 1;
        let mut result = vec![Scalar::ZERO; degree + 1];
 
        // For each point, calculate its contribution to the final polynomial
        for (i, (x_i, y_i)) in points.iter().enumerate() {
            let mut basis_poly = vec![Scalar::ONE]; 
 
            for (j, (x_j, _)) in points.iter().enumerate() {
                if i != j {
                    let mut new_coeffs = vec![Scalar::ZERO];
                    new_coeffs.extend(basis_poly.iter().cloned());
 
                    // Subtract x_j times each coefficient
                    for (k, coeff) in basis_poly.iter().enumerate() {
                        new_coeffs[k] -= *x_j * *coeff;
                    }
 
                    basis_poly = new_coeffs;
                }
            }
 
            // Calculate denominator: product of (x_i - x_j) for all j != i
            let mut denominator = Scalar::ONE;
            for (j, (x_j, _)) in points.iter().enumerate() {
                if i != j {
                    denominator *= *x_i - *x_j;
                }
            }
 
            // Multiply basis polynomial by y_i/denominator
            let coefficient = *y_i * denominator.invert().unwrap();
            for coeff in basis_poly.iter_mut() {
                *coeff *= coefficient;
            }
 
            for (k, coeff) in basis_poly.iter().enumerate() {
                result[k] += *coeff;
            }
        }
 
        Self {
            coefficients: result,
        }
    }
}
 
impl PartialEq for Polynomial {
    fn eq(&self, other: &Self) -> bool {
        self.coefficients == other.coefficients
    }
}
 
impl Mul<Scalar> for Polynomial {
    type Output = Self;
 
    fn mul(self, rhs: Scalar) -> Self::Output {
        let mut coefficients = vec![];
        for c in self.coefficients {
            coefficients.push(c * rhs);
        }
        Self { coefficients }
    }
}
 
impl Mul<Polynomial> for Polynomial {
    type Output = Self;
 
    fn mul(self, rhs: Polynomial) -> Self::Output {
        let mut coefficients = vec![Scalar::ZERO; self.degree() + rhs.degree() + 1];
        for i in 0..=self.degree() {
            for j in 0..=rhs.degree() {
                coefficients[i + j] += self.coefficients[i] * rhs.coefficients[j];
            }
        }
        Self { coefficients }
    }
}
 
impl std::ops::MulAssign<Scalar> for Polynomial {
    fn mul_assign(&mut self, rhs: Scalar) {
        for c in self.coefficients.iter_mut() {
            *c *= rhs;
        }
    }
}
 
impl std::ops::MulAssign<Polynomial> for Polynomial {
    fn mul_assign(&mut self, rhs: Polynomial) {
        if self.coefficients.is_empty() {
            return;
        }
        if rhs.coefficients.is_empty() {
            self.coefficients = vec![];
            return;
        }
        let mut coefficients = vec![Scalar::ZERO; self.degree() + rhs.degree() + 1];
        for i in 0..=self.degree() {
            for j in 0..=rhs.degree() {
                coefficients[i + j] += self.coefficients[i] * rhs.coefficients[j];
            }
        }
        self.coefficients = coefficients;
    }
}
 
impl std::ops::Add for Polynomial {
    type Output = Self;
 
    fn add(self, rhs: Self) -> Self::Output {
        let mut coefficients = vec![];
        let mut i = 0;
        let mut j = 0;
        while i < self.coefficients.len() && j < rhs.coefficients.len() {
            coefficients.push(self.coefficients[i] + rhs.coefficients[j]);
            i += 1;
            j += 1;
        }
        while i < self.coefficients.len() {
            coefficients.push(self.coefficients[i]);
            i += 1;
        }
        while j < rhs.coefficients.len() {
            coefficients.push(rhs.coefficients[j]);
            j += 1;
        }
        Self { coefficients }
    }
}
 
impl std::ops::AddAssign for Polynomial {
    fn add_assign(&mut self, rhs: Self) {
        let mut coefficients = vec![];
        let mut i = 0;
        let mut j = 0;
        while i < self.coefficients.len() && j < rhs.coefficients.len() {
            coefficients.push(self.coefficients[i] + rhs.coefficients[j]);
            i += 1;
            j += 1;
        }
        while i < self.coefficients.len() {
            coefficients.push(self.coefficients[i]);
            i += 1;
        }
        while j < rhs.coefficients.len() {
            coefficients.push(rhs.coefficients[j]);
            j += 1;
        }
        self.coefficients = coefficients;
    }
}
 
impl std::ops::SubAssign for Polynomial {
    fn sub_assign(&mut self, rhs: Self) {
        // Add the negation of the second polynomial
        *self += rhs * Scalar::ONE.neg();
    }
}
 
impl std::ops::AddAssign<&Polynomial> for Polynomial {
    fn add_assign(&mut self, rhs: &Self) {
        let mut coefficients = vec![];
        let mut i = 0;
        let mut j = 0;
        while i < self.coefficients.len() && j < rhs.coefficients.len() {
            coefficients.push(self.coefficients[i] + rhs.coefficients[j]);
            i += 1;
            j += 1;
        }
        while i < self.coefficients.len() {
            coefficients.push(self.coefficients[i]);
            i += 1;
        }
        while j < rhs.coefficients.len() {
            coefficients.push(rhs.coefficients[j]);
            j += 1;
        }
        self.coefficients = coefficients;
    }
}
 
impl std::ops::Sub for Polynomial {
    type Output = Self;
 
    fn sub(self, rhs: Self) -> Self::Output {
        // Add the negation of the second polynomial
        self + rhs * Scalar::ONE.neg()
    }
}
types.rs
use bls12_381_plus::{G1Projective, Scalar as BLSScalar};
use elliptic_curve::hash2curve::ExpandMsgXmd;
use lazy_static::lazy_static;
 
pub type Scalar = BLSScalar;
/// A share of a secret
pub type Share = (Scalar, Scalar); // (i, f(i))
pub type PedersenShare = (Scalar, Scalar, Scalar); // (i, f(i), g(i))
 
/// Wrapper type for a commitment
pub type Point = G1Projective;
 
pub const GENERATOR: Point = G1Projective::GENERATOR;

Let’s get to the code!

Splitting the Secret

/// Split a secret into n shares, requiring k shares to recover the secret
pub fn split(s: Scalar, n: u8, k: u8) -> Result<Vec<Share>> {
    ensure!(k <= n, "Cannot require (k = {k}) > (n = {n})");
 
    // Random polynomial: secret + a_1 * x^1 + ... + a_(k-1) * x^(k-1)
    let mut poly = Polynomial::new(vec![s])
        .with_fill(k - 1);
    // Compute our n shares
    let shares = (1..=n)
        .map(|i| {
            let x = Scalar::from(i as u32);
            (x, poly.evaluate(x))
        })
        .collect();
 
    Ok(shares)
}

Verifying the Shares - Feldman’s VSS

/// Verify a share against a set of commitments
pub fn verify(share: Share, commitments: Vec<Point>) -> Result<()> {
    // We need to check that share_i * G = sum_(j=0)^(t-1) i^j * commitment_j
    let mut lhs = Point::identity();
    let rhs = GENERATOR * share.1;
    for (j, commitment) in commitments.iter().enumerate() {
        lhs += commitment * share.0.pow_vartime(&Scalar::from(j as u8).to_raw());
    }
 
    ensure!(
        bool::from((lhs - rhs).is_identity()),
        "Verification failed: {:#?} != {:#?}",
        lhs,
        rhs
    );
    Ok(())
}

Verifying the Shares - Pedersen’s VSS

lazy_static! {
    // We need a 2nd generator point - just hash a random message
    static ref GENERATOR_BLINDING: Point = G1Projective::hash::<ExpandMsgXmd<Sha256>>(
        b"generator_blinding",
        &[0xde, 0xad, 0xbe, 0xef]
    );
}
 
/// Verify a share against a set of Pedersen commitments
pub fn verify(share: PedersenShare, commitments: Vec<Point>) -> Result<()> {
    // We need to check that f(i) * G + g(i) * H = sum_(j=0)^(t-1) i^j * commitment_j
    let mut lhs = Point::identity();
    let rhs = share.1 * GENERATOR + share.2 * *GENERATOR_BLINDING;
    for (j, commitment) in commitments.iter().enumerate() {
        lhs += commitment * share.0.pow_vartime(&Scalar::from(j as u8).to_raw());
    }
 
    ensure!(
        bool::from((lhs - rhs).is_identity()),
        "Verification failed: {:#?} != {:#?}",
        lhs,
        rhs
    );
    Ok(())
}

Reconstructing the Secret

// See the Polynomial struct for the implementation of the Lagrange interpolation
pub fn recover(shares: Vec<Share>) -> Scalar {
    Polynomial::lagrange(shares).evaluate(Scalar::ZERO)
}

Schnorr Signatures

For this part, I used the Secp256k1 curve, via the really cool secp crate. The implementation is quite similar to the one I described earlier.

use rand::rngs::OsRng;
use secp::{Point, Scalar, G};
use sha2::{Digest, Sha256};
 
fn compute_challenge(public_nonce: Point, public_key: Point, message: impl AsRef<str>) -> Scalar {
    // H(R || P || m)
    let challenge: [u8; 32] = Sha256::new()
        .chain_update((public_nonce).serialize())
        .chain_update((public_key).serialize())
        .chain_update(message.as_ref().as_bytes())
        .finalize()
        .into();
    Scalar::reduce_from(&challenge)
}
 
fn sign(message: impl AsRef<str>, key: Scalar) -> Result<Signature> {
    // Enabled via the `rand` feature of the `secp` crate
    let nonce = Scalar::random(&mut OsRng); // r
 
    let public_nonce = nonce * G; // R
    let public_key = key * G; // P
 
    let challenge = compute_challenge(public_nonce, public_key, message); // e
    // s = r + e * p
    let signature = (nonce + challenge * key).not_zero()?;
 
    Ok((public_nonce, signature))
}
 
fn verify(message: impl AsRef<str>, public_key: Point, signature: Signature) -> Result<bool> {
    let challenge = compute_challenge(signature.0, public_key, message);
    // s * G =? R + e * P
    Ok(signature.1 * G == (signature.0 + challenge * public_key).not_inf()?)
}

References / Suggested readings

Acknowledgements

I wanted to thank conduition.io for his amazing articles on cryptography. They are a great source of inspiration and knowledge, and I highly recommend you to check them out if you want to learn more about cryptography.