Skip to content

Chapter 19: Coding theory for cryptographers

A code adds redundancy to a message so that small errors can be detected and corrected. Hamming invented his code in 1950 because the Bell Labs card reader kept rejecting his programs over single-bit errors (Hamming, 1950). The idea is old and well understood. Given a message of kk bits, produce a codeword of n>kn > k bits. Design the redundancy so that a receiver can recover the original message even when a few bits flip in transit.

The cryptographic twist is this: what if the code’s structure is a secret? If you know the structure, decoding is efficient. If you do not, the best known algorithms for decoding a random linear code take exponential time in the code length (Prange, 1962). The worst-case decision problem is NP-complete (Berlekamp et al., 1978). Those are two different statements, and the section on syndrome decoding below keeps them apart. That gap between “easy with the key” and “hard without it” is the security assumption behind the code-based systems in Part IV. McEliece proposed the first code-based public-key cryptosystem in 1978 (McEliece, 1978), the same year as RSA. The binary-Goppa-code line has resisted practical cryptanalysis at suitable parameters ever since, though many structured and reduced-parameter variants have fallen.

Notation. Throughout the coding chapters of Part IV, Chapters 19 to 21, nn denotes the code length (the number of bits in a codeword), kk denotes the dimension (the number of message bits), and dd denotes the minimum Hamming distance. An [n,k,d][n, k, d] code encodes kk-bit messages into nn-bit codewords with minimum distance dd. This notation is standard in coding theory (MacWilliams & Sloane, 1977). It does not conflict with Part III’s use of nn for hash output length, which addressed a different cryptographic family, and Chapter 24 resets nn once more for the multivariate systems, where it counts variables rather than codeword bits.

The smallest interesting example is the [7,4,3][7,4,3] Hamming code. It takes a 4-bit message, appends 3 parity bits, and produces a 7-bit codeword. The redundancy lets a receiver correct any single-bit error.

Start with the parity-check matrix HH, a 3×73 \times 7 binary matrix in systematic form H=[AI3]H = [A \mid I_3]. The seven columns of HH are the seven nonzero vectors of GF(2)3\mathrm{GF}(2)^3. Every possible nonzero syndrome points to a unique column:

H=(110110010110100111001).H = \begin{pmatrix} 1 & 1 & 0 & 1 & 1 & 0 & 0 \\ 1 & 0 & 1 & 1 & 0 & 1 & 0 \\ 0 & 1 & 1 & 1 & 0 & 0 & 1 \end{pmatrix}.

The generator matrix GG is a 4×74 \times 7 matrix in systematic form G=[I4AT]G = [I_4 \mid A^T], where AA is the left 3×43 \times 4 block of HH:

G=(1000110010010100100110001111).G = \begin{pmatrix} 1 & 0 & 0 & 0 & 1 & 1 & 0 \\ 0 & 1 & 0 & 0 & 1 & 0 & 1 \\ 0 & 0 & 1 & 0 & 0 & 1 & 1 \\ 0 & 0 & 0 & 1 & 1 & 1 & 1 \end{pmatrix}.

The defining relationship is GHT=0G \cdot H^T = 0 over GF(2)\mathrm{GF}(2). This says that every row of GG (and therefore every codeword, since codewords are linear combinations of rows of GG) is in the null space of HH.

Encode the message m=(1,0,1,1)m = (1, 0, 1, 1) by computing c=mGc = m \cdot G:

# Generator matrix G for the [7,4,3] Hamming code (systematic form).
G = [
[1, 0, 0, 0, 1, 1, 0],
[0, 1, 0, 0, 1, 0, 1],
[0, 0, 1, 0, 0, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
]
def gf2_encode(message, G):
n = len(G[0])
codeword = [0] * n
for i, bit in enumerate(message):
if bit:
codeword = [(c ^ g) for c, g in zip(codeword, G[i])]
return codeword
m = [1, 0, 1, 1]
c = gf2_encode(m, G)
print("codeword:", c)
# ==> codeword: [1, 0, 1, 1, 0, 1, 0]

Every Python block this chapter prints is also a standalone file in the companion repository, under chapter-code/ch19/, one file per block. Appendix C covers the clone and the environment they run on.

The first four bits of the codeword are the message itself (because GG is in systematic form), and the last three bits are the parity checks.

Now introduce a single-bit error at position 2 (0-indexed). The received word is r=c+er = c + e where ee has a single 1 at position 2. Compute the syndrome s=HrTs = H \cdot r^T:

H = [
[1, 1, 0, 1, 1, 0, 0],
[1, 0, 1, 1, 0, 1, 0],
[0, 1, 1, 1, 0, 0, 1],
]
def gf2_mat_vec(A, v):
return [sum(a * x for a, x in zip(row, v)) % 2 for row in A]
c = [1, 0, 1, 1, 0, 1, 0]
r = list(c)
r[2] ^= 1 # flip bit 2
s = gf2_mat_vec(H, r)
print("received:", r)
print("syndrome:", s)
# ==> received: [1, 0, 0, 1, 0, 1, 0]
# ==> syndrome: [0, 1, 1]

The syndrome (0,1,1)(0, 1, 1) is column 2 of HH. That is not a coincidence. Since HcT=0H \cdot c^T = 0 for any valid codeword, the syndrome HrT=H(c+e)T=HeTH \cdot r^T = H \cdot (c + e)^T = H \cdot e^T depends only on the error pattern, not on which codeword was sent. When ee has a single 1 at position jj, the syndrome equals column jj of HH. The receiver looks up the syndrome in a table, flips the indicated bit, and recovers the original codeword.

This works because the seven columns of HH are all distinct. Each single-bit error produces a unique syndrome, and the zero vector (the all-zero syndrome) corresponds to no error. The code has 23=82^3 = 8 possible syndromes, accounting for 7 single-bit error patterns plus the no-error case. The 24=162^4 = 16 codewords and their 8-element radius-1 correction balls partition GF(2)7\mathrm{GF}(2)^7 perfectly: 16×8=128=2716 \times 8 = 128 = 2^7. (Each ball holds the codeword itself plus its 7 single-bit neighbors: (70)+(71)=1+7=8\binom{7}{0} + \binom{7}{1} = 1 + 7 = 8.) A code that achieves this exact packing with no wasted syndromes is called a perfect code (MacWilliams & Sloane, 1977). (A sphere-packing picture of GF(2)7\mathrm{GF}(2)^7 would require seven dimensions; the arithmetic carries the intuition instead.)

Linear code. A binary linear code CC of length nn and dimension kk is a kk-dimensional subspace of GF(2)n\mathrm{GF}(2)^n. It contains 2k2^k codewords. Being a subspace means that the sum of any two codewords is a codeword, and the zero vector is always a codeword (MacWilliams & Sloane, 1977).

Generator matrix. A k×nk \times n matrix GG whose rows form a basis for CC. Every codeword is a unique linear combination of the rows: c=mGc = m \cdot G for some mGF(2)km \in \mathrm{GF}(2)^k. A generator matrix in systematic form G=[IkP]G = [I_k \mid P] embeds the message in the first kk positions and appends nkn - k parity bits (MacWilliams & Sloane, 1977).

Parity-check matrix. An (nk)×n(n - k) \times n matrix HH with the property C=ker(H)={vGF(2)n:HvT=0}C = \ker(H) = \{v \in \mathrm{GF}(2)^n : H \cdot v^T = 0\}. In systematic form H=[AInk]H = [A \mid I_{n-k}], where A=PTA = P^T. Since AT=(PT)T=PA^T = (P^T)^T = P, the product is GHT=IkAT+PInk=P+P=0G \cdot H^T = I_k \cdot A^T + P \cdot I_{n-k} = P + P = 0 over GF(2)\mathrm{GF}(2), because x+x=0x + x = 0 in characteristic 2. The relationship GHT=0G \cdot H^T = 0 is the defining constraint: every row of GG satisfies all parity checks (MacWilliams & Sloane, 1977).

Syndrome. For a received word r=c+er = c + e (where cc is the transmitted codeword and ee is the error pattern), the syndrome is s=HrT=HeTs = H \cdot r^T = H \cdot e^T. The syndrome depends only on the error, not on which codeword was sent. Two received words with the same syndrome differ by a codeword.

Minimum distance. The minimum distance of a linear code is d(C)=min{wt(c):cC,c0}d(C) = \min\{\mathrm{wt}(c) : c \in C,\, c \neq 0\}, where wt(c)\mathrm{wt}(c) is the Hamming weight (number of nonzero coordinates). For a linear code, the minimum distance equals the minimum weight of a nonzero codeword, because dist(c1,c2)=wt(c1c2)\mathrm{dist}(c_1, c_2) = \mathrm{wt}(c_1 - c_2) and c1c2c_1 - c_2 is itself a codeword (MacWilliams & Sloane, 1977).

Error-correcting capability. An [n,k,d][n, k, d] code can correct up to t=(d1)/2t = \lfloor(d-1)/2\rfloor errors. The decoder finds the codeword nearest to the received word. If at most tt bits were flipped, the nearest codeword is the one that was sent.

Hamming bound. The number of syndromes is 2nk2^{n-k}, and each must account for a distinct error pattern of weight at most tt. The number of such patterns is i=0t(ni)\sum_{i=0}^{t} \binom{n}{i}. For the code to correct all weight-tt errors, the syndromes must be sufficient:

i=0t(ni)2nk.\sum_{i=0}^{t} \binom{n}{i} \leq 2^{n-k}.

Equality defines a perfect code. The [7,4,3][7,4,3] Hamming code is perfect: (70)+(71)=1+7=8=274\binom{7}{0} + \binom{7}{1} = 1 + 7 = 8 = 2^{7-4} (MacWilliams & Sloane, 1977).

Syndrome decoding problem (SDP). Chapter 3 introduced this problem in its bounded-weight form and cited the NP-completeness result. Here it is stated precisely. The decision version: given an (nk)×n(n-k) \times n binary matrix HH, a target syndrome sGF(2)nks \in \mathrm{GF}(2)^{n-k}, and a weight bound ww, decide whether some eGF(2)ne \in \mathrm{GF}(2)^n has wt(e)w\mathrm{wt}(e) \leq w and HeT=sH \cdot e^T = s. Berlekamp, McEliece, and van Tilborg proved this decision problem NP-complete in 1978 (Berlekamp et al., 1978). The associated search problem, finding such an ee when it exists, is NP-hard. Code-based cryptography rests on the stronger practical assumption that random-looking instances at the chosen parameters are hard on average. No polynomial-time algorithm is known for decoding a random [n,k][n, k] code when the error weight is close to tt.

The generator and parity-check matrices for the Hamming code, with the check GHT=0G \cdot H^T = 0 that confirms the two describe the same code. The syndrome table below is the decoder’s half, and the full encode, corrupt and decode cycle runs in the coding_theory package of solutions/ch19-coding-theory:

# GF(2) matrix product A * B^T where B_rows holds the rows of B.
# Each row of B is a column of B^T, so the (i,j) entry of the product
# is the dot product of A's row i with B's row j, reduced mod 2.
def gf2_mul_by_transpose(A, B_rows):
return [[sum(a * b for a, b in zip(row_a, row_b)) % 2
for row_b in B_rows]
for row_a in A]
G = [
[1, 0, 0, 0, 1, 1, 0],
[0, 1, 0, 0, 1, 0, 1],
[0, 0, 1, 0, 0, 1, 1],
[0, 0, 0, 1, 1, 1, 1],
]
H = [
[1, 1, 0, 1, 1, 0, 0],
[1, 0, 1, 1, 0, 1, 0],
[0, 1, 1, 1, 0, 0, 1],
]
# Verify the defining relationship: G * H^T = 0.
# Pass H directly: each row of H is a column of H^T.
product = gf2_mul_by_transpose(G, H)
print("G * H^T =", product)
# ==> G * H^T = [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]

Build the full syndrome-to-error lookup table for the [7,4,3][7,4,3] Hamming code. The table maps each of the 7 nonzero syndromes to the single-bit error it corrects:

H = [
[1, 1, 0, 1, 1, 0, 0],
[1, 0, 1, 1, 0, 1, 0],
[0, 1, 1, 1, 0, 0, 1],
]
# Build syndrome table: syndrome -> error position.
table = {}
for j in range(7):
syn = tuple(H[r][j] for r in range(3))
table[syn] = j
print("syndrome table:")
for syn, pos in sorted(table.items()):
print(f" {syn} -> position {pos}")
# ==> syndrome table:
# ==> (0, 0, 1) -> position 6
# ==> (0, 1, 0) -> position 5
# ==> (0, 1, 1) -> position 2
# ==> (1, 0, 0) -> position 4
# ==> (1, 0, 1) -> position 1
# ==> (1, 1, 0) -> position 0
# ==> (1, 1, 1) -> position 3

A weight-2 error exceeds the code’s correction capability (t=1t = 1) and produces a syndrome that maps to the wrong position:

H = [
[1, 1, 0, 1, 1, 0, 0],
[1, 0, 1, 1, 0, 1, 0],
[0, 1, 1, 1, 0, 0, 1],
]
def gf2_mat_vec(A, v):
return [sum(a * x for a, x in zip(row, v)) % 2 for row in A]
# Encode the zero message, flip bits 0 and 1 (weight-2 error).
c = [0, 0, 0, 0, 0, 0, 0]
e = [1, 1, 0, 0, 0, 0, 0]
r = [(ci ^ ei) for ci, ei in zip(c, e)]
s = gf2_mat_vec(H, r)
print("syndrome of weight-2 error:", s)
# ==> syndrome of weight-2 error: [0, 1, 1]
# Syndrome (0,1,1) maps to position 2, not positions 0 and 1.
# The decoder "corrects" to the wrong codeword.
print("decoder thinks error is at position 2")
# ==> decoder thinks error is at position 2

The syndrome (0,1,1)(0,1,1) is the XOR of columns 0 and 1 of HH, which happens to equal column 2. The decoder cannot distinguish a weight-2 error at positions 0 and 1 from a weight-1 error at position 2. This is expected: the code corrects at most t=(31)/2=1t = \lfloor(3-1)/2\rfloor = 1 error.

Given a parity-check matrix HH and a syndrome ss, the syndrome decoding problem asks for a low-weight error vector ee with HeT=sH \cdot e^T = s. Prange’s algorithm (Prange, 1962) is the simplest attack:

  1. Pick kk random column indices as the “information set” II.
  2. Let JJ be the remaining nkn - k indices.
  3. Extract the (nk)×(nk)(n-k) \times (n-k) submatrix HJH_J from the columns in JJ.
  4. If HJH_J is invertible over GF(2)\mathrm{GF}(2), solve HJeJ=sH_J \cdot e_J = s.
  5. If wt(eJ)=w\mathrm{wt}(e_J) = w (the target error weight), output ee with zeros at positions in II and eJe_J at positions in JJ.

The algorithm succeeds when the randomly chosen information set avoids all ww error positions. The probability of this per iteration is (nwk)/(nk)\binom{n-w}{k} / \binom{n}{k}, so the expected number of iterations is:

Prange cost=(nk)(nwk).\text{Prange cost} = \frac{\binom{n}{k}}{\binom{n-w}{k}}.

For the [7,4,3][7,4,3] Hamming code with w=1w = 1, this gives (74)/(64)=35/152.3\binom{7}{4}/\binom{6}{4} = 35/15 \approx 2.3 iterations. The formula conditions on the sampled set JJ yielding an invertible HJH_J, which holds with constant probability over GF(2)\mathrm{GF}(2). That constant shifts exact small-case counts but not the asymptotic exponent. For real parameters, the cost is exponential:

import math
def prange_exponent(n, k, w):
"""Log2 of expected Prange iterations: C(n,k) / C(n-w,k)."""
log_cost = (
sum(math.log2(n - i) for i in range(k))
- sum(math.log2(n - w - i) for i in range(k))
)
return log_cost
# Hamming [7,4,3] with w=1.
exp_hamming = prange_exponent(7, 4, 1)
print(f"[7,4,3] w=1: 2^{exp_hamming:.1f} iterations")
# ==> [7,4,3] w=1: 2^1.2 iterations
# Classic McEliece mceliece348864: n=3488, k=2720, w=64.
exp_mce = prange_exponent(3488, 2720, 64)
print(f"mceliece348864: 2^{exp_mce:.1f} iterations")
# ==> mceliece348864: 2^142.8 iterations
# Classic McEliece mceliece6960119: n=6960, k=5413, w=119.
exp_mce2 = prange_exponent(6960, 5413, 119)
print(f"mceliece6960119: 2^{exp_mce2:.1f} iterations")
# ==> mceliece6960119: 2^263.4 iterations

Prange’s 2143\approx 2^{143} iterations for mceliece348864 is the baseline. The information-set decoding section below surveys the improvements.

A binary Goppa code is a family of codes with a hidden algebraic structure that makes efficient decoding possible for someone who knows the structure, while the parity-check matrix looks random to an outsider. This is the trapdoor behind McEliece’s 1978 cryptosystem (McEliece, 1978).

The construction uses extension fields. Fix m1m \geq 1 and work in GF(2m)\mathrm{GF}(2^m). Choose an irreducible polynomial g(x)g(x) of degree tt over GF(2m)\mathrm{GF}(2^m) and a support set L={α0,α1,,αn1}L = \{\alpha_0, \alpha_1, \ldots, \alpha_{n-1}\} of nn distinct elements of GF(2m)\mathrm{GF}(2^m) that are not roots of gg. The binary Goppa code Γ(L,g)\Gamma(L, g) is defined as:

Γ(L,g)={cGF(2)n  :  i=0n1cixαi0(modg(x))}.\Gamma(L, g) = \left\{c \in \mathrm{GF}(2)^n \;:\; \sum_{i=0}^{n-1} \frac{c_i}{x - \alpha_i} \equiv 0 \pmod{g(x)}\right\}.

The code has parameters [n,nmt,2t+1][n,\, \geq n - mt,\, \geq 2t + 1] and corrects up to tt errors (MacWilliams & Sloane, 1977). Patterson’s algorithm decodes Γ(L,g)\Gamma(L, g) in polynomial time given g(x)g(x) and LL (Patterson, 1975). Without g(x)g(x) and the support LL, the binary parity-check matrix is meant to look like a random (mt)×n(mt) \times n binary matrix: the best known attack is then generic syndrome decoding, which is exponential. Security therefore rests on two legs, the average-case hardness of decoding random-looking codes and the absence of an efficient attack that recovers or exploits the hidden Goppa structure. Chapter 20 takes up the structural side for Classic McEliece.

The smallest concrete example uses m=3m = 3 (so GF(23)=GF(8)\mathrm{GF}(2^3) = \mathrm{GF}(8)) and t=1t = 1. The field GF(8)=GF(2)[x]/(x3+x+1)\mathrm{GF}(8) = \mathrm{GF}(2)[x]/(x^3 + x + 1) has 8 elements (0 through 7). Take g(x)=xαg(x) = x - \alpha for some root α\alpha and let LL be the 7 elements of GF(8)\mathrm{GF}(8) excluding α\alpha. With α=3\alpha = 3 and L={0,1,2,4,5,6,7}L = \{0, 1, 2, 4, 5, 6, 7\}, the support has n=7n = 7 elements and the code has parameters [7,73,3]=[7,4,3][7,\, \geq 7 - 3,\, \geq 3] = [7,\, \geq 4,\, \geq 3]. The parity-check matrix is 3×73 \times 7 with entries Hj=1/(αjα)H_j = 1/(\alpha_j \oplus \alpha) expanded to binary:

def gf8_mul(a, b):
"""Multiply in GF(2^3) = GF(2)[x]/(x^3 + x + 1)."""
p = 0
for _ in range(3):
if b & 1:
p ^= a
b >>= 1
a <<= 1
if a & 0b1000:
a ^= 0b1011 # reduce mod x^3 + x + 1
return p
def gf8_inv(a):
"""Brute-force inverse in GF(8). Crashes on zero input."""
assert a != 0, "zero has no inverse"
for x in range(1, 8):
if gf8_mul(a, x) == 1:
return x
# Goppa code with g(x) = x - 3 over GF(8), support = GF(8) \ {3}.
g_root = 3
support = [i for i in range(8) if i != g_root]
print("support:", support)
# ==> support: [0, 1, 2, 4, 5, 6, 7]
# H_j = 1 / (L_j XOR g_root), expanded to 3 binary rows.
H_goppa = [[0] * len(support) for _ in range(3)]
for j, alpha in enumerate(support):
val = gf8_inv(alpha ^ g_root)
for bit in range(3):
H_goppa[2 - bit][j] = (val >> bit) & 1
print("Goppa parity-check matrix:")
for row in H_goppa:
print(" ", row)
# ==> Goppa parity-check matrix:
# ==> [1, 1, 0, 1, 0, 0, 1]
# ==> [1, 0, 0, 0, 1, 1, 1]
# ==> [0, 1, 1, 0, 1, 0, 1]

The 3×73 \times 7 binary parity-check matrix has no visible structure that reveals the Goppa polynomial or the support set. This toy instance is far too small to actually hide anything. It only shows the shape of the trapdoor. This is the trapdoor: the code owner knows g(x)g(x) and can decode efficiently with Patterson’s algorithm (Patterson, 1975); an eavesdropper sees a random-looking binary matrix and must solve SDP. Chapter 20 builds a full McEliece key generation, encryption, and decryption around this structure.

A quasi-cyclic (QC) code is a code where a cyclic shift of any codeword by a fixed block length is also a codeword. The parity-check matrix is built from circulant blocks, where each block is fully determined by its first row. Circulant multiplication corresponds to polynomial multiplication modulo xn1x^n - 1 over GF(2)\mathrm{GF}(2).

HQC uses this structure. The name stands for “Hamming Quasi-Cyclic,” though the scheme’s inner code is not a Hamming code. Chapter 21 covers HQC’s actual construction. A double-circulant quasi-cyclic code is described by a single vector hGF(2)nh \in \mathrm{GF}(2)^n: the parity-check matrix has the form [Inrot(h)][I_n \mid \mathrm{rot}(h)] where rot(h)\mathrm{rot}(h) is the n×nn \times n circulant matrix whose rows are cyclic shifts of hh. HQC’s actual public key is not just hh; it is a pair (h,s)(h, s) where s=x+hys = x + h \cdot y is a noisy syndrome of secret sparse vectors x,yx, y (Chapter 21). What the quasi-cyclic structure buys is compactness: a circulant block is fixed by one row. Storing hh takes nn bits; storing the full parity-check matrix of a random [2n,n][2n, n] code would take n2nn \cdot 2n bits:

# Key-size savings from quasi-cyclic structure.
# Random [2n, n] code: parity-check matrix has n * 2n bits.
# QC code: store one row of the circulant block, n bits.
n_hqc1 = 17_669
random_matrix_bits = n_hqc1 * (2 * n_hqc1)
qc_bits = n_hqc1
savings = random_matrix_bits / qc_bits
# Ceil division: 17,669 bits needs 2,209 bytes, not 2,208.
random_matrix_bytes = -(-random_matrix_bits // 8)
qc_bytes = -(-qc_bits // 8)
print(f"random matrix: {random_matrix_bits:,} bits = {random_matrix_bytes:,} bytes")
print(f"QC representation: {qc_bits:,} bits = {qc_bytes:,} bytes")
print(f"savings factor: {savings:,.0f}x")
# ==> random matrix: 624,387,122 bits = 78,048,391 bytes
# ==> QC representation: 17,669 bits = 2,209 bytes
# ==> savings factor: 35,338x

The tradeoff is that quasi-cyclic structure introduces algebraic relations that might help an attacker. HQC’s security rests on the quasi-cyclic syndrome decoding (QCSD) problem, a structured variant of generic SDP rather than generic SDP itself. The structure is what compresses the key, and it is also why QCSD needs separate cryptanalysis: no known algorithm exploits the quasi-cyclic structure to break HQC’s chosen parameters. Chapter 21 states the IND-CPA reduction to the decisional QCSD variants the HQC specification uses (2-DQCSD-P and 3-DQCSD-PT) and builds the scheme’s IND-CPA core at toy parameters on this foundation.

Circulant multiplication is polynomial multiplication in the ring GF(2)[x]/(xn1)\mathrm{GF}(2)[x]/(x^n - 1). The vector h=(h0,h1,,hn1)h = (h_0, h_1, \ldots, h_{n-1}) represents the polynomial h(x)=h0+h1x++hn1xn1h(x) = h_0 + h_1 x + \cdots + h_{n-1} x^{n-1}. Multiplying two such polynomials and reducing modulo xn1x^n - 1 wraps high-degree terms back around, which is exactly what a cyclic shift does:

def poly_mul_mod(a, b, n):
"""Multiply polynomials a and b in GF(2)[x]/(x^n - 1)."""
result = [0] * n
for i, ai in enumerate(a):
if ai == 0:
continue
for j, bj in enumerate(b):
if bj:
result[(i + j) % n] ^= 1
return result
# Small example: n=5, multiply (1 + x) by (1 + x^3) mod x^5 - 1.
a = [1, 1, 0, 0, 0]
b = [1, 0, 0, 1, 0]
c = poly_mul_mod(a, b, 5)
print("product mod x^5-1:", c)
# ==> product mod x^5-1: [1, 1, 0, 1, 1]

Prange’s algorithm from 1962 is the baseline ISD (Prange, 1962). Every subsequent improvement refines the same framework: guess a structure-free subset of the codeword positions, solve the resulting system, and check whether the solution has the right weight.

The ISD exponent timeline below is for half-distance decoding of random binary codes: error weight at half the Gilbert-Varshamov distance, and each exponent the maximum of the algorithm’s rate-dependent cost over all code rates, which is the worst case each paper reports and which falls near rate 0.450.45 to 0.470.47 for every algorithm in the table. This bounded-distance regime is closer to the setting code-based KEMs use than full-distance decoding is. Each time exponent comes with the memory the algorithm’s lists occupy, which is where the later improvements pay for their speed. Concrete schemes still pick their own rates and weights and size parameters with finite-nn estimators, not with this asymptotic exponent. Each entry is the asymptotic cost 2cn2^{cn}:

YearAlgorithmTime exponentMemory exponentReference
1962Prange0.0576n\approx 0.0576npolynomial(Prange, 1962)
1989Stern0.0556n\approx 0.0556n0.0135n\approx 0.0135n(Stern, 1989)
2011MMT0.0537n\approx 0.0537n0.0216n\approx 0.0216n(May et al., 2011)
2012BJMM0.0494n\approx 0.0494n0.0286n\approx 0.0286n(Becker et al., 2012)
2015May-Ozerov0.0473n\approx 0.0473nexponential, not tabulated(May & Ozerov, 2015)

The memory column is the space coefficient BJMM’s comparison table reports for each algorithm optimized for speed (Table 1 in Becker et al., 2012). May-Ozerov’s theorem states a time exponent and no memory coefficient. Its nearest-neighbour search keeps exponential lists, and its authors record a large polynomial overhead as an open problem.

Each improvement applies a refined search strategy within the information set. Lee-Brickell allows a small number of error positions inside the information set, a constant-factor gain over Prange (Lee & Brickell, 1988). Stern splits the information set in half and uses a birthday-style collision search to find partial matches (Stern, 1989). BJMM exploits the “representation technique”: in GF(2)\mathrm{GF}(2) a target vector has exponentially many representations as a sum of two vectors. These extra representations speed up the collision search (Becker et al., 2012).

BJMM’s 0.0494n0.0494n is the classical milestone most often cited for random binary codes in this regime. May-Ozerov’s nearest-neighbour search lowered it further to 0.0473n0.0473n, the figure the Classic McEliece submission itself quotes against Prange’s 0.0576n0.0576n (Albrecht et al., 2022, sec. 3.4 of the guide for security reviewers). Standards bodies set parameters from detailed finite-nn cost models, not from a single asymptotic exponent. For comparison, the lattice core-SVP model from Chapter 13 gives a cost of 20.292β2^{0.292\beta} where β\beta is the BKZ block size. Both are exponential-time attacks. From Prange’s 0.0576n\approx 0.0576n to May-Ozerov’s 0.0473n\approx 0.0473n the exponent has fallen only modestly over fifty years, slow progress next to lattice algorithms, and the lattice sieving exponent 0.2920.292 has itself been stable since 2016.

Quantum ISD. Grover’s algorithm provides a quadratic speedup on the brute-force search component of ISD, and both submissions state the consequence in one line. The Classic McEliece guide for security reviewers has known quantum attacks replacing the asymptotic base with its square root (Albrecht et al., 2022, sec. 3.4). The HQC specification obtains its quantum-safe security “by dividing the security bits by two (taking the square root of the complexity)” (Gaborit et al., 2025, sec. 6.3). Both cite the same source, Bernstein’s analysis of Grover applied to information-set decoding (Bernstein, 2010).

Halving applies to whatever exponent the regime supplies, so the regime has to be named. Taking Prange at rate 1/21/2 and the full Gilbert-Varshamov distance (δ0.1100\delta \approx 0.1100, from H2(δ)=1RH_2(\delta) = 1 - R), the classical exponent H2(R)(1δ)H2(R/(1δ))H_2(R) - (1-\delta)H_2(R/(1-\delta)) evaluates to 0.1199n\approx 0.1199n, and Grover takes it to 0.0599n\approx 0.0599n. The 0.1207n0.1207n frequently quoted for this setting is not the rate-1/21/2 value: it is the maximum of the same exponent over all rates, attained near rate 0.4540.454. Note also that halving a Prange exponent is not the same as halving the best classical exponent, because the quantum variants of the improved algorithms rebalance their parameters rather than inheriting a clean square root (Albrecht et al., 2022, sec. 3.3 of the guide for security reviewers).

Neither submission actually prices its own parameters in a 2cn2^{cn} regime. Classic McEliece analyses error weight to(n)t \in o(n), which gives 2Θ(n/log2n)2^{\Theta(n/\log_2 n)} rather than 2Θ(n)2^{\Theta(n)}, and says in as many words that the Θ(n)\Theta(n) weights used in the coding-theory literature “do not appear in the McEliece system” (Albrecht et al., 2022, sec. 3.4 of the guide for security reviewers). HQC analyses ω=O(n)\omega = O(\sqrt{n}) (Gaborit et al., 2025, sec. 6.3). NIST’s security categories for code-based schemes account for the quantum speedup, and the parameters of both schemes are chosen so that even the quantum ISD cost exceeds the target security level.

All sizes below use Category 1 parameter sets (128-bit classical security target). HQC sizes follow the August 2025 specification (Table 6 in Gaborit et al., 2025), which also renamed the parameter sets: what earlier rounds called HQC-128 is HQC-1 there, and this book uses the current names. The Classic McEliece ciphertext is the Niederreiter syndrome only, with the 32-byte session key a separate Encap output (Albrecht et al., 2022, sec. 6.2 of the cryptosystem specification).

PropertyML-KEM-512SLH-DSA-128smceliece348864HQC-1
Hardness assumptionModule-LWEHash/XOF propertiesGoppa-code SDP (+ structural resistance)QCSD (decisional variants)
Representative origin2012 (LWE: 2005)197919782017
Public key800 B32 B261,120 B2,241 B
Ciphertext / signature768 B7,856 B96 B4,433 B
Reduction typeWorst-to-avg (GapSVP to LWE)EUF-CMA from hash/XOFNo worst-case-to-avg reductionDecisional QCSD to IND-CPA; KEM via transform

McEliece’s public keys are large because the systematic-form matrix of a random Goppa code has no exploitable structure for compression. HQC’s quasi-cyclic structure compresses the key from megabytes to kilobytes, at the cost of a structured assumption. SLH-DSA has the smallest keys but the largest signatures.

No assumption family is dominated on size, but individual parameter sets are, and the distinction matters. Read the two size rows as coordinates. mceliece348864 has the largest key (261 KB) but the smallest ciphertext (96 B); SLH-DSA-128s has the smallest key (32 B) but the largest output (7,856 B); ML-KEM-512 sits between them on both axes. No other set in the table beats any of those three on key and output at once. HQC-1 is beaten on both by ML-KEM-512, at 2,241 against 800 bytes of key and 4,433 against 768 of ciphertext. What HQC buys is not size against ML-KEM but a code-based assumption at kilobyte rather than megabyte keys, which is why the code-based family as a whole is not dominated even though this one set is. The figure below plots all four.

Plotting those pairs on logarithmic axes shows the shape of the tradeoff directly. Both axes span orders of magnitude, so a linear plot would collapse three of the four points into the origin.

Public key against ciphertext or signature size at NIST Category 1. A log-log scatter plot of four post-quantum parameter sets. The horizontal axis is public key size in bytes, from 10 to 1,000,000. The vertical axis is ciphertext or signature size in bytes, from 10 to 10,000. SLH-DSA-128s sits at 32 bytes of public key and 7,856 bytes of signature: smallest key, largest output. ML-KEM-512 sits at 800 and 768, near the middle of both axes. mceliece348864 sits at 261,120 and 96: largest key, smallest ciphertext. HQC-1 sits at 2,241 and 4,433, which is larger than ML-KEM-512 on both axes. A dashed staircase joins SLH-DSA-128s, ML-KEM-512 and mceliece348864, the three sets that no other set beats on both axes at once. HQC-1 lies above and right of that staircase. 10 100 1 KB 10 KB 100 KB 1 MB public key (bytes, log scale) 10 100 1 KB 10 KB ciphertext or signature (bytes) SLH-DSA-128s 32 B key, 7,856 B sig ML-KEM-512 800 B key, 768 B ct HQC-1 2,241 B key, 4,433 B ciphertext mceliece348864 261,120 B key, 96 B ciphertext code-based lattice-based hash-based not beaten on both axes
Figure 19.1. Public key against ciphertext or signature size, NIST Category 1, logarithmic on both axes. The dashed staircase marks the three sets that no other set beats on both axes at once. SLH-DSA-128s buys a 32-byte key with a 7,856-byte signature; mceliece348864 buys a 96-byte ciphertext with a 261,120-byte key; ML-KEM-512 sits between them on both. HQC-1 is above and right of the staircase, larger than ML-KEM-512 on both axes. What it buys is not size against ML-KEM but a code-based assumption at kilobyte rather than megabyte keys, which is the tradeoff Chapter 21 develops.

The binary Goppa McEliece line has the longest track record of any post-quantum candidate: 48 years unbroken at suitable parameters as of 2026. ISD exponent improvements from 1962 to 2015 lowered the constant only modestly (Prange’s 0.0576n\approx 0.0576n to May-Ozerov’s 0.0473n\approx 0.0473n in the bounded-distance regime), slow progress compared to lattice algorithm advances over the same period. The decoding-hardness foundation has held up under sustained cryptanalytic scrutiny across that window, though concrete schemes still depend on parameter choices and resistance to structural attacks.

Chapter 20 builds McEliece from Goppa codes in its original generator-matrix form, then connects it to the Niederreiter-form Classic McEliece KEM. Chapter 21 builds HQC from quasi-cyclic codes.

Exercise 1. Verify GHT=0G \cdot H^T = 0 by hand. Multiply the 4×74 \times 7 generator matrix GG and the transpose of the 3×73 \times 7 parity-check matrix HH from this chapter over GF(2)\mathrm{GF}(2). The product is a 4×34 \times 3 matrix. Confirm that every entry is zero. Then explain in one sentence why this relationship guarantees that every codeword has the zero syndrome.

Exercise 2. Decode a weight-2 error. Encode the message (1,1,0,0)(1, 1, 0, 0) using the Hamming code from this chapter. Flip bits at positions 2 and 5 of the codeword. Compute the syndrome. Look up the syndrome in the table and report which position the decoder thinks the error is at. Explain why the decoder corrects to the wrong codeword, given that t=(31)/2=1t = \lfloor(3-1)/2\rfloor = 1.

Exercise 3. Estimate Prange ISD cost. Using math.comb and the formula (nk)/(nwk)\binom{n}{k}/\binom{n-w}{k}, compute the expected number of Prange iterations for (a) the [7,4,3][7,4,3] Hamming code with w=1w = 1, and (b) mceliece348864 with n=3488n = 3488, k=2720k = 2720, w=64w = 64. Express both results as log2\log_2. Verify that (a) gives approximately 21.22.32^{1.2} \approx 2.3 iterations and (b) gives approximately 21432^{143}.

Exercise 4. Quasi-cyclic key savings. Storing a dense n×nn \times n binary matrix costs n2n^2 bits. A circulant block of the same size is fixed by its first row, so it costs nn bits. For HQC-1 with n=17,669n = 17{,}669, compute both storages in bytes, rounding each up to a whole byte, and express the savings factor. The factor is close to nn but not equal to it. Say why. (This is the comparison against the non-identity block alone, the one the aside above calls the honest dense comparison. Comparing against the full n×2nn \times 2n parity-check matrix, as the chapter body’s block does, doubles both the dense side and the factor.)

Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 19. A separate track, for rebuilding rather than reading. The package exercises/ch19-coding-theory hands you the six routines this chapter prints in full. What it stubs is the four the chapter only describes: the general GF(2) matrix product, the zero-syndrome entry that turns a lookup table into a decoder, the parameterised Goppa parity-check construction, and Prange ISD with its cost estimator. Run PQC_IMPL=exercises pytest tests/ch19 to grade your version against the suite that proves the reference one.

Albrecht, M. R., Bernstein, D. J., Chou, T., Cid, C., Gilcher, J., Lange, T., Maram, V., von Maurich, I., Misoczki, R., Niederhagen, R., Paterson, K. G., Persichetti, E., Peters, C., Schwabe, P., Sendrier, N., Szefer, J., Tjhai, C. J., Tomlinson, M., & Wang, W. (2022). Classic McEliece: conservative code-based cryptography. NIST Post-Quantum Cryptography Round 4 submission. https://classic.mceliece.org/nist.html
Becker, A., Joux, A., May, A., & Meurer, A. (2012). Decoding Random Binary Linear Codes in 2n/20: How 1+1=0 Improves Information Set Decoding. Advances in Cryptology – EUROCRYPT 2012, 7237, 520–536. https://doi.org/10.1007/978-3-642-29011-4_31
Berlekamp, E. R., McEliece, R. J., & van Tilborg, H. C. A. (1978). On the inherent intractability of certain coding problems. IEEE Transactions on Information Theory, 24(3), 384–386. https://doi.org/10.1109/TIT.1978.1055873
Bernstein, D. J. (2010). Grover vs. McEliece. Post-Quantum Cryptography – PQCrypto 2010, 6061, 73–80. https://doi.org/10.1007/978-3-642-12929-2_6
Gaborit, P., Aguilar-Melchor, C., Aragon, N., Bettaieb, S., Bidoux, L., Blazy, O., Deneuville, J.-C., Persichetti, E., Zémor, G., Bos, J., Dion, A., Lacan, J., Robert, J.-M., Véron, P., Barreto, P. S. L. M., Ghosh, S., Gueron, S., Güneysu, T., Misoczki, R., … Vasseur, V. (2025). HQC: Hamming Quasi-Cyclic. https://pqc-hqc.org/doc/hqc_specifications_2025_08_22.pdf
Hamming, R. W. (1950). Error detecting and error correcting codes. The Bell System Technical Journal, 29(2), 147–160. https://doi.org/10.1002/j.1538-7305.1950.tb00463.x
Lee, P. J., & Brickell, E. F. (1988). An Observation on the Security of McEliece’s Public-Key Cryptosystem. Advances in Cryptology – EUROCRYPT ’88, 330, 275–280. https://doi.org/10.1007/3-540-45961-8_25
MacWilliams, F. J., & Sloane, N. J. A. (1977). The Theory of Error-Correcting Codes. North-Holland. https://doi.org/10.1016/s0924-6509%2808%29x7030-8
May, A., Meurer, A., & Thomae, E. (2011). Decoding Random Linear Codes in Õ(20.054n). Advances in Cryptology – ASIACRYPT 2011, 7073, 107–124. https://doi.org/10.1007/978-3-642-25385-0_6
May, A., & Ozerov, I. (2015). On Computing Nearest Neighbors with Applications to Decoding of Binary Linear Codes. Advances in Cryptology – EUROCRYPT 2015, 9056, 203–228. https://doi.org/10.1007/978-3-662-46800-5_9
McEliece, R. J. (1978). A public-key cryptosystem based on algebraic coding theory. DSN Progress Report, 42–44, 114–116. https://ipnpr.jpl.nasa.gov/progress_report2/42-44/44N.PDF
Patterson, N. J. (1975). The algebraic decoding of Goppa codes. IEEE Transactions on Information Theory, 21(2), 203–207. https://doi.org/10.1109/TIT.1975.1055350
Prange, E. (1962). The use of information sets in decoding cyclic codes. IRE Transactions on Information Theory, 8(5), S5–S9. https://doi.org/10.1109/TIT.1962.1057777
Stern, J. (1989). A method for finding codewords of small weight. Coding Theory and Applications, 388, 106–113. https://doi.org/10.1007/BFb0019850

Last updated: