Fully Homomorphic Encryption from Scratch
03 Jan 2024
Introduction
Fully Homomorphic Encryption (FHE) is a form of encryption that makes it possible to evaluate functions on encrypted inputs without access to the decryption key. For example, suppose you want to use a neural network running on an untrusted server to determine whether your image contains a cat. Since the server is untrusted, you do not want it to see your image. With FHE you can encrypt the image with your secret key and upload the encrypted image to the server. The server can then apply its neural network to the encrypted image and produce an encrypted bit. You can then download the encrypted bit and decrypt it with your secret key to obtain the result.
In other words, FHE makes it possible to leverage the computational resources of an untrusted server without having to send it any unencrypted data.
At first glance it seems like FHE is impossible. Encrypted data should be indistinguishable from random bytes to a person without access to the encryption key. So how could such a person perform a meaningful calculation on the encrypted data?
In this post we will explore a popular FHE encryption scheme called TFHE and implement it from scratch in Python. All of the code in this post together with tests can be found here: https://github.com/lowdanie/tfhe
The standard cryptography exposition disclaimer applies. Namely, that it probably is not a good idea to copy paste code from this post to secure your sensitive data. You can use the official TFHE library instead.
A Fully Homomorphic NAND Gate
Any boolean function can be implemented with a combination of NAND gates. Therefore, in this post we’ll focus on implementing a homomorphic version of the NAND gate which can be evaluated on encrypted inputs.
Specifically, we’ll implement generate_key
, encrypt
and decrypt
functions:
def generate_key() -> EncryptionKey:
pass
def encrypt(plaintext: Plaintext, key: EncryptionKey) -> Ciphertext:
pass
def decrypt(ciphertext: Ciphertext, key: EncryptionKey) -> Plaintext:
pass
and a homomorphic NAND gate:
def homomorphic_nand(
ciphertext_left: Ciphertext,
ciphertext_right: Ciphertext,
) -> Ciphertext:
"""Homomorphically evaluate the NAND function."""
pass
The homomorphic property will allow us to perform the following type of computation:
# Client side: Create an encryption key and encrypt two bits.
client_key = generate_key()
b_0 = True
b_1 = False
ciphertext_0 = encrypt(b_0, client_key)
ciphertext_1 = encrypt(b_1, client_key)
# The client sends the ciphertexts to the server.
# Server side: Compute an encryption of NAND(b_0, b_1) from
# the two ciphertexts:
ciphertext_nand = homomorphic_nand(ciphertext_0, ciphertext_1)
# The server sends ciphertext_nand back to the client.
# Client side: Decrypt ciphertext_nand.
# The result should be equal to NAND(True, False) = True
result = decrypt(ciphertext_nand, client_key)
assert result
Importantly, the server never receives the client_key
and so it is not able to
read the contents of ciphertext_left
or ciphertext_right
. Nevertheless, it
can run the homomorphic_nand
function on the ciphertexts to produce
ciphertext_nand
which encrypts NAND(b_0, b_1)
. Without the client_key
, the
server cannot read the contents of ciphertext_nand
. All it can do is send
ciphertext_nand
back to the client who can use their client_key
to decrypt
it and reveal the result.
Of course, all of this complexity is unnecessary if the client is only interested in evaluating a single NAND gate. The client would be better off just evaluating the NAND function locally not involving a server at all.
Homomorphic encryption becomes more useful when the client wants to evaluate a
complex circuit such as a neural network which is composed of many billions of
NAND gates. In that case the client may not have the compute or the permission
to run the program locally. Instead they can offload the computation to an
untrusted server using the approach in the code snippet above, where the
homomorphic_nand
function is used by the server to evaluate each of the NAND
gates in the complex circuit.
The goal of this post is to implement generate_key
, encrypt
, decrypt
and
homomorphic_nand
.
Learning With Errors
In this section we wil implement generate_key
, encrypt
and decrypt
. This
combination of functions is typically called an Encryption Scheme.
Encryption schemes are usually based on a fundamental mathematical problem that is assumed to be “hard”. For example, RSA is based on the assumption that is is hard to find the prime factors of large integers. Similarly, TFHE is based on a problem called Learning With Errors (LWE). In the next section we will define the LWE problem and use it to construct an encryption scheme.
As we will see, in the LWE encryption scheme it is possible to homomorphically add ciphertexts but not to homomorphically multiply them. This type of encryption scheme is called Partially Homomorphic because not all operations are supported. Despite this limitation, the partially homomorphic operations in the LWE scheme will be useful stepping stones on the path towards fully homomorphic encryption.
In this section we will define the Learning With Errors (LWE) problem and use it to implement our encryption scheme as well as some homomorphic operations.
Notation
Let
Similarly,
The Learning With Errors Problem
Let
Given
random vectors and the dot products , is it possible to efficiently learn the secret vector ?
It is not hard to see that the answer is “yes”. Indeed, we can create an
We can then express
Finally, we can use
Gaussian Elimination to
solve for
We can think of the above problem as Learning Without Errors since we are
given the exact dot products
Given
random vectors and noisy dot products , is it possible to efficiently learn the secret vector ?
Note that we have not yet specified which distribution the errors
We will denote the distribution on
In the next section we will see how to build a partially homomorphic encryption scheme based on the hardness of LWE.
An LWE Based Encryption Scheme
Following the notation of the previous section, our encryption scheme will be
parameterized by a modulus
The valid inputs to an encryption function are known as the message space. The
message space of the LWE scheme is
The encryption keys are random length
To encrypt a message
If we know the secret key
In section Message Encoding we’ll describe a method for
removing the error term
Note that the encryption function is not deterministic since it samples from the
distribution
Let
Here is an implementation of the LWE encryption scheme:
import dataclasses
import numpy as np
from tfhe import utils
@dataclasses.dataclass
class LweConfig:
# Size of the LWE encryption key.
dimension: int
# Standard deviation of the encryption noise.
noise_std: float
@dataclasses.dataclass
class LwePlaintext:
message: np.int32
@dataclasses.dataclass
class LweCiphertext:
config: LweConfig
a: np.ndarray # An int32 array of size config.dimension
b: np.int32
@dataclasses.dataclass
class LweEncryptionKey:
config: LweConfig
key: np.ndarray # An int32 array of size config.dimension
def generate_lwe_key(config: LweConfig) -> LweEncryptionKey:
return LweEncryptionKey(
config=config,
key=np.random.randint(
low=0, high=2, size=(config.dimension,), dtype=np.int32
),
)
def lwe_encrypt(
plaintext: LwePlaintext, key: LweEncryptionKey
) -> LweCiphertext:
a = utils.uniform_sample_int32(size=key.config.dimension)
noise = utils.gaussian_sample_int32(std=key.config.noise_std, size=None)
# b = (a, key) + message + noise
b = np.add(np.dot(a, key.key), plaintext.message, dtype=np.int32)
b = np.add(b, noise, dtype=np.int32)
return LweCiphertext(config=key.config, a=a, b=b)
def lwe_decrypt(
ciphertext: LweCiphertext, key: LweEncryptionKey
) -> LwePlaintext:
return LwePlaintext(
np.subtract(ciphertext.b, np.dot(ciphertext.a, key.key), dtype=np.int32)
)
where the utils
module contains:
from typing import Optional
import numpy as np
INT32_MIN = np.iinfo(np.int32).min
INT32_MAX = np.iinfo(np.int32).max
def uniform_sample_int32(size: int) -> np.ndarray:
return np.random.randint(
low=INT32_MIN,
high=INT32_MAX + 1,
size=size,
dtype=np.int32,
)
def gaussian_sample_int32(std: float, size: Optional[float]) -> np.ndarray:
return np.int32(INT32_MAX * np.random.normal(loc=0.0, scale=std, size=size))
Throughout this post, we will used the following LweConfig
whose parameters
are taken from the popular
Lattice Estimator.
from tfhe import lwe
LWE_CONFIG = lwe.LweConfig(dimension=1024, noise_std=2 ** (-24))
Ciphertext Noise
Let
where
As an example, we’ll use the code above to encrypt the message
Generate LWE Error Samples
# Generate an LWE key.
key = lwe.generate_lwe_key(config.LWE_CONFIG)
# This is the plaintext that we will encrypt.
plaintext = lwe.LwePlaintext(2**29)
# Encrypt the plaintext 1000 times and store the error of each ciphertext.
errors = []
for _ in range(1000):
ciphertext = lwe.lwe_encrypt(plaintext, key)
errors.append(lwe.lwe_decrypt(ciphertext, key).message - plaintext.message)
Here is a histogram of errors
:
This matches our estimated standard deviation of
Message Encoding
In the previous section we saw that when we decrypt a ciphertext with LWE we get
the original message plus a small error. For some applications such as neural
networks a small amount of error may be tolerable. An alternative approach is to
restrict the set of possible messages so that a message
For the purposes of this post we will only need to distinguish between 8
different messages and so all of our messages will be of the form
Here is a depiction of
We will use the following encoding function to encode an integer
Similarly, we will use the following decoding function to convert a 32-bit LWE
plaintext in
where
The thick segment of the circle depicts points whose distance from the message
In the previous section we saw that, using our standard LWE parameters, the
distribution of LWE ciphertext errors has a standard deviation of around
In summary, if we only encrypt messages that are encodings of
Here is an implementation of the encoding and decoding functions:
def encode(i: int) -> np.int32:
"""Encode an integer in [-4, 4) as an int32"""
return np.multiply(i, 1 << 29, dtype=np.int32)
def decode(i: np.int32) -> int:
"""Decode an int32 to an integer in the range [-4, 4) mod 8"""
d = int(np.rint(i / (1 << 29)))
return ((d + 4) % 8) - 4
def lwe_encode(i: int) -> LwePlaintext:
"""Encode an integer in [-4,4) as an LWE plaintext."""
return LwePlaintext(utils.encode(i))
def lwe_decode(plaintext: LwePlaintext) -> int:
"""Decode an LWE plaintext to an integer in [-4,4) mod 8."""
return utils.decode(plaintext.message)
In the following example we’ll use the encoding and decoding functions to encrypt a message and recover the exact message without noise after decryption.
LWE Message Encoding Example
key = lwe.generate_lwe_key(config.LWE_CONFIG)
# Encode the number 2 as an LWE plaintext
plaintext = lwe.lwe_encode(2)
# Encrypt the plaintext
ciphertext = lwe.lwe_encrypt(plaintext, key)
# Decrypt the ciphertext. The result will contain noise.
decrypted = lwe.lwe_decrypt(ciphertext, key)
# Decode the result of the decryption. The decoded decryption
# should be exactly equal to the initial message of 2.
decoded = lwe.lwe_decode(decrypted)
assert decoded == 2
Homomorphic Operations
In the next two sections we will show that the LWE scheme above is homomorphic under addition and multiplication by a plaintext.
More precisely, we will define a homomorphic addition function
In other words,
Similarly, let
In other words,
Importantly, these homomorphic operations operate only on ciphertexts and do not
receive the secret key
We will add these operations to our library by implementing the following methods:
def lwe_add(
ciphertext_left: LweCiphertext, ciphertext_right: LweCiphertext
) -> LweCiphertext:
"""Homomorphically add two LWE ciphertexts.
If ciphertext_left is an encryption of m_left and ciphertext_right is
an encryption of m_right then the output will be an encryption of
m_left + m_right.
"""
pass
def lwe_plaintext_multiply(c: int, ciphertext: LweCiphertext) -> LweCiphertext:
"""Homomorphically multiply an LWE ciphertext with a plaintext integer.
If the ciphertext is an encryption of m then the output will be an
encryption of c * m.
"""
pass
Before showing how this works let’s take a minute to explain why this is both
interesting and useful. Suppose a server hosts a simple linear regression model
with weights
In addition, suppose you have two secret numbers
Homomorphic Operations Example
# Client side: Generate and encryption key and encrypt m_0 and m_1
key = lwe.generate_lwe_key(config.LWE_CONFIG)
ciphertext_0 = lwe.lwe_encrypt(m_0, key)
ciphertext_1 = lwe.lwe_encrypt(m_1, key)
# Send the ciphertexts to the untrusted server.
# Server side: Generate an encryption of
# f(m_0, m_1) = w_0*m_0 + w_1*m_1
ciphertext_result = lwe.lwe_add(
lwe.lwe_plaintext_multiply(w_0, ciphertext_0),
lwe.lwe_plaintext_multiply(w_1, ciphertext_1),
)
# The server sends ciphertext_f back to the client
# Client side: Decrypt ciphertext_result to obtain f(m_0, m_1)
result = lwe.lwe_decrypt(ciphertext_result, key)
Note that this example can easily be extended to general dot products between a
vector of weights
Homomorphic Addition
In this section we’ll implement the homomorphic addition function:
Let
By definition,
How can we obtain
It turns out that all we have to do is add the coefficients of
To prove that
In summary:
This proves that
Later in this post we will also use the analogous homomorphic subtraction
function
Here are concrete implementations of
def lwe_add(
ciphertext_left: LweCiphertext,
ciphertext_right: LweCiphertext) -> LweCiphertext:
"""Homomorphic addition evaluation.
If ciphertext_left is an encryption of m_left and ciphertext_right is
an encryption of m_right then return an encryption of
m_left + m_right.
"""
return LweCiphertext(
ciphertext_left.config,
np.add(ciphertext_left.a, ciphertext_right.a, dtype=np.int32),
np.add(ciphertext_left.b, ciphertext_right.b, dtype=np.int32))
def lwe_subtract(
ciphertext_left: LweCiphertext,
ciphertext_right: LweCiphertext) -> LweCiphertext:
"""Homomorphic subtraction evaluation.
If ciphertext_left is an encryption of m_left and ciphertext_right is
an encryption of m_right then return an encryption of
m_left - m_right.
"""
return LweCiphertext(
ciphertext_left.config,
np.subtract(ciphertext_left.a, ciphertext_right.a, dtype=np.int32),
np.subtract(ciphertext_left.b, ciphertext_right.b, dtype=np.int32))
Here is an example of homomorphic addition:
key = lwe.generate_lwe_key(config.LWE_CONFIG)
# Encode the values 3 and -1 as LWE plaintexts
plaintext_a = lwe.lwe_encode(3)
plaintext_b = lwe.lwe_encode(-1)
# Encrypt both plaintexts
ciphertext_a = lwe.lwe_encrypt(plaintext_a, key)
ciphertext_b = lwe.lwe_encrypt(plaintext_b, key)
# Homomorphically add the ciphertexts.
ciphertext_sum = lwe.lwe_add(ciphertext_a, ciphertext_b)
# Decrypt the ciphertext_sum and then decode it. The result should
# be equal to 3 + (-1) = 2
decrypted = lwe.lwe_decrypt(ciphertext_sum, key)
decoded = lwe.lwe_decode(decrypted)
assert decoded == 2
Homomorphic Multiplication By Plaintext
In this section we’ll implement the homomorphic multiplication by plaintext function:
Let
By definition,
Inspired by the previous section, we can attempt to implement
As before, we can verify that this is correct by decrypting
This shows that the result of decrypting
Here is an implementation of
def lwe_plaintext_multiply(c: int, ciphertext: LweCiphertext) -> LweCiphertext:
"""Homomorphically multiply an LWE ciphertext with a plaintext integer."""
return LweCiphertext(
ciphertext.config,
np.multiply(c, ciphertext.a, dtype=np.int32),
np.multiply(c, ciphertext.b, dtype=np.int32),
)
Noise Analysis
In this section we’ll analyze the effects of homomorphic operations on ciphertext noise.
Let
For example, let’s see what happens when we take an encryption of
Homomorphic Addition Noise Analysis
key = lwe.generate_lwe_key(config.LWE_CONFIG)
plaintext_zero = lwe.LwePlaintext(0)
initial_errors = []
addition_errors = []
for _ in range(1000):
# Encrypt the plaintext and record the ciphertext error.
ciphertext_zero = lwe.lwe_encrypt(plaintext_zero, key)
initial_errors.append(lwe.lwe_decrypt(ciphertext_zero, key).message)
# Homomorphically add the ciphertext to itself 10 times.
ciphertext_sum = ciphertext_zero
for _ in range(10):
ciphertext_sum = lwe.lwe_add(ciphertext_sum, ciphertext_zero)
# Record the error of the ciphertext sum.
addition_errors.append(lwe.lwe_decrypt(ciphertext_sum, key).message)
The graph on the left shows the distribution of initial_errors
which records
the errors in the initial encryptions of addition_errors
which contains the ciphertext errors after 10
homomorphic additions. As expected, the noise on the right is about 10 times
larger.
The maximum error in the graph on the right is around 5000 which is still
significantly smaller than the maximal acceptable error of
A major goal for the rest of this post is to develop a homomorphic operation whose output noise is independent of the input noise. This will make it possible to compose an arbitrary number of homomorphic operations, without running into the noise issue above.
Trivial Encryption
Let
where
The ciphertext
Here is our library implementation:
def lwe_trivial_ciphertext(plaintext: LwePlaintext, config: LweConfig):
"""Generate a trivial encryption of the plaintext."""
return LweCiphertext(
config=config,
a=np.zeros(config.dimension, dtype=np.int32),
b=plaintext.message,
)
As an example of why this is useful, let
In section Homomorphic Addition we implemented the
function
Homomorphic NAND Revisited
In the previous section we introduced the LWE encryption scheme together with some basic homomorphic operations. This will allow us to refine our definition of the homomorphic NAND gate from section A Fully Homomorphic Nand Gate and give an overview of our implementation strategy.
Definition
In this section we’ll introduce an encoding of boolean values as LWE messages and precisely define the homomorphic NAND function.
We’ll use the
Let
In other words, if
Another important requirement for
Here are the functions we’ll use to encode booleans as LWE plaintexts and to
decode plaintexts back to booleans. Note that we’re using the encode
and
decode
functions defined here.
def encode_bool(b: bool) -> np.int32:
"""Encode a bit as an int32."""
return encode(2 * int(b))
def decode_bool(i: np.int32) -> bool:
"""Decode an int32 to a bool."""
return bool(decode(i) / 2)
def lwe_encode_bool(b: bool) -> LwePlaintext:
"""Encode a boolean as an LWE plaintext."""
return LwePlaintext(utils.encode_bool(b))
def lwe_decode_bool(plaintext: LwePlaintext) -> bool:
"""Decode an LWE plaintext to a boolean."""
return utils.decode_bool(plaintext.message)
The signature of our homomorphic NAND function is:
def lwe_nand(
lwe_ciphertext_left: lwe.LweCiphertext,
lwe_ciphertext_right: lwe.LweCiphertext,
bootstrap_key: bootstrap.BootstrapKey,
) -> lwe.LweCiphertext:
"""Homomorphically evaluate the NAND function.
Suppose that lwe_ciphertext_left is an LWE encryption of
lwe_encode_bool(b_left) and lwe_ciphertext_right is an LWE encryption
lwe_encode_bool(b_right). Then the the output is an LWE encryption
lwe_encode_bool(NAND(b_left, b_right)).
"""
pass
As we’ll see later, bootstrap_key
is a
public key that untrusted parties can use to homomorphically evaluate functions.
Here is an example:
# Generate a private LWE encryption key.
lwe_key = lwe.generate_lwe_key(config.LWE_CONFIG)
# Generate a public bootstrapping key.
gsw_key = gsw.convert_lwe_key_to_gsw(lwe_key, config.GSW_CONFIG)
bootstrap_key = bootstrap.generate_bootstrap_key(lwe_key, gsw_key)
# Encode two boolean values as LWE plaintexts.
plaintext_0 = lwe.lwe_encode_bool(False)
plaintext_1 = lwe.lwe_encode_bool(True)
# Encrypt the plaintexts.
ciphertext_0 = lwe.lwe_encrypt(plaintext_0, lwe_key)
ciphertext_1 = lwe.lwe_encrypt(plaintext_1, lwe_key)
# Homomorphically compute the NAND function on the inputs.
ciphertext_nand = nand.lwe_nand(
ciphertext_0, ciphertext_1, bootstrap_key
)
# Decrypt the homomorphic NAND result with the LWE key.
plaintext_nand = lwe.lwe_decrypt(ciphertext_nand, lwe_key)
# Decode the LWE plaintext back to a bool.
boolean_nand = lwe.lwe_decode_bool(plaintext_nand)
# The result should be equal to NAND(False, True) = True
assert boolean_nand == True
Implementation Strategy
The remainder of this post will be dedicated to implementing lwe_nand
. In this
section we’ll discuss our high level strategy.
Recall that our encoding function
We’ll start by expressing NAND in terms of elementary operations on
As before, we will identify
In terms of the diagram above, we start at
Note that
Here’s what happens when we apply
It is evident from the table that
In summary, we’ve expressed the NAND function in terms of two operations on
In section Homomorphic Addition we already
implemented a homomorphic subtraction function
We can then use
Here is the python implementation:
import numpy as np
from tfhe import bootstrap, lwe
def lwe_nand(
lwe_ciphertext_left: lwe.LweCiphertext,
lwe_ciphertext_right: lwe.LweCiphertext,
bootstrap_key: bootstrap.BootstrapKey,
) -> lwe.LweCiphertext:
"""Homomorphically evaluate the NAND function.
Suppose that lwe_ciphertext_left is an LWE encryption of an encoding of the
boolean b_left and lwe_ciphertext_right is an LWE encryption of an encoding
of the boolean b_right. Then the the output is an LWE encryption of an encoding
of NAND(b_left, b_right).
"""
# Compute an LWE encryption of: encode(-3) - b_left - b_right
# First create a trivial encryption of encode(-3)
initial_lwe_ciphertext = lwe.lwe_trivial_ciphertext(
plaintext=lwe.lwe_encode(-3),
config=lwe_ciphertext_left.config,
)
# Homomorphically subtract lwe_ciphertext_left and lwe_ciphertext_right
lwe_ciphertext = lwe.lwe_subtract(
initial_lwe_ciphertext, lwe_ciphertext_left
)
lwe_ciphertext = lwe.lwe_subtract(
test_lwe_ciphertext, lwe_ciphertext_right
)
# Bootstrap lwe_ciphertext to output encode_bool(False) or
# encode_bool(True).
return bootstrap.bootstrap(
lwe_ciphertext, bootstrap_key, scale=utils.encode_bool(True)
)
Bootstrapping
Definition
In the context of TFHE, the
More precisely, let
In other words,
The other key property of
Here is the declaration of the
def bootstrap(
lwe_ciphertext: lwe.LweCiphertext,
bootstrap_key: BootstrapKey,
scale: np.int32,
) -> lwe.LweCiphertext:
"""Bootstrap the LWE ciphertext.
Suppose that lwe_ciphertext is an encryption of the int32 i.
If -2^30 < i <= 2^30 then return an LWE encryption of the scale argument.
Otherwise return an LWE encryption of 0. In both cases the ciphertext noise
will be bounded and independent of the lwe_ciphertext noise.
"""
pass
The bootstrap_key
argument will be explained
later in this post. The scale
parameter is
the non zero output value of the step function. Our definition of
scale = utils.encode(2)
.
Noise Properties
As mentioned in the previous section, a key property of the bootstrap function is that it has bounded output noise. In this section we’ll explore this property in more detail through an example.
Let
Suppose we homomorphically multiply
As we saw in section
Homomorphic Multiplication By Plaintext,
The following program computes the ciphertexts
Bootstrap Noise Distribution
from tfhe import bootstrap, gsw, lwe, utils
lwe_key = lwe.generate_lwe_key(config.LWE_CONFIG)
gsw_key = gsw.convert_lwe_key_to_gsw(lwe_key, config.GSW_CONFIG)
bootstrap_key = bootstrap.generate_bootstrap_key(lwe_key, gsw_key)
plaintext_0 = lwe.lwe_encode(0)
plaintext_3 = lwe.lwe_encode(3)
decryptions = []
bootstrap_decryptions = []
for _ in range(1000):
ciphertext_0 = lwe.lwe_encrypt(plaintext_0, lwe_key)
ciphertext_3 = lwe.lwe_encrypt(plaintext_3, lwe_key)
ciphertext = lwe.lwe_add(
ciphertext_3, lwe.lwe_plaintext_multiply(2**20, ciphertext_0))
bootstrap_ciphertext = bootstrap.bootstrap(
ciphertext, bootstrap_key, scale=utils.encode(2)
)
decryptions.append(lwe.lwe_decrypt(ciphertext, lwe_key).message)
bootstrap_decryptions.append(
lwe.lwe_decrypt(bootstrap_ciphertext, lwe_key).message)
Here is a comparison of the array decryptions
which holds decryptions of bootstrap_decryptions
which holds decryptions of
As expected, the distribution of decryptions of
After bootstrapping, the distribution of decryptions is centered at
Furthermore, the distribution clearly is less noisy after bootstrapping. Indeed,
the standard deviation of
Implementation Strategy
The rest of this post will build towards an implementation of
If you prefer to get a high level picture of the implementation before diving into all the details, then it would make sense to start with the following sections:
Ring LWE
Introduction
Ring LWE (RLWE) is a variation of LWE in which messages are polynomials rather than scalars. As mentioned in the previous section, the RLWE encryption scheme will be an important ingredient in our bootstrapping implementation.
In the next section we’ll define the message space of RLWE more precisely. Then we’ll define the RLWE encryption scheme.
Negacyclic Polynomials
The message space of the RLWE scheme is the ring of polynomials
In this section we will describe this polynomial ring.
First,
Here is an example of an element in
We call
The highest power of
We now turn to the ring
is defined to be the ring of polynomials modulo .
As an important special case,
Note that as monomials reach
Now let’s see an example of multiplication in
Here is some negacyclic polynomial code that will be used later:
import numpy as np
import dataclasses
@dataclasses.dataclass
class Polynomial:
"""A polynomial in the ring Z_q[x] / (x^N + 1)"""
N: int
coeff: np.ndarray # Polynomial coefficients f_0, f_1, ..., f_{N-1}
def polynomial_constant_multiply(c: int, p: Polynomial) -> Polynomial:
return Polynomial(N=p.N, coeff=np.multiply(c, p.coeff, dtype=np.int32))
def polynomial_multiply(p1: Polynomial, p2: Polynomial) -> Polynomial:
"""Multiply two negacyclic polynomials.
Note that this is not an optimal implementation.
See
https://www.jeremykun.com/2022/12/09/negacyclic-polynomial-multiplication/
for a more efficient version which uses FFTs.
"""
N = p1.N
# Multiply and pad the result to have length 2N-1.
# Note that the polymul function expects the coefficients to be from highest
# to lowest degree so we reverse our coefficients before and after using it.
prod = np.polymul(p1.coeff[::-1], p2.coeff[::-1])[::-1]
prod_padded = np.zeros(2 * N - 1, dtype=np.int32)
prod_padded[: len(prod)] = prod
# Use the relation x^N = -1 to obtain a polynomial of degree N-1
result = prod_padded[:N]
result[:-1] -= prod_padded[N:]
return Polynomial(N=N, coeff=result)
def polynomial_add(p1: Polynomial, p2: Polynomial) -> Polynomial:
return Polynomial(N=p1.N, coeff=np.add(p1.coeff, p2.coeff, dtype=np.int32))
def polynomial_subtract(p1: Polynomial, p2: Polynomial) -> Polynomial:
return Polynomial(
N=p1.N, coeff=np.subtract(p1.coeff, p2.coeff, dtype=np.int32)
)
def zero_polynomial(N: int) -> Polynomial:
return Polynomial(N=N, coeff=np.zeros(N, dtype=np.int32))
def build_monomial(c: int, i: int, N: int) -> Polynomial:
"""Build a monomial c*x^i in the ring Z[x]/(x^N + 1)"""
coeff = np.zeros(N, dtype=np.int32)
# Find k such that: 0 <= i + k*N < N
i_mod_N = i % N
k = (i_mod_N - i) // N
# If k is odd then the monomial picks up a negative sign since:
# x^i = (-1)^k * x^(i + k*N) = (-1)^k * x^(i % N)
sign = 1 if k % 2 == 0 else -1
coeff[i_mod_N] = sign * c
return Polynomial(N=N, coeff=coeff)
The RLWE Encryption Scheme
In this section we will define the RLWE Encryption Scheme which is very
similar to the LWE encryption scheme but with messages in
A secret key in RLWE is a polynomial in
To encrypt a message
To decrypt a ciphertext
Similarly to LWE, the encryption function is not deterministic and so each
message has many valid encryptions. The set of valid encryptions of a message
Here is our implementation of the RLWE scheme:
import dataclasses
import numpy as np
from tfhe import lwe, polynomial, utils
from tfhe.polynomial import Polynomial
@dataclasses.dataclass
class RlweConfig:
degree: int # Messages will be in the space Z[X]/(x^degree + 1)
noise_std: float # The std of the noise added during encryption.
@dataclasses.dataclass
class RlweEncryptionKey:
config: RlweConfig
key: Polynomial
@dataclasses.dataclass
class RlwePlaintext:
config: RlweConfig
message: Polynomial
@dataclasses.dataclass
class RlweCiphertext:
config: RlweConfig
a: Polynomial
b: Polynomial
def generate_rlwe_key(config: RlweConfig) -> RlweEncryptionKey:
return RlweEncryptionKey(
config=config,
key=Polynomial(
N=config.degree,
coeff=np.random.randint(
low=0, high=2, size=config.degree, dtype=np.int32
),
),
)
def rlwe_encrypt(
plaintext: RlwePlaintext, key: RlweEncryptionKey
) -> RlweCiphertext:
a = Polynomial(
N=key.config.degree,
coeff=utils.uniform_sample_int32(size=key.config.degree),
)
noise = Polynomial(
N=key.config.degree,
coeff=utils.gaussian_sample_int32(
std=key.config.noise_std, size=key.config.degree
),
)
b = polynomial.polynomial_add(
polynomial.polynomial_multiply(a, key.key), plaintext.message
)
b = polynomial.polynomial_add(b, noise)
return RlweCiphertext(config=key.config, a=a, b=b)
def rlwe_decrypt(
ciphertext: RlweCiphertext, key: RlweEncryptionKey
) -> RlwePlaintext:
message = polynomial.polynomial_subtract(
ciphertext.b, polynomial.polynomial_multiply(ciphertext.a, key.key)
)
return RlwePlaintext(config=key.config, message=message)
We’ll use the following RLWE parameters in this post:
RLWE_CONFIG = rlwe.RlweConfig(degree=1024, noise_std=2 ** (-24))
Message Encoding
As with LWE, after decrypting a ciphertext
The extended
Similarly, the extended
Just like with LWE, we’ll restrict our message space to encodings of
Here is an implementation of the encoding and decoding functions for RLWE messages:
def rlwe_encode(p: Polynomial, config: RlweConfig) -> RlwePlaintext:
"""Encode a polynomial with coefficients in [-4, 4) as an RLWE plaintext."""
encode_coeff = np.array([utils.encode(i) for i in p.coeff])
return RlwePlaintext(
config=config, message=polynomial.Polynomial(N=p.N, coeff=encode_coeff)
)
def rlwe_decode(plaintext: RlwePlaintext) -> Polynomial:
"""Decode an RLWE plaintext to a polynomial with coefficients in [-4, 4) mod 8."""
decode_coeff = np.array([utils.decode(i) for i in plaintext.message.coeff])
return Polynomial(N=plaintext.message.N, coeff=decode_coeff)
Here is an example of using the RLWE encryption scheme with the above encoding:
RLWE Message Encoding Example
key = rlwe.generate_rlwe_key(config.RLWE_CONFIG)
# p(x) = 2x^3
p = polynomial.build_monomial(c=2, i=3, N=config.RLWE_CONFIG.degree)
# Encode the polynomial p(x) as an RLWE plaintext.
plaintext = rlwe.rlwe_encode(p, config)
# Encrypt the plaintext with the key.
ciphertext = rlwe.rlwe_encrypt(plaintext, key)
# Decrypt the ciphertext.
decrypted = rlwe.rlwe_decrypt(ciphertext, key)
# Decode the decrypted message to remove the noise.
decoded = rlwe.rlwe_decode(decrypted)
# The final decoded result should be equal to the original polynomial p.
assert np.all(decoded.coeff == p.coeff)
Homomorphic Addition
In this section we’ll develop the RLWE analog of LWE Homomorphic Addition.
Let
In other words,
Similarly to LWE Homomorphic Addition, we can
implement
The RLWE version of the homomorphic subtraction function
Here is the corresponding code:
def rlwe_add(
ciphertext_left: RlweCiphertext, ciphertext_right: RlweCiphertext
) -> RlweCiphertext:
"""Homomorphically add two RLWE ciphertexts."""
return RlweCiphertext(
ciphertext_left.config,
polynomial.polynomial_add(ciphertext_left.a, ciphertext_right.a),
polynomial.polynomial_add(ciphertext_left.b, ciphertext_right.b),
)
def rlwe_subtract(
ciphertext_left: RlweCiphertext, ciphertext_right: RlweCiphertext
) -> RlweCiphertext:
"""Homomorphically subtract two RLWE ciphertexts."""
return RlweCiphertext(
ciphertext_left.config,
polynomial.polynomial_subtract(ciphertext_left.a, ciphertext_right.a),
polynomial.polynomial_subtract(ciphertext_left.b, ciphertext_right.b),
)
Homomorphic Multiplication By Plaintext
In this section we’ll develop the RLWE analog of LWE Homomorphic Multiplication By Plaintext.
Let
Let
Just like in the LWE case,
Here is an implementation:
def rlwe_plaintext_multiply(
c: RlwePlaintext, ciphertext: RlweCiphertext
) -> RlweCiphertext:
"""Homomorphically multiply an RLWE ciphertext by a plaintext polynomial."""
return RlweCiphertext(
ciphertext.config,
polynomial.polynomial_multiply(c.message, ciphertext.a),
polynomial.polynomial_multiply(c.message, ciphertext.b),
)
And here is an example:
key = rlwe.generate_rlwe_key(config.RLWE_CONFIG)
# c(x) = x, m(x) = 2x^2
c = polynomial.build_monomial(1, 1, N=config.RLWE_CONFIG.degree)
m = polynomial.build_monomial(2, 2, N=config.RLWE_CONFIG.degree)
# Convert c(x) into an RLWE plaintext without encoding. Note that encoding is
# not necessary since c(x) will not be encrypted.
c_plaintext = rlwe.RlwePlaintext(config=config.RLWE_CONFIG, message=c)
# Encode m(x) as an RLWE plaintext.
m_plaintext = rlwe.rlwe_encode(m, config)
# Encrypt m(x)
m_ciphertext = rlwe.rlwe_encrypt(m_plaintext, key)
# Homomorphically multiply the encryption of m(x) with c(x)
cm_ciphertext = rlwe.rlwe_plaintext_multiply(c_plaintext, m_ciphertext)
# Decrypt the product.
cm_decrypted = rlwe.rlwe_decrypt(cm_ciphertext, key)
# Decode the result.
cm_decoded = rlwe.rlwe_decode(cm_decrypted)
# The decoded result should be equal to c(x)*m(x) = 2x^3
assert np.all(cm_decoded.coeff == polynomial.polynomial_multiply(c, m))
Trivial Encryption
In this section we’ll define the RLWE analog of LWE Trivial Encryption
Let
As in the LWE case, it is easy to verify that this is a valid RLWE encryption of
Here is an implementation:
def rlwe_trivial_ciphertext(
f: Polynomial, config: RlweConfig
) -> RlweCiphertext:
"""Generate a trivial encryption of the plaintext."""
if f.N != config.degree:
raise ValueError(
f"The degree of f ({f.N}) does not match the config degree ({config.degree}) "
)
return RlweCiphertext(
config=config,
a=polynomial.zero_polynomial(config.degree),
b=f,
)
LWE To RLWE Keys
Recall from section
An LWE Based Encryption Scheme that LWE
encryption keys are length
where
As we just saw, RLWE encryption keys are degree
where
In this post, we’ll be using the parameters
def convert_lwe_key_to_rlwe(lwe_key: lwe.LweEncryptionKey) -> RlweEncryptionKey:
rlwe_config = RlweConfig(
degree=lwe_key.config.dimension, noise_std=lwe_key.config.noise_std
)
return RlweEncryptionKey(
config=rlwe_config,
key=Polynomial(N=rlwe_config.degree, coeff=lwe_key.key),
)
The ability to convert LWE keys to RLWE keys will come in handy in sections Blind Rotate and Sample Extraction where we’ll implement homomorphic operations that combine both LWE and RLWE ciphertexts.
A Homomorphic Multiplexer
Introduction
The Multiplexer gate has three
inputs: a selector bit
In other words, the multiplexer outputs one of the two input lines depending on the selector bit.
Our implementation of bootstrapping will require a homomorphic multiplexer
Note that, for reasons that will soon become clear, we have not yet specified
the encryption scheme used to encrypt
The goal of this section is to implement
The first step is to note that we can express the standard
Therefore, all we need to do to implement
In the next section we will introduce the GSW encryption scheme and implement homomorphic multiplication between a GSW ciphertext and an RLWE ciphertext:
We’ll use the GSW scheme to encrypt the selector bit, and so we can update the
signature of
We can implement this version of
Here is an implementation of
def cmux(
gsw_ciphertext: GswCiphertext,
rlwe_ciphertext_0: rlwe.RlweCiphertext,
rlwe_ciphertext_1: rlwe.RlweCiphertext,
) -> rlwe.RlweCiphertext:
"""Homomorphically evaluate the multiplexer function.
Suppose that rlwe_ciphertext_0 is an encryption of l_0 and rlwe_ciphertext_1
is an encryption of l_1. If gsw_ciphertext is a GSW encryption of 0, then
the output will be an RLWE encryption of l_0. Otherwise, the output will be
an RLWE encryption of l_1.
"""
return rlwe.rlwe_add(
gsw_multiply(
gsw_ciphertext,
rlwe.rlwe_subtract(rlwe_ciphertext_1, rlwe_ciphertext_0),
),
rlwe_ciphertext_0,
)
Here is an example:
rlwe_config = config.RLWE_CONFIG
gsw_config = config.GSW_CONFIG
# Generate an RLWE key and convert it to a GSW key.
rlwe_key = rlwe.generate_rlwe_key(rlwe_config)
gsw_key = gsw.convert_rlwe_key_to_gsw(rlwe_key, gsw_config)
# The selector bit is b=1
selector = polynomial.build_monomial(c=1, i=0, N=rlwe_config.degree)
# The lines are: l_0(x) = x, l_1(x) = 2x
line_0 = polynomial.build_monomial(c=1, i=1, N=rlwe_config.degree)
line_1 = polynomial.build_monomial(c=2, i=1, N=rlwe_config.degree)
# Create a GSW plaintext from the selector bit.
selector_plaintext = gsw.GswPlaintext(config=gsw_config, message=selector)
# Create RLWE plaintexts by encoding the lines.
line_0_plaintext = rlwe.rlwe_encode(line_0, rlwe_config)
line_1_plaintext = rlwe.rlwe_encode(line_1, rlwe_config)
# Encrypt the selector bit with GSW and the lines with RLWE.
selector_ciphertext = gsw.gsw_encrypt(selector_plaintext, gsw_key)
line_0_ciphertext = rlwe.rlwe_encrypt(line_0_plaintext, rlwe_key)
line_1_ciphertext = rlwe.rlwe_encrypt(line_1_plaintext, rlwe_key)
# Apply the CMux function to homomorphically evaluate Mux(b, l_0, l_1).
# This can be done on an untrusted server since all the inputs are encrypted.
cmux_ciphertext = gsw.cmux(
selector_ciphertext, line_0_ciphertext, line_1_ciphertext)
# Decrypt the cmux result using the RLWE key.
cmux_decrypted = rlwe.rlwe_decrypt(cmux_ciphertext, rlwe_key)
# Decode the decrypted result.
cmux_decoded = rlwe.rlwe_decode(cmux_decrypted)
# Since the selector bit was b=1, the cmux result should be equal to line_1.
assert np.all(cmux_decoded.coeff == line_1.coeff)
The GSW Encryption Scheme
The GSW encryption scheme was proposed in 2013 by Gentry, Sahai and Waters. In this section we’ll define GSW encryption and implement homomorphic multiplication between a GSW ciphertext and an RLWE ciphertext:
In terms of our python implementation, our goal is to implement the following methods:
@dataclasses.dataclass
class GswConfig:
rlwe_config: rlwe.RlweConfig
log_p: int # Homomorphic multiplication will use the base-2^log_p representation.
@dataclasses.dataclass
class GswPlaintext:
config: GswConfig
message: polynomial.Polynomial
@dataclasses.dataclass
class GswCiphertext:
config: GswConfig
rlwe_ciphertexts: Sequence[rlwe.RlweCiphertext]
@dataclasses.dataclass
class GswEncryptionKey:
config: GswConfig
key: polynomial.Polynomial
def convert_rlwe_key_to_gsw(
rlwe_key: rlwe.RlweEncryptionKey, gsw_config: GswConfig
) -> GswEncryptionKey:
pass
def gsw_encrypt(
plaintext: GswPlaintext, key: GswEncryptionKey
) -> GswCiphertext:
pass
def gsw_multiply(
gsw_ciphertext: GswCiphertext, rlwe_ciphertext: rlwe.RlweCiphertext
) -> rlwe.RlweCiphertext:
"""Homomorphically multiply a GSW ciphertext with an RLWE ciphertext.
If gsw_ciphertext is a GSW encryption of f(x) and rlwe_ciphertext is an RLWE
encryption of g(x) then the output is an RLWE encryption of f(x)g(x).
"""
pass
Encryptions Of Zero
An encryption of zero is an RLWE encryption of the zero polynomial.
In this section we’ll see how encryptions of zero can help us make progress towards implementing homomorphic multiplication between ciphertexts.
Let
without revealing the plaintexts
In section
Homomorphic Multiplication By Plaintext we
saw that
Let
Also, let
We claim that
is also a valid RLWE encryption of
Proof [click to expand]
As usual, we’ll prove this by analyzing the RLWE decryption of
Again by linearity:
Similarly:
By definition,
By plugging in equations
This shows that the decryption of
This error will be small under our assumption that
The idea of the GSW scheme is to encrypt a plaintext
Motivated by equation
According to the claim above,
The small coefficient requirement on
Perhaps the simplest way to decrease the magnitude of the coefficients of the
ciphertext
Together we get:
It is not hard to show that with this new definition, the division and
multiplication by
We can therefore obtain an acceptable bound on the noise by choosing
Unfortunately this simple approach does not quite work since division by
The key trick of the GSW scheme is to upgrade from a simple division to
something like a binary representation. I.e, rather than decomposing an integer
We’ll decompose it using multiple powers of
In the next section we’ll describe the details of the base-
The Base-p Representation
Recall that in this post,
Let
For example,
which means that the base-
Another interesting example is
Here we are relying on the fact that
We’ll define
For example,
The remainder of this section will be concerned with implementing the function
First, note that the analog of the base-
00000000000000000000001111101000
To get an unsigned base-
x_0 = 0b11101000 = 232
x_1 = 0b00000011 = 3
x_2 = 0b00000000 = 0
x_3 = 0b00000000 = 0
Indeed:
Note that this is not a valid signed base-
What should we use for the offset
Rearranging this give us:
This means that we should the offset to
Here is an implementation of the signed base-
def base_p_num_powers(log_p: int):
"""Return the size of a base 2^log_p representation of an int32."""
return 32 // log_p
def array_to_base_p(a: np.ndarray, log_p: int) -> Sequence[np.ndarray]:
"""Compute the base 2^log_p representation of each element in a.
a: An array of type int32
log_p: Compute the representation in base 2^log_p
"""
num_powers = base_p_num_powers(log_p)
half_p = np.int32(2 ** (log_p - 1))
offset = half_p * sum(2 ** (i * log_p) for i in range(num_powers))
mask = 2 ** (log_p) - 1
a_offset = (a + offset).astype(np.uint32)
output = []
for i in range(num_powers):
output.append(
(np.right_shift(a_offset, i * log_p) & mask).astype(np.int32)
- half_p
)
return output
def base_p_to_array(a_base_p: Sequence[np.ndarray], log_p) -> np.ndarray:
"""Reconstruct an array of int32s from its base 2^log_p representation."""
return sum(2 ** (i * log_p) * x for i, x in enumerate(a_base_p)).astype(
np.int32
)
We’ll similarly define the base-
and we’ll extend the function
The base-
def polynomial_to_base_p(
f: polynomial.Polynomial, log_p: int
) -> Sequence[polynomial.Polynomial]:
"""Compute the base 2^log_p of the polynomial f."""
return [
polynomial.Polynomial(coeff=v, N=f.N)
for v in array_to_base_p(f.coeff, log_p=log_p)
]
def base_p_to_polynomial(
f_base_p: Sequence[polynomial.Polynomial], log_p: int
) -> polynomial.Polynomial:
"""Recover the polynomial f from its base 2^log_p representation."""
f = polynomial.zero_polynomial(f_base_p[0].N)
for i, level in enumerate(f_base_p):
p_i = 2 ** (i * log_p)
f = polynomial.polynomial_add(
f, polynomial.polynomial_constant_multiply(p_i, level)
)
return f
Note that equation
We’ll define the row vector
Equation
In the next section we will apply the base-
where
where
Note that similarly to the trivial factorization
The advantage of the base-
GSW Encryption and Homomorphic Multiplication
We’ll now use the base-
Let
where
We’ll now implement homomorphic multiplication. Let
We claim that
Let’s do a quick sanity check on the dimensions. Since
Here are the implementations of GSW encryption and homomorphic multiplication:
def convert_rlwe_key_to_gsw(
rlwe_key: rlwe.RlweEncryptionKey, gsw_config: GswConfig
) -> GswEncryptionKey:
return GswEncryptionKey(config=gsw_config, key=rlwe_key.key)
def convert_gws_key_to_rlwe(
gsw_key: GswEncryptionKey,
) -> rlwe.RlweEncryptionKey:
return rlwe.RlweEncryptionKey(
config=gsw_key.config.rlwe_config, key=gsw_key.key
)
def gsw_encrypt(
plaintext: GswPlaintext, key: GswEncryptionKey
) -> GswCiphertext:
gsw_config = key.config
num_powers = base_p_num_powers(log_p=gsw_config.log_p)
# Create 2 RLWE encryptions of 0 for each element of a base-p representation.
rlwe_key = convert_gws_key_to_rlwe(key)
rlwe_plaintext_zero = rlwe.build_zero_rlwe_plaintext(gsw_config.rlwe_config)
rlwe_ciphertexts = [
rlwe.rlwe_encrypt(rlwe_plaintext_zero, rlwe_key)
for _ in range(2 * num_powers)
]
# Add multiples p^i * message to the RLWE ciphertexts.
for i in range(num_powers):
p_i = 2 ** (i * gsw_config.log_p)
scaled_message = polynomial.polynomial_constant_multiply(
p_i, plaintext.message
)
rlwe_ciphertexts[i].a = polynomial.polynomial_add(
rlwe_ciphertexts[i].a, scaled_message
)
b_idx = i + num_powers
rlwe_ciphertexts[b_idx].b = polynomial.polynomial_add(
rlwe_ciphertexts[b_idx].b, scaled_message
)
return GswCiphertext(gsw_config, rlwe_ciphertexts)
def gsw_multiply(
gsw_ciphertext: GswCiphertext, rlwe_ciphertext: rlwe.RlweCiphertext
) -> rlwe.RlweCiphertext:
gsw_config = gsw_ciphertext.config
rlwe_config = rlwe_ciphertext.config
# Concatenate the base-p representations of rlwe_ciphertext.a and rlwe_ciphertext.b
rlwe_base_p = polynomial_to_base_p(
rlwe_ciphertext.a, log_p=gsw_config.log_p
) + polynomial_to_base_p(rlwe_ciphertext.b, log_p=gsw_config.log_p)
# Multiply the row vector rlwe_base_p with the
# len(rlwe_base_p)x2 matrix gsw_ciphertext.rlwe_ciphertexts.
rlwe_ciphertext = rlwe.RlweCiphertext(
config=rlwe_config,
a=polynomial.zero_polynomial(rlwe_config.degree),
b=polynomial.zero_polynomial(rlwe_config.degree),
)
for i, p in enumerate(rlwe_base_p):
rlwe_ciphertext.a = polynomial.polynomial_add(
rlwe_ciphertext.a,
polynomial.polynomial_multiply(
p, gsw_ciphertext.rlwe_ciphertexts[i].a
),
)
rlwe_ciphertext.b = polynomial.polynomial_add(
rlwe_ciphertext.b,
polynomial.polynomial_multiply(
p, gsw_ciphertext.rlwe_ciphertexts[i].b
),
)
return rlwe_ciphertext
This completes our implementation of the homomorphic multiplexer
Blind Rotation
Definition
Consider the polynomial
in the negacyclic polynomial ring of degree
In other words, multiplying by
For this reason, multiplication by a monomial
Note that in the negacyclic polynomial ring
Therefore, we can define a function
Note that due to the negacyclic relation
In this post we’re working with the integers modulo
We can reconcile this difference by rescaling
In other words,
For example, if
It’s easy to see that
Note that in the equation above,
The goal of this section will be to implement blind rotation. Here is the signature of the function that we will implement:
def blind_rotate(
lwe_ciphertext: lwe.LweCiphertext,
rlwe_ciphertext: rlwe.RlweCiphertext,
bootstrap_key: BootstrapKey,
) -> rlwe.RlweCiphertext:
"""Homomorphically evaluate the function: Rotate(i, f(x)) = x^((2N/q)*i)) * f(x).
Suppose lwe_ciphertext is an encryption of i and rlwe_ciphertext is an
encryption of f(x). Then the output will be an encryption of
x^((2N/q)*i) * f(x).
"""
pass
The bootstrapping_key
argument will be explained in the following section.
Implementation
Let
To facilitate notation, in this section we’ll define:
Our goal is to evaluate
First, we’ll define
Recall that the LWE decryption operation is defined by:
Since
This equality is not exact, but we’ll absorb the difference into the error
Therefore:
Since each
for
Therefore, it is easy to prove by induction that:
By equation
Our strategy for producing an RLWE encryption of
The first modification is to replace
The second modification is to replace the multiplexer
for
We claim that
Proof [click to expand]
The claim follows by induction on
For the
Suppose that
In particular, from the
Note that our implementation of
Here is a concrete implementation of our
@dataclasses.dataclass
class BootstrapKey:
config: gsw.GswConfig
gsw_ciphertexts: Sequence[gsw.GswCiphertext]
def generate_bootstrap_key(
lwe_key: lwe.LweEncryptionKey, gsw_key: gsw.GswEncryptionKey
) -> BootstrapKey:
bootstrap_key = BootstrapKey(config=gsw_key.config, gsw_ciphertexts=[])
for b in lwe_key.key:
b_plaintext = rlwe.build_monomial_rlwe_plaintext(
b, 0, gsw_key.config.rlwe_config
)
bootstrap_key.gsw_ciphertexts.append(
gsw.gsw_encrypt(b_plaintext, gsw_key)
)
return bootstrap_key
def blind_rotate(
lwe_ciphertext: lwe.LweCiphertext,
rlwe_ciphertext: rlwe.RlweCiphertext,
bootstrap_key: BootstrapKey,
) -> rlwe.RlweCiphertext:
"""Homomorphically evaluate the function: Rotate(i, f(x)) = x^i * f(x).
Suppose lwe_ciphertext is an encryption of i and rlwe_ciphertext is an
encryption of a polynomial f(x). Then the output will be an encryption
of x^i * f(x).
"""
N = rlwe_ciphertext.config.degree
# Scale the lwe_ciphertext by N / 2^31 so that the message is between -N and N
scaled_lwe_a = np.int32(np.rint(lwe_ciphertext.a * (N * 2 ** (-31))))
scaled_lwe_b = np.int32(np.rint(lwe_ciphertext.b * (N * 2 ** (-31))))
# Initialize the rotation by X^b
rotated_rlwe_ciphertext = rlwe.rlwe_plaintext_multiply(
rlwe.build_monomial_rlwe_plaintext(
1, scaled_lwe_b, rlwe_ciphertext.config
),
rlwe_ciphertext,
)
# Rotate by X^-a_i if s_i = 1
for i, a_i in enumerate(scaled_lwe_a):
rotated_rlwe_ciphertext = gsw.cmux(
bootstrap_key.gsw_ciphertexts[i],
rotated_rlwe_ciphertext,
rlwe.rlwe_plaintext_multiply(
rlwe.build_monomial_rlwe_plaintext(
1, -a_i, rlwe_ciphertext.config
),
rotated_rlwe_ciphertext,
),
)
return rotated_rlwe_ciphertext
Here is an example:
# Generate random encryption keys.
lwe_key = lwe.generate_lwe_key(config.LWE_CONFIG)
rlwe_key = rlwe.generate_rlwe_key(config.RLWE_CONFIG)
gsw_key = gsw.convert_rlwe_key_to_gsw(rlwe_key, config.GSW_CONFIG)
bootstrap_key = bootstrap.generate_bootstrap_key(lwe_key, gsw_key)
# f(x) = -1 -x ... -x^(N/2-1) + x^(N/2) + ... + x^(N-1)
N = config.RLWE_CONFIG.degree
f = polynomial.Polynomial(N=N, coeff=np.ones(N, dtype=np.int32))
f.coeff[: N // 2] = -1
# Encode f(x) as an RLWE plaintext and encrypt it with the key.
f_plaintext = rlwe.rlwe_encode(f, config.RLWE_CONFIG)
f_ciphertext = rlwe.rlwe_encrypt(f_plaintext, rlwe_key)
# i = 3/8 * q. This means that we will rotate by (2N/q)*i = 3/4 * N.
# Encode i as an LWE plaintext and encrypt it with the key.
index_plaintext = lwe.lwe_encode(3)
index_ciphertext = lwe.lwe_encrypt(index_plaintext, lwe_key)
# Use blind_rotate to rotate f(x) by i, using only the ciphertexts
# index_ciphertext and f_ciphertext.
rotated_ciphertext = bootstrap.blind_rotate(
index_ciphertext, f_ciphertext, bootstrap_key
)
# Use the encryption key to decrypt rotated_ciphertext, and then decode it.
rotated_f = rlwe.rlwe_decode(
rlwe.rlwe_decrypt(rotated_ciphertext, rlwe_key)
)
# The rotated result should be approximately:
# x^(3/4 * N)f(x) = 1 + x ... + x^(N/4-1) - x^(N/4) - ... - x^(N-1)
assert rotated_f.coeff[0] == 1
assert rotated_f.coeff[N // 2] == -1
assert rotated_f.coeff[-1] == -1
Noise Analysis
Let
For example, suppose that
Let
What does
where the
To see why
Multiplying the ciphertext
This property of
Sample Extraction
Definition
The coefficient function
and outputs
Sample Extraction is the homomorphic version of the coefficient function. The
As before, in the definition above
The goal of this section is to implement sample extraction. Here is the definition of the function in our library:
def extract_sample(
i: int, rlwe_ciphertext: rlwe.RlweCiphertext
) -> lwe.LweCiphertext:
"""Homomorphically extract the i-th coefficient from the RLWE ciphertext.
If rlwe_ciphertext is an RLWE encryption of
f(x) = f_0 + f_1*x + ... + f_{N-1}x^{N-1}
then the output will be an LWE encryption of f_i.
"""
pass
Implementation
Let
Let
By the definition of RLWE encryption:
for some small error
Let
Note that if we define the vector
Then we can rewrite equation
Plugging this into equation
But note that the right hand side of the last equation is exactly the LWE
decryption of the ciphertext
In summary, we can implement
Here is a concrete implementation:
def extract_sample(
i: int, rlwe_ciphertext: rlwe.RlweCiphertext
) -> lwe.LweCiphertext:
"""Homomorphically extract the i-th coefficient from the RLWE ciphertext.
If rlwe_ciphertext is an RLWE encryption of
f(x) = c_0 + c_1*x + ... + c_{N-1}x^{N-1}
then the output will be an LWE encryption of c_i.
"""
lwe_config = lwe.LweConfig(
dimension=rlwe_ciphertext.config.degree,
noise_std=rlwe_ciphertext.config.noise_std,
)
a = np.hstack(
[
rlwe_ciphertext.a.coeff[: i + 1][::-1],
-1 * rlwe_ciphertext.a.coeff[i + 1 :][::-1],
]
)
b = rlwe_ciphertext.b.coeff[i]
return lwe.LweCiphertext(lwe_config, a, b)
Here is an example:
lwe_key = lwe.generate_lwe_key(config.LWE_CONFIG)
rlwe_key = rlwe.convert_lwe_key_to_rlwe(lwe_key)
N = lwe_key.config.dimension
# f(x) = 2x
f = polynomial.build_monomial(2, 1, N)
# Encode f(x) as an RLWE plaintext and encrypt it.
f_plaintext = rlwe.rlwe_encode(f, config.RLWE_CONFIG)
f_ciphertext = rlwe.rlwe_encrypt(f_plaintext, rlwe_key)
# Homomorphically extract an LWE encryption of the coefficient f_1
sample_ciphertext = bootstrap.extract_sample(1, f_ciphertext)
# Decrypt and decode sample_ciphertext. The result should be equal to f_1=2.
coeff_decoded = lwe.lwe_decode(lwe.lwe_decrypt(sample_ciphertext, lwe_key))
assert coeff_decoded == 2
Noise Analysis
As before, let
We’ve seen that for an integer
The claim follows immediately from equation
Therefore, the ciphertext
Bootstrapping Implementation
We now have all the pieces required to implement the bootstrapping function.
Recall from section Bootstrap that
Then we will leverage the homomorphic versions of these functions,
Building The Step Function From Polynomial Rotations
Consider the polynomial
We will refer to
All of the coefficients get shifted one place to the right, and the last
coefficient,
By iterating this process, we can see that if we rotate
What happens if we rotate one more time? Since the last coefficient of
Now the ordering between the positive and negative coefficient has flipped.
The above analysis shows that the zeroth coefficient of
In fact, it is not hard to generalize this to the case where
In section Blind Rotation we defined
Plugging this into equation
Note that this equation looks very similar to the definition of the step function from section Implementation Strategy:
Indeed, we can obtain the step function by translating and scaling
This realizes our objective of expressing
A Homomorphic Step Function
In section Bootstrapping we introduced the bootstrapping
function
In equation
In section Blind Rotation we implemented a homomorphic
version of
More precisely, let
We can compute
Here is a concrete implementation:
def _build_test_polynomial(N: int) -> polynomial.Polynomial:
p = polynomial.Polynomial(N=N, coeff=np.ones(N, dtype=np.int32))
p.coeff[: N // 2] = -1
return p
def bootstrap(
lwe_ciphertext: lwe.LweCiphertext,
bootstrap_key: BootstrapKey,
scale: np.int32,
) -> lwe.LweCiphertext:
"""Bootstrap the LWE ciphertext.
Suppose that lwe_ciphertext is an encryption of the int32 i.
If -2^30 < i <= 2^30 then return an LWE encryption of the scale argument.
Otherwise return an LWE encryption of 0. In both cases the ciphertext noise
will be bounded and independent of the lwe_ciphertext noise.
"""
N = bootstrap_key.config.rlwe_config.degree
test_polynomial = polynomial.polynomial_constant_multiply(
scale // 2, _build_test_polynomial(N)
)
test_rlwe_ciphertext = rlwe.rlwe_trivial_ciphertext(
test_polynomial, bootstrap_key.config.rlwe_config
)
rotated_rlwe_ciphertext = blind_rotate(
lwe_ciphertext, test_rlwe_ciphertext, bootstrap_key
)
sample_lwe_ciphertext = extract_sample(0, rotated_rlwe_ciphertext)
offset_lwe_ciphertext = lwe.lwe_trivial_ciphertext(
plaintext=lwe.LwePlaintext(scale // 2),
config=sample_lwe_ciphertext.config,
)
return lwe.lwe_add(offset_lwe_ciphertext, sample_lwe_ciphertext)
Note that this implementation of bootstrap
returns either an encryption of 0
or an encryption of the scale
parameter. In the presentation above we used
scale=utils.Encode(2)
.
Noise Analysis
Recall from section Bootstrapping that a key
property of
This bound follows immediately from our previous noise analyses of the two
bootstrapping building blocks:
The desired bound on the noise of
Performance
The
official TFHE implementation
reports that one bootstrapping operation takes around 20ms on a single core of
an intel i7 processor. In comparison, our bootstrap
function runs in about 20
seconds on a single core of an intel i5 processor. There are two factors which
contribute to this 1000x discrepancy.
First of all, computationally intensive python programs are typically around 100x slower than their C++ counterparts. This is especially true for our implementation in which we made no attempt to cache frequently computed values or optimize loops with numpy vectorization.
Second of all, the blind_rotate
function is essentially a loop of 1024 cmux
operations, each of which involves around 10 polynomial_multiply
calls. For
simplicity, our implementation of polynomial_multiply
uses
the numpy
polymul
method. That numpy method implements naive coefficient-by-coefficient polynomial
multiplication whose runtime complexity is
Since even heavily optimized implementations take over a millisecond to perform a single homomorphic NAND gate, use of FHE to secure real-world computations will likely require additional algorithmic advances.
Comments
The comments are powered by the utterences Github app. If you do not want the app to post on your behalf, you can comment directly on this Github issue.