Horcrux: Implementing Shamir's Secret Sharing in Rust (part 1)
As I mentioned in a previous blog post, I think that Rust is a good programming language to implement cryptographic algorithms, thanks to its memory safety, strong type system, ease of unit testing, and high performance. I’ve already experimented with that some years ago with the Gravity-SPHINCS algorithm that I designed during my master’s thesis.
I recently wanted to try Rust again to implement a less niche algorithm, and notably see how more recent features of the language such as const generics can help with cryptography. I settled to write an implementation of Shamir’s Secret Sharing, which I you can find on GitHub under the codename Horcrux.
In this first blog post, I’ll go through the mathematics of Shamir’s Secret Sharing, explaining the algorithms and highlighting choices that will affect implementation. In a second blog post, I’ll explain the Rust implementation, and notably how the language allows to easily test and benchmark the code, to write an algorithm generic over mathematical parameters, and to use CPU-specific instructions to make an algorithm 10x faster.
Shamir’s Secret Sharing
Threshold secret sharing
Let’s first start with a definition: Shamir’s Secret Sharing is a threshold secret sharing scheme. This means that it allows to take a secret value, typically an -bit string, and to “split” it into shares. These shares can then be distributed to various participants, which can work together to reconstruct the initial secret. More precisely, the scheme is also parameterized by some threshold , such that the knowledge of any shares is enough to reconstruct the secret. On the other hand knowing (or fewer) shares gives no information whatsoever about the secret.
Typically, this means that a single share doesn’t reveal anything about the secret (unless ). This also means that losing one share doesn’t block reconstructing the secret (unless ).
One use case could be to split the encryption key of some document (or backup, or hard drive, etc.) and spread the shares in multiple places. With that, there is no single place to steal the key – an attacker has to steal shares in multiple places to recover the encryption key – nor a single point of failure – if one or a few shares are physically destroyed by accident (e.g. due to fire) the encryption key can still be recovered.
These properties may seem like magic, so how does this scheme actually work?
Polynomial interpolation
The basic principle behind Shamir’s Secret Sharing is polynomial interpolation. Given points such that the are all distinct, there exists a unique polynomial of degree that interpolates through these points, i.e. .
Based on this principle, Shamir’s Secret Sharing works as follows. Given a secret and a threshold , set and pick uniformly at random – these coefficients define the polynomial . Then the shares are distinct points on the polynomial, i.e. points such that all the are distinct and .
The secret at (green point) can be recovered by interpolating a subset of other points (in red).
Here are some remarks from this construction.
- All points have , so that no share directly contains the secret.
- Given (at least) shares, we can reconstruct the polynomial by interpolation, and therefore the secret by computing .
- Given (at most) shares, we cannot reconstruct any information about the secret. Indeed, any secret value could equally work, essentially because one can interpolate the points to obtain a plausible polynomial of degree .
- If one of the shares gets corrupted, the secret reconstructed from shares containing it will be corrupted as well, without inherent means of detecting the error.
With less than shares, we cannot recover the secret because there exist interpolations passing through all possible secret values.
Now, how do we reconstruct from the points? There is the following explicit construction:
where is a Lagrange polynomial, equal to:
A couple remarks on Lagrange polynomials.
- You can see on this formula that the have to be distinct, so that none of the denominators is zero.
- You can verify that and for . This explains the formula for , which will evaluate to .
All in all, recovering the secret from shares can be done by computing:
Some optimizations
From this mathematical formulation, let me present a couple optimizations that can be useful in the actual implementation.
The first optimization is how we represent the shares.
- Randomized shares. As we’ve seen, each share is a point where and are elements of our finite field. Typically, if the secret contains bits, representing each field element takes bits. Therefore, if we take arbitrary , then we need bits to represent a share. Let’s call this construction randomized shares, where the are uniformly distributed at random.
- Compact shares. However, the scheme doesn’t rely on the being random: to build shares we can simply take the first integers: . We can then store each using only bits, which can be much less than for a reasonable number of shares . For example, if there are at most 256 shares then we can use only a single byte to represent , and this should be enough for practical purposes. Let’s call this construction compact shares, where the are small integers.
The second optimization is purely algorithmic, and is about how we compute the reconstruction using Lagrange polynomials. As we will see in the following section, inversion is usually the most expensive operation to compute. In the formula described above, we are computing the inverse for each pair , which is not very efficient. We can however compute fewer inverses by rewriting the formula.
- We could have a table that maps to its inverse, in order to cache common values to invert. This caching works only if the are such that the same difference comes for multiple pairs (i.e. ), which is the case for compact shares. However, in practice I didn’t see this optimization bringing the most benefits, due to the overhead of maintaining the cache table.
- A more general and optimized solution is to compute the product of all denominators before doing the inversion, so that we only have one inverse to compute for each term in the formula, i.e. in total inversions to compute for each reconstruction. This means rewriting the formula as follows.
Fields
The above description works on any polynomials whose coefficients are in a field. Let me recall the definition: a field is a set on which the following arithmetic operations are defined.
- Addition: an associative and commutative operation, with a neutral element (zero).
- Subtraction: every element has an opposite , or in other words we can subtract elements.
- Multiplication: an associative and commutative operation, with a neutral element (one), which is distributive over addition.
- Division: every non-zero element has a multiplicative inverse , or in other words there is a division operation by non-zero elements.
If you look at the formulas above, these four operations are exactly what we need to reconstruct a secret by polynomial interpolation.
The next question is which field(s) to choose in order to instantiate Shamir’s Secret Sharing. Common fields one can think of are the rational numbers , the real numbers or the complex numbers . However, these fields are infinite and therefore inconvenient to work with on computers. Even though each rational number can be represented by a finite amount of memory – the numerator and denominator are both integers – their representation can be unbounded and exceed a computer’s memory after many operations. Regarding real and complex numbers, only a subset of them can actually be computed, but again even working with this subset is inconvenient.
Finite fields
Apart from these infinite fields, there exist finite fields, that contain a finite number of elements. The structure of finite fields is actually well understood, with a canonical construction, as we’ll see in the next section. This important theorem is that finite fields are all of the form where is a prime number and a positive integer. The field contains exactly elements.
Shamir’s Secret Sharing algorithm is the same regardless of the underlying field, but implementing arithmetic operations is more or less complex depending on the choice of the field. One thing to have in mind is that we want to be able to split secrets of 256 bits (a common size for symmetric encryption keys such as AES), so the finite field should contain at least elements, and ideally the conversion between a 256-bit secret and a field element should be simple.
There are two interesting forms of finite fields, that are used in various cryptographic algorithms. As an example, elliptic curves based on either of these two categories have been standardized.
- Prime fields.
These fields are of the form for a prime , i.e. we set .
The most common elliptic curve used on the Internet today1 -
secp256r1
a.k.a. P-256 - is defined over a finite field of this form. Field operations are relatively simple to describe: they are arithmetic modulo . Despite this simple description, arithmetic in is not trivial to implement, precisely because of the modulo operation. It’s even more complex to implement in constant time. Another problem is that cannot be equal to , so we’d have to do some conversion between 256-bit secrets and elements of . - Binary fields. These fields are of the form , i.e. we set . Binary fields are used in some block ciphers: for example the “mix columns” step of AES relies on arithmetic in . Field operations are a bit more complicated to describe than “modulo ”, but using 2 as the prime allows to implement them as a combination of bitwise operations, such as XOR and shifts. As we’ll see later, recent CPUs also have dedicated instructions to help with multiplication in . The nice thing is that there is a natural one-to-one mapping between -bit secrets and elements of .
Given this constraints, for Horcrux I’ve decided to implement arithmetic in binary fields .
Arithmetic in
As I briefly mentioned, there exists an explicit construction of , which will be the basis for implementing arithmetic in . This construction is the set of polynomials with coefficients in , modulo some irreducible polynomial of degree .
This reduction modulo is determined by the Euclidean division of polynomials, which like for integers states that each polynomial can be decomposed into a quotient and a remainder such that where the polynomial has a smaller degree than . The remainder is the reduction modulo of .
In more concrete terms, after applying the reduction each field element can be represented by a polynomial with coefficients. In our case, each coefficient is simply a bit because , and operations between coefficients follow the arithmetic in , i.e. addition is a logical XOR and multiplication is a logical AND.
Representation of elements
A naive way to represent an element would be to use an array of booleans. However, we can do better by packing the bits together in some unsigned integer type. For example, if , we can use a single 64-bit integer to store all the coefficients. In the more general sense, given a word size (typically ) and a degree that is a multiple of , we represent each polynomial by an array of words, each being a -bit integer.
For example, with we can use 4 words of 64 bits, which gives the following type in Rust.
struct GF256 {
words: [u64; 4],
}
Addition and subtraction
Given two polynomials and , their sum is defined by doing an addition in coefficient by coefficient. And because addition in is a XOR operation (denoted by ), we have:
In other words, we just need to XOR the bits of A with the bits of B, and this is where the representation as an array of words shines: we can XOR bits at once with a single XOR instruction.
impl Add for GF256 {
type Output = Self;
fn add(self, other: Self) -> Self {
let mut words = [0u64; 4];
for i in 0..4 {
words[i] = self.words[i] ^ other.words[i];
}
Self { words }
}
}
For subtraction, we also need to subtract coefficient by coefficient, but because only has 2 elements, subtraction is equal to XOR as well.
Basic multiplication algorithm
Multiplication is certainly the most complex operation that we have to implement, because we have to apply the reduction modulo . This is where there are various opportunities for optimizations, and where the choice of will be important.
One way to look at multiplication is as a series of additions, generalizing over the “long multiplication” taught in school to multiply integers.
With this approach, we compute each term by taking the polynomial , multiplying it by and reducing modulo . We then add the -th term if , and ignore it if .
Because we’ll compute iteratively , , …, until , we only need a “multiply by modulo ” operation. This multiplication by is actually relatively straightforward. We first note that multiplying by shifts all the coefficients by one place:
Then, we can write down the polynomial as with:
Because is equal to zero modulo , and subtraction is equal to addition, we have the equality . This means that we can replace the first term by , or in other words:
Overall, our algorithm to multiply by is the following:
- shift each word to the left by 1 bit,
- propagate a carry bit from each word into the next word,
- apply the reduction modulo , by adding if the last carry bit is set.
impl Mul<&Self> for GF256 {
type Output = Self;
fn mul(self, other: &Self) -> Self {
let mut result = Self {
words: [0u64; 4],
};
for &word in &other.words {
for i in 0..64 {
if word & (1 << i) != 0 {
result += &self;
}
self.shl1();
}
}
result
}
}
impl GF256 {
// Based on the irreducible polynomial X^256 + X^10 + X^5 + X^2 + 1.
const P: u64 = 1 ^ (1 << 2) ^ (1 << 5) ^ (1 << 10);
// Shift left by 1 place, a.k.a. multiplication by X.
fn shl1(&mut self) {
let mut carry = 0u64;
for i in 0..4 {
let d = self.words[i];
self.words[i] = (d << 1) ^ carry;
carry = d >> 63;
}
if carry != 0 {
self.words[0] ^= Self::P;
}
}
}
Optimized multiplication algorithm
The above algorithm works, but it has some drawbacks.
- It’s rather slow because we have a loop with iterations, each of which does a few shifts and XORs on all of the words.
- It’s not constant-time as is, because the reduction modulo uses an “if” statement.
Modern CPUs have some instructions to directly interpret -bit words as binary polynomials and multiply them as such. This is actually quite similar to regular multiplication, except that there is no carry to propagate, and therefore these instructions are called “carry-less multiplication” or CLMUL on Intel processors.
Given two polynomials and of degree (at most) , their product is a polynomial of degree (at most) , that therefore fits into a “double word” of bits.
Intel’s clmul
instructions take as input two 64-bit words and return their product as a 128-bit double word.
If we come back to the multiplication of polynomials of degree that each consist of words, we can decompose multiplication into a series of clmul
instructions, followed by some reduction of the carries modulo .
In this case though, the reduction will be a bit more complex than “adding if the last carry bit is set”, because the carries to propagate are now entire words rather than bits.
I’ll discuss in the next blog post how to detect support for the relevant clmul
instructions in Rust, and select between the basic and optimized algorithms accordingly.
We’ll also see in the benchmarks that this optimization yields a 10x performance improvement!
Inversion
Once we have a multiplication operation, we can define inversion from it. Indeed, the non-zero elements of form the group of invertible elements with respect to multiplication. Therefore, Lagrange’s theorem shows that for every element , , from which we can conclude that:
So we can compute the inverse of by computing its -th power via square-and-multiply exponentiation, using up to multiplications.
impl GF256 {
const ONE: Self = Self {
words: [1u64, 0u64, 0u64, 0u64],
};
fn invert(mut self) -> Self {
let mut result = Self::ONE;
for _ in 1..256 {
self = self * &self;
result *= &self;
}
result
}
}
Choice of the irreducible polynomial
As we have seen, multiplication in relies on an irreducible polynomial, which is XOR-ed to the result when a shift produces a carry bit. It is essential that this polynomial is indeed irreducible, otherwise the arithmetic doesn’t define a field (some non-zero elements won’t be invertible, and the above formula likely won’t work for those which are invertible).
If we pick a simple case like , we can reuse a well-known irreducible polynomial such as the one used by AES, i.e. . However, what about the general case for any ?
Here are a few remarks about an irreducible polynomial of degree over .
- The first term is , because we look for a polynomial of degree .
- The last term must be , otherwise divides the polynomial , which is therefore not irreducible.
- There must be an odd number of terms. Indeed, because we work with coefficients in , any polynomial with an even number of terms will evaluate to , that is to say that 1 is a root of , therefore divides and is not irreducible.
The choice of will be quite important for performance, because it will affect the multiplication and inversion operations.
- If is sparse, i.e. has a small number of terms, arithmetic will be easier because we have to XOR fewer bits. So we are looking for polynomials of the form (trinomials) or (pentanomials).
- If all the terms of fit in the last word (i.e. , and are at most ), then XOR-ing requires only XOR-ing this last word, instead of words.
Generating irreducible polynomials of arbitrary degree over is not a trivial problem. I’ve looked a bit into the literature and found sub-categories of irreducible polynomials such as primitive polynomials and Conway polynomials. There were also some papers giving tables of such polynomials (low-weight polynomials, primitive polynomials). Some other paper discussed methods to choose “optimal” polynomials, but none of the examples where for polynomials with a power-of-2 degree.
In the end, one approach is simply a brute force algorithm, generating polynomials of the required form until we find one that is irreducible. Deciding whether a given is irreducible over is not a trivial problem either. There are various methods for factorization of polynomials over finite fields, but we can more simply defer that part to a mathematics software like Sage.
I also wanted a bit more flexibility in generating polynomials of any degree, without being limited by those already listed in some table. So I ended up writing a Sage script that:
- constructs the finite field ,
- constructs a polynomial ring over it,
- for each polynomial to test, computes its factorization, and if there was only a single factor then the polynomial was irreducible :)
For Horcrux, I mostly chose to consider power-of-two number of bits, and the script found the following polynomials. Interestingly, there were no trinomials for these values of , only pentanomials.
X^8 + X^4 + X^3 + X + 1
X^16 + X^5 + X^3 + X + 1
X^32 + X^7 + X^3 + X^2 + 1
X^64 + X^4 + X^3 + X + 1
X^128 + X^7 + X^2 + X + 1
X^256 + X^10 + X^5 + X^2 + 1
In the end, these polynomials match the table of low-weight polynomials mentioned above. We notably retrieve the polynomial for used in AES’s “mix columns”, and the polynomial for used in GCM.
Conclusion
We’re now done with the mathematical description of Shamir’s Secret Sharing. The next blog post will discuss how to implement that in Rust.
-
According to section 9.1 of RFC 8446,
secp256r1
is the only elliptic curve mandatory to implement for TLS 1.3, both in terms of ECDSA signatures and ECDH key exchange. ↩
Comments
To react to this blog post please check the Reddit thread and the Twitter thread.
You may also like