Chapter 10: Regev encryption from scratch
Regev encryption hides a message bit inside LWE noise. The public key is an LWE sample . A ciphertext is a random -linear combination of the rows of this sample. The sender adds to the second coordinate to carry the bit . Decryption computes modulo , which cancels the secret and leaves . Rounding the result to the nearer of or recovers whenever the noise stays inside the window the decoder tolerates.
For correctness, the noise budget is the entire story. Security adds two more pieces: the public-key indistinguishability from uniform under decisional LWE, and the leftover hash lemma applied to the joint hash .
A bit hidden in LWE noise
Section titled “A bit hidden in LWE noise”The toy parameters are , matching Chapter 8. Key generation draws a uniform secret and a uniform matrix . It also draws an error vector and sets in . The public key is the LWE sample and the secret key is (Regev, 2009).
Seeding numpy’s default generator at fixes a specific instance. The secret is . The error in symmetric representatives is . Every coordinate of is at most in absolute value.
To encrypt the bit , the sender draws a random . The ciphertext is . The shift is the encoding of the bit . At the same seed the draw is , and the ciphertext is with .
Decryption uses the secret to compute . The result is . In symmetric representatives this is distance from , so it rounds to and decodes to the bit . The noise did not flip the decoded bit because , and the decoder tolerates any up to at these parameters.
The same with the bit gives instead of , because the encoding shift drops from to . The decrypted value is , which in symmetric representatives is . That rounds to and decodes to . The distance from to and the distance from to are both the same quantity .
A numpy block reproduces the walkthrough end to end.
import numpy as np
n, q, m, B = 4, 97, 8, 1rng = np.random.default_rng(seed=0)s = rng.integers(0, q, size=n, dtype=np.int64)A = rng.integers(0, q, size=(m, n), dtype=np.int64)e = rng.integers(-B, B + 1, size=m, dtype=np.int64)b = (A @ s + e) % qr = rng.integers(0, 2, size=m, dtype=np.int64)half_q = q // 2
c1 = (A.T @ r) % qc2_one = (int(b @ r) + half_q * 1) % qc2_zero = (int(b @ r) + half_q * 0) % qv_one = (c2_one - int(c1 @ s)) % qv_zero = (c2_zero - int(c1 @ s)) % q
print("s =", s.tolist())print("r =", r.tolist())print("c1 =", c1.tolist())print("c2 for mu=1:", c2_one, "c2 for mu=0:", c2_zero)print("v for mu=1:", v_one, "v for mu=0:", v_zero)# ==> s = [82, 61, 49, 26]# ==> r = [0, 1, 1, 1, 0, 1, 1, 0]# ==> c1 = [44, 50, 54, 74]# ==> c2 for mu=1: 21 c2 for mu=0: 70# ==> v for mu=1: 45 v for mu=0: 94Every Python block this chapter prints is also a standalone file in the companion repository, under chapter-code/ch10/, one file per block. Appendix C covers the clone and the environment they run on.
Noise budget and symmetric representatives
Section titled “Noise budget and symmetric representatives”Two notational conventions carry through the rest of the chapter. The first is symmetric representatives on : every residue is mapped to the signed integer in the half-open interval that shares its residue class. For odd , this is the integer closest to . For , the values map to themselves, and the values map to . The symmetric representative of is ; the symmetric representative of is .
The second convention is the nearest-codeword decoder for the two encodings and . Given , the decoder asks which of and is closer to on the cycle . The integer expression gives the same answer without any floating-point arithmetic. For the decoder returns on the residues and on the residues . The two regions have different widths because is odd, and the decoder breaks the midpoint tie toward the bit . A ciphertext decodes correctly if and only if the decrypted value lands inside the region that contains its encoded value.
The elementary noise bound is
for and . The worst case is achieved when every coordinate of is and every coordinate of is .
Chapter 8’s note “Which error distribution” sets the toy’s bounded uniform error beside , the rounded Gaussian of width about that Regev’s theorem uses, and says why the two are not interchangeable inside a reduction (Regev, 2009). This chapter adds a difference in correctness. For bounded uniform errors with , the typical scales as by sub-Gaussian concentration; for Regev’s Gaussian-shaped error distribution, the analogous scale is .
The bounded-uniform typical size sits a factor of below the deterministic worst case . The Gaussian error has no deterministic bound at all, which is why Regev’s correctness statement is a probability rather than an inequality. The uniform worst-case bound is what the walkthrough code uses, and it suffices for decryption correctness at the toy parameters.
Key generation, encryption, decryption
Section titled “Key generation, encryption, decryption”Key generation, encryption, and decryption all live inside . They use only matrix-vector multiplication, modular reduction, and the nearest-codeword decoder for the two encodings and . The blocks below pass the parameters around as loose integers so that each one runs on its own. The regev_pke package under solutions/ch10-regev-pke carries them in a RegevParams dataclass instead, with the noise budget exposed as a method on it.
Key generation. KeyGen takes the parameters and returns a public key together with a secret key . The secret is drawn uniformly from . The matrix is drawn uniformly from . The error is drawn uniformly from . The public vector is in . The public key is the LWE sample , and for the same error distribution it is computationally indistinguishable from uniform under decisional LWE from Chapter 8 (Regev, 2009).
import numpy as np
def keygen(n, q, m, B, rng): s = rng.integers(0, q, size=n, dtype=np.int64) A = rng.integers(0, q, size=(m, n), dtype=np.int64) e = rng.integers(-B, B + 1, size=m, dtype=np.int64) b = (A @ s + e) % q return (A, b), s
rng = np.random.default_rng(seed=0)(A, b), s = keygen(4, 97, 8, 1, rng)
# Recompute A @ s with an explicit Python loop and verify that# b - A s lives in the symmetric interval [-1, 1], which is the# noise bound B = 1 set by the parameters.As_loop = [sum(int(A[i, j]) * int(s[j]) for j in range(4)) % 97 for i in range(8)]residual = [(int(b[i]) - As_loop[i]) % 97 for i in range(8)]e_sym = [v - 97 if v > 48 else v for v in residual]print("s =", s.tolist())print("b - A s (symmetric) =", e_sym)# ==> s = [82, 61, 49, 26]# ==> b - A s (symmetric) = [-1, -1, 0, 0, 0, -1, -1, -1]Encryption. Encrypt takes the public key and a message bit and returns a ciphertext . The sender draws a random . The first coordinate is . The second coordinate is . The ciphertext lives in .
import numpy as np
def encrypt(A, b, bit, q, rng): m = b.shape[0] r = rng.integers(0, 2, size=m, dtype=np.int64) half_q = q // 2 c1 = (A.T @ r) % q c2 = (int(b @ r) + half_q * bit) % q return c1, int(c2)
# Replay the seeded keygen so this block is self-contained.rng = np.random.default_rng(seed=0)n, q, m, B = 4, 97, 8, 1s = rng.integers(0, q, size=n, dtype=np.int64)A = rng.integers(0, q, size=(m, n), dtype=np.int64)e = rng.integers(-B, B + 1, size=m, dtype=np.int64)b = (A @ s + e) % q
c1_one, c2_one = encrypt(A, b, 1, q, rng)print("mu = 1: c1 =", c1_one.tolist(), "c2 =", c2_one)# ==> mu = 1: c1 = [44, 50, 54, 74] c2 = 21Decryption. Decrypt takes the secret key and a ciphertext and returns the decoded bit. It computes and returns . The expression is the nearest-codeword decoder for applied to .
import numpy as np
def decrypt(s, c1, c2, q): v = (int(c2) - int(c1 @ s)) % q half_q = q // 2 return ((2 * v + half_q) // q) % 2
# Replay keygen and produce ciphertexts for both message bits with# the same random r, so the only difference is the encoding shift.rng = np.random.default_rng(seed=0)n, q, m, B = 4, 97, 8, 1s = rng.integers(0, q, size=n, dtype=np.int64)A = rng.integers(0, q, size=(m, n), dtype=np.int64)e = rng.integers(-B, B + 1, size=m, dtype=np.int64)b = (A @ s + e) % qr = rng.integers(0, 2, size=m, dtype=np.int64)c1 = (A.T @ r) % qc2_one = (int(b @ r) + (q // 2) * 1) % qc2_zero = (int(b @ r) + (q // 2) * 0) % q
print("decrypt mu = 1 ->", decrypt(s, c1, c2_one, q))print("decrypt mu = 0 ->", decrypt(s, c1, c2_zero, q))# ==> decrypt mu = 1 -> 1# ==> decrypt mu = 0 -> 0The secret-cancellation identity. Start from the definitions and . Substituting and expanding gives
The expansion uses distributivity and the matrix transpose identity. The subtracted term equals by the same identity. Cancelling the two summands leaves
The decryption input is therefore the encoded bit plus a small noise term, viewed modulo . The multiple of vanishes in before the decoder runs.
The noise budget. The decoder returns correctly whenever lies inside the decoding region of the intended codeword. The two regions are separated by on the cycle, and because is odd they are not perfectly symmetric. For , as at and , the bit- region contains residues and the bit- region contains ; for the counts swap. A clean symmetric sufficient condition that works for both message bits and both signs of the noise is
For large the right side is essentially , and the condition is often written in the asymptotic form . For the symmetric form gives . The actual decoder boundary differs by at most one residue depending on the sign of the noise and the message bit. The symmetric form is the worst-case correctness condition used in the rest of this chapter.
The elementary bound gives for and . Combining the two inequalities produces the bounded-uniform correctness budget
which is sufficient for every honest encryption to decrypt correctly. Regev’s original paper states correctness probabilistically through the distribution of sums of errors rather than through a deterministic worst-case bound (Regev, 2009). The deterministic form above is the bounded-uniform analogue used by the toy code in this chapter.
At the toy parameters the left side is and the right side is , so the budget has a factor-of-three margin. At the parameters the left side is still but the right side is , so the budget is violated. The exact distribution of at those parameters predicts a failure rate of about . The code block below shows an empirical rate of across seeds with both message bits encrypted. A sample of seeds is small for a -rate event, so the empirical and analytical rates differ within typical sample-size variance.
import numpy as np
def keygen(n, q, m, B, rng): s = rng.integers(0, q, size=n, dtype=np.int64) A = rng.integers(0, q, size=(m, n), dtype=np.int64) e = rng.integers(-B, B + 1, size=m, dtype=np.int64) return (A, (A @ s + e) % q), s
def encrypt(A, b, bit, q, rng): m = b.shape[0] r = rng.integers(0, 2, size=m, dtype=np.int64) half_q = q // 2 return (A.T @ r) % q, (int(b @ r) + half_q * bit) % q
def decrypt(s, c1, c2, q): v = (int(c2) - int(c1 @ s)) % q half_q = q // 2 return ((2 * v + half_q) // q) % 2
def failure_rate(n, q, m, B, num_seeds): failures = 0 for seed in range(num_seeds): rng = np.random.default_rng(seed=seed) (A, b), s = keygen(n, q, m, B, rng) for bit in (0, 1): c1, c2 = encrypt(A, b, bit, q, rng) if decrypt(s, c1, c2, q) != bit: failures += 1 return failures / (2 * num_seeds)
print("feasible (n=4, q=97, m=8, B=1):", failure_rate(4, 97, 8, 1, 200))print("infeasible (n=4, q=13, m=8, B=1):", failure_rate(4, 13, 8, 1, 200))# ==> feasible (n=4, q=97, m=8, B=1): 0.0# ==> infeasible (n=4, q=13, m=8, B=1): 0.0675The Ring-LWE / LPR-style descendant. Replacing the flat LWE sample with a Ring-LWE sample in keeps the same cancellation idea but changes the shape of the ciphertext. The construction is no longer literally Regev. Instead of a random -subset sum of many flat LWE samples, it works over with short polynomial secrets and adds fresh encryption errors .
The public key is a single polynomial together with in . Encryption draws a random short polynomial and the two fresh short errors . The ciphertext is . The message polynomial encodes up to bits as coefficients in . Decryption computes , and the noise term is now two small polynomial products plus the fresh error . This two-element scheme is the one the journal version of the Ring-LWE paper presents. The conference version’s example cryptosystem is the dual-style scheme with about ring elements per ciphertext (Lyubashevsky et al., 2013, sec. 1). Chapter 11 walks the Module-LWE refinement that ML-KEM uses and the Fujisaki-Okamoto transform that FIPS 203 applies to its component PKE, yielding a KEM that §3.2 says is believed to satisfy IND-CCA2 security (National Institute of Standards and Technology, 2024).
IND-CPA from decisional LWE
Section titled “IND-CPA from decisional LWE”In the IND-CPA game, the adversary receives the public key, submits two equal-length messages and of its choice, receives an encryption of for a uniformly random bit , and must guess . The scheme is IND-CPA secure if every polynomial-time adversary guesses correctly with probability at most , where is the security parameter (Boneh & Shoup, 2023). For Regev PKE the two messages are the bit and the bit , so the game reduces to: given and an encryption of a random bit , guess .
Security reduces to decisional LWE (DLWE) through a hybrid argument over three games, each changing one thing in the adversary’s view. Write for the adversary’s advantage in Hybrid .
| Hybrid | Public key | Challenge ciphertext | Reached by |
|---|---|---|---|
| 0 | the real scheme | ||
| 1 | uniform in | decisional LWE | |
| 2 | uniform in | uniform in | the leftover hash lemma |
Hybrid 0 to Hybrid 1. A distinguisher for the two hybrids gives a DLWE distinguisher. On input with either or uniform in , installs as the public key. It then samples the IND-CPA challenge bit itself, forms the challenge ciphertext using (encryption does not need the secret), gives the ciphertext to the adversary, and outputs iff the adversary’s final guess equals . If the simulation is exactly Hybrid 0; if is uniform it is Hybrid 1. Any non-negligible gap in the adversary’s success probability between the two hybrids therefore gives a DLWE distinguisher with the same advantage, which Chapter 8 assumes is negligible (Regev, 2009).
Hybrid 1 to Hybrid 2. The adversary’s advantage in Hybrid 2 is exactly zero, because a challenge ciphertext drawn uniformly and independently of carries no information about . The adversary’s view in Hybrid 1 is , with uniform in . Apply the leftover hash lemma to the joint hash . For uniform and uniform , the joint output is -close to uniform on when (Boneh & Shoup, 2023; Regev, 2009). The shift by preserves uniformity, so the Hybrid 1 view is -close to the Hybrid 2 view and the advantage gap is at most .
Chaining the Hybrid 0 to Hybrid 1 to Hybrid 2 transitions bounds the real-world advantage: , where is the distinguishing advantage against decisional LWE and is the leftover hash lemma statistical distance. Both are negligible in under the standard Regev parameterization, so the scheme is IND-CPA secure (Boneh & Shoup, 2023; Regev, 2009).
Recovering the encryption randomness. At the randomness takes only values, so an eavesdropper who holds the public key can try them all. It computes for each candidate and looks for among the images. When only one candidate matches, it is the sender’s , and then with no noise left. The bit comes out without the secret, without a decryption oracle and without solving LWE. The block below runs the attack on the 200 seeded keys that the failure-rate block above uses at , with both bits encrypted under each.
import itertoolsimport numpy as np
def keygen(n, q, m, B, rng): s = rng.integers(0, q, size=n, dtype=np.int64) A = rng.integers(0, q, size=(m, n), dtype=np.int64) e = rng.integers(-B, B + 1, size=m, dtype=np.int64) return (A, (A @ s + e) % q), s
def encrypt(A, b, bit, q, rng): m = b.shape[0] r = rng.integers(0, 2, size=m, dtype=np.int64) half_q = q // 2 return (A.T @ r) % q, (int(b @ r) + half_q * bit) % q
# The eavesdropper holds the public key (A, b) and a ciphertext# (c1, c2), never s. It tabulates A^T r for all 2^m choices of r.n, q, m, B = 4, 97, 8, 1cube = np.array(list(itertools.product((0, 1), repeat=m)), dtype=np.int64)one_to_one, recovered, shifts = 0, 0, set()for seed in range(200): rng = np.random.default_rng(seed=seed) (A, b), _ = keygen(n, q, m, B, rng) table = {tuple(((A.T @ r) % q).tolist()): r for r in cube} one_to_one += len(table) == len(cube) for bit in (0, 1): c1, c2 = encrypt(A, b, bit, q, rng) r = table[tuple(c1.tolist())] shift = (c2 - int(b @ r)) % q shifts.add(shift) recovered += int(shift == q // 2) == bit
print("keys where r -> A^T r is one to one:", one_to_one, "of 200")print("values of c2 - b^T r:", sorted(shifts))print("bits recovered without s:", recovered, "of 400")# ==> keys where r -> A^T r is one to one: 200 of 200# ==> values of c2 - b^T r: [0, 48]# ==> bits recovered without s: 400 of 400In each of these keys the map is one to one on , so the match is unique and every bit comes back. That is a fact about these 200 matrices, not about every matrix: a zero row in already gives two choices of the same image. For a fixed and a uniform , another has the same image exactly when . That happens with probability , because has an entry , which makes uniform. A union bound over the other choices puts the chance that one of them shares the sender’s image at most . That is the chance that the match on one ciphertext is not unique.
That attack is the toy’s failure, and its cost of trials is small only because is. The term in the leftover hash lemma’s condition has a separate reason, which is a count. The step from Hybrid 1 to Hybrid 2 needs the joint output to be close to uniform on , and for a fixed that output takes at most values.
The statistical distance between two distributions is the largest difference between the probabilities they give one set of outcomes. The uniform distribution puts probability at least on the points the output never takes, so the distance between the two is at least . That stays at least until exceeds . At the toy it is : eight random bits cannot spread over a space of about bits.
The lemma’s condition, , adds to the term to reach the accuracy . It is sufficient for -closeness, not a threshold whose failure yields an attack. The count shows that the proof’s last step bounds nothing at , and the enumeration is what breaks the toy.
Decrypting the challenge with one query. The proof establishes IND-CPA security only. An IND-CCA2 adversary gets an additional decryption oracle that it can query on any ciphertext except the challenge, and against Regev’s scheme one query is enough to learn the challenge bit. Given the challenge , the adversary asks for the decryption of . That ciphertext differs from the challenge in its second coordinate, so the oracle answers it.
With the challenge’s noise, the decrypted value moves from to . At the toy that is for , and for . An honest ciphertext of the toy has , so the first lands in the bit- region, the second in the bit- region, and the oracle returns .
import numpy as np
def keygen(n, q, m, B, rng): s = rng.integers(0, q, size=n, dtype=np.int64) A = rng.integers(0, q, size=(m, n), dtype=np.int64) e = rng.integers(-B, B + 1, size=m, dtype=np.int64) return (A, (A @ s + e) % q), s
def encrypt(A, b, bit, q, rng): m = b.shape[0] r = rng.integers(0, 2, size=m, dtype=np.int64) half_q = q // 2 return (A.T @ r) % q, (int(b @ r) + half_q * bit) % q
def decode(v, q): half_q = q // 2 return ((2 * (v % q) + half_q) // q) % 2
def decrypt(s, c1, c2, q): return decode(int(c2) - int(c1 @ s), q)
# The challenger holds s and decrypts any ciphertext except the# challenge. The adversary sends one query and flips the answer.n, q, m, B = 4, 97, 8, 1half_q = q // 2wins = 0for seed in range(200): rng = np.random.default_rng(seed=seed) (A, b), s = keygen(n, q, m, B, rng) for bit in (0, 1): c1, c2 = encrypt(A, b, bit, q, rng) answer = decrypt(s, c1, (c2 + half_q) % q, q) wins += (1 - answer) == bitprint("challenge bits found with one query:", wins, "of 400")
# Every honest noise value E = e^T r has |E| <= m B = 8.flips = sum(decode(half_q * bit + E + half_q, q) == 1 - bit for bit in (0, 1) for E in range(-m * B, m * B + 1))stuck = [v for v in range(q) if decode(v + half_q, q) == decode(v, q)]print("pairs (bit, E) where the shift flips the bit:", flips, "of 34")print("decrypted values the shift does not flip:", stuck)# ==> challenge bits found with one query: 400 of 400# ==> pairs (bit, E) where the shift flips the bit: 34 of 34# ==> decrypted values the shift does not flip: [73]The flip holds at all pairs of a bit and an with . It is not an identity on . There the bit- region has residues and the bit- region , so no shift can carry every bit- value into the bit- region. The one left behind is , whose shift also decodes to . The value is not an honest one, since honest decrypted values lie within of or .
The adversary never uses , which the block holds only to play the oracle. Unlike the enumeration, the query does not depend on the small : adding to adds to the decrypted value under every key. So the raw Regev scheme is not IND-CCA2 secure, and malformed ciphertexts can also leak the secret itself. Chapter 13’s decoder-boundary key recovery recovers the whole secret of a small-secret variant of this scheme in decryption queries.
Chapter 11 moves from PKE to KEMs and adds the Fujisaki-Okamoto-style transform used by ML-KEM: decapsulation decrypts the ciphertext, derives the encryption randomness again from the decrypted message, re-encrypts, and compares the recomputed ciphertext to the received one. On mismatch, ML-KEM performs implicit rejection and returns a pseudorandom fallback key derived from a stored secret rather than an explicit failure symbol. The resulting KEM is believed to satisfy IND-CCA2 under the stated module-lattice assumptions and the FO-style random-oracle / quantum-random-oracle modeling assumptions. Chapter 11 walks that construction and states the Hofheinz-Hövelmanns-Kiltz theorem’s assumptions. Their random-oracle reduction for the implicit-rejection transform is the one FIPS 203 cites, and it builds on the original Fujisaki-Okamoto transform (Fujisaki & Okamoto, 1999; Hofheinz et al., 2017, sec. 3; National Institute of Standards and Technology, 2024, sec. 3.2). Chapter 11 also says what their quantum-random-oracle theorems for KEMs cover.
Tradeoffs inside Part II
Section titled “Tradeoffs inside Part II”Regev’s original flat construction uses only matrix-vector arithmetic over , which is why the noise budget derivation and the IND-CPA proof in this chapter both fit in a page (Regev, 2009). The Ring-LWE framing from Chapter 9 improves public-key size and amortized bandwidth by replacing unstructured flat-LWE linear algebra with structured multiplication by ring elements in . A single ring element acts as a negacyclic convolution operator, so an entire structured linear map is carried by one polynomial.
A strict single-bit comparison is closer to field elements (flat Regev for one bit) versus field elements (the two-element Ring-LWE ciphertext) (Lyubashevsky et al., 2013, sec. 1). The structural gain comes from public-key compression, faster polynomial arithmetic, and message packing of up to bits per ciphertext. The cost is a security assumption that restricts to ideal lattices (Lyubashevsky et al., 2010).
Chapter 11 uses Module-LWE, where the secret is a vector of ring elements in . At this is Ring-LWE; at ring dimension this is flat LWE. ML-KEM chooses and to match a target bandwidth and security level (National Institute of Standards and Technology, 2024). Chapter 13 walks primal and dual lattice attacks against the public LWE samples such a scheme’s key material exposes, and estimates their cost under a stated attack and cost model. It reads those estimates against the published category assessments rather than deriving a security level for arbitrary decisional-LWE parameters.
Chapter 11 is the immediate next step: it rebuilds key generation, encryption, and decryption over Module-LWE, then adds the Fujisaki-Okamoto transform that turns an IND-CPA scheme like this one into a KEM believed to satisfy IND-CCA2 (Hofheinz et al., 2017), standardized as ML-KEM (National Institute of Standards and Technology, 2024).
Exercises
Section titled “Exercises”-
Modify
RegevParamsto accept and measure the decryption failure rate across 1000 seeds with both message bits. Compare the observed rate against the analytical failure rate computed from the exact distribution of at those parameters. -
Extend the single-bit scheme to encrypt a -bit plaintext by running independent ciphertexts with the same public key. Report the ciphertext expansion factor in entries per plaintext bit.
-
Re-derive the noise budget for drawn uniformly from instead of . Show that the worst-case bound remains , while the typical size increases by a constant factor because ternary is nonzero with probability rather than . The signs make the sum symmetric but do not by themselves increase the variance when the error distribution is already symmetric.
-
Explain in two sentences why replacing the encoding shift with breaks the existing nearest-codeword decoder for . State the new noise bound that would make a redesigned nearest-codeword decoder for work. Show that it tightens to roughly .
Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 10. A separate track, for rebuilding rather than reading: the package exercises/ch10-regev-pke has every function the chapter teaches replaced by a stub. Run PQC_IMPL=exercises pytest tests/ch10 to grade your version against the suite that proves the reference one.
References
Section titled “References”Last updated: