Chapter 46 — Practical Exercises#
This sheet accompanies Chapter 46 — The Post-Quantum Landscape and the Knapsack Problem. Six exercises from one-liner to small research project.
How to use#
Each exercise has three parts:
Problem statement — the goal, plus a difficulty marker (★ very easy to ★★★★★ research project) and a link to the chapter section that covers the theory.
Your turn — a code cell with
TODOgaps. Fill them in, run the cell, and check the assertion at the bottom.Solution — hidden by default. Click “Click to show” to reveal a fully-commented reference implementation that explains every step and links back to the chapter.
The gaps deliberately point at concepts the chapter teaches; doing the exercises is a more durable way to learn the theory than re-reading the text.
Working environment
All exercises run in pure Python with the standard library plus NumPy.
Run pip install numpy matplotlib once if you have not already.
Exercise 46.E1 — ★ Validate a superincreasing sequence#
Goal. Write is_superincreasing(w) returning True iff
\(w_k > \sum_{i<k} w_i\) for every \(k \ge 2\).
Theory. §46.5 — Superincreasing knapsacks are easy.
Why this matters. Merkle–Hellman keygen requires a superincreasing secret sequence; if you accidentally generate one that is not, the greedy decryption silently returns the wrong plaintext.
def is_superincreasing(w):
'''Return True iff w_k > sum(w_i for i < k) for every k >= 2.'''
# TODO: walk through w accumulating a running sum and check each w_k.
# If any element fails the condition, return False; otherwise True.
raise NotImplementedError('your turn')
# Tests. Uncomment and run.
# assert is_superincreasing([2, 3, 8, 17, 31, 65, 130, 258])
# assert not is_superincreasing([2, 3, 4]) # 4 == 2+3 fails strict >
# assert not is_superincreasing([5, 3])
# assert is_superincreasing([1]) # singleton trivially yes
# print('E1 OK')
Exercise 46.E2 — ★★ Decrypt a Merkle–Hellman ciphertext#
Goal. You are given a Merkle–Hellman secret key \((w, m, r)\) and a ciphertext \(S\). Recover the plaintext bit-vector \(\mathbf{x}\).
Theory. §46.6 — The Merkle–Hellman trapdoor.
Why this matters. This is exactly what the recipient does on every incoming ciphertext. Two non-trivial steps: (1) modular un-disguise via \(r^{-1} \bmod m\), and (2) the greedy decode.
# Given key + ciphertext.
W = [2, 7, 18, 28, 63, 127, 249, 500] # secret superincreasing sequence
M = 1045 # secret modulus
R = 789 # secret multiplier
S = 1514 # ciphertext (an ordinary integer)
def egcd(a, b):
if b == 0: return a, 1, 0
g, x1, y1 = egcd(b, a % b)
return g, y1, x1 - (a // b) * y1
def modinv(a, m):
g, x, _ = egcd(a, m)
if g != 1: raise ValueError('not invertible')
return x % m
def merkle_hellman_decrypt(S, w, m, r):
'''Recover plaintext bits from ciphertext S given secret (w, m, r).'''
# TODO step 1: compute the modular inverse r_inv of r mod m.
# TODO step 2: compute S' = (r_inv * S) mod m. Note: S' should now
# equal sum_i x_i * w_i AS AN INTEGER -- the integer sum is
# less than m by construction, so reducing mod m is benign.
# TODO step 3: greedy decode S' against the SUPERINCREASING w. Return
# a list of bits.
raise NotImplementedError('your turn')
# Test: round-trip the published values.
# bits = merkle_hellman_decrypt(S, W, M, R)
# print('Recovered bits:', bits)
# assert bits == [1, 1, 0, 1, 0, 0, 1, 1] # the answer
# print('E2 OK')
Exercise 46.E3 — ★★★ Density vs the Lagarias–Odlyzko threshold#
Goal. Implement density(public_key) and predict whether
Lagarias–Odlyzko (1985) lattice reduction can break the instance.
Theory. §46.7 — Why Merkle–Hellman broke and exercise §46.8 Ex 46.2.
Why this matters. Density is the single most important parameter for predicting whether LLL will recover the plaintext. Anything below \(d \approx 0.94\) is generically broken in polynomial time; above 1, LLL expects multiple-bit collisions and the attack does not directly work.
import math
def density(a):
'''Knapsack density d = n / log2(max(a_i)).'''
# TODO: return n divided by log2 of the maximum entry of a.
raise NotImplementedError('your turn')
def lo_attackable(a, threshold=0.9408):
'''Predict whether Lagarias-Odlyzko (and Coster et al. 1992) succeeds.'''
# TODO: return True iff density(a) < threshold.
raise NotImplementedError('your turn')
# Toy public keys at three densities.
PK_LOW = [0xC2DE for _ in range(8)] # ~16-bit a_i, n=8 -> d~0.5
PK_MED = [(1 << 12) - 1 - i * 137 for i in range(8)] # ~12-bit a_i -> d~0.67
PK_HIGH = [3 + i for i in range(8)] # tiny a_i -> d ~ 2
# print('low d =', density(PK_LOW), ' attackable =', lo_attackable(PK_LOW))
# print('med d =', density(PK_MED), ' attackable =', lo_attackable(PK_MED))
# print('high d =', density(PK_HIGH), ' attackable =', lo_attackable(PK_HIGH))
Exercise 46.E4 — ★★★ Brute force vs greedy: timing comparison#
Goal. Implement an exhaustive \(O(2^n)\) subset-sum solver and time it against the greedy decoder on superincreasing instances of growing \(n\).
Theory. §46.4 — The subset-sum / 0-1 knapsack problem.
Why this matters. Convince yourself, with concrete timings, that the NP-hard generic problem and the \(O(n)\) trapdoored problem really are in totally different complexity classes.
import time, itertools
def brute_force_subset_sum(a, S):
'''Try every subset; return the bit-vector matching S, or None.'''
# TODO: enumerate all 2^n bit-vectors and pick the first whose dot product
# with `a` equals S. itertools.product([0,1], repeat=n) is one way.
raise NotImplementedError('your turn')
def greedy_subset_sum(w, S):
bits = [0] * len(w)
rem = S
for k in range(len(w) - 1, -1, -1):
if w[k] <= rem:
bits[k] = 1
rem -= w[k]
return bits if rem == 0 else None
# Generate a superincreasing sequence and time both solvers for growing n.
def superincr(n):
w = [2]
while len(w) < n:
w.append(sum(w) + 1)
return w
# for n in (8, 12, 16, 20):
# w = superincr(n)
# S = sum(w[:n:2]) # take the even-indexed weights
# t0 = time.time(); b1 = brute_force_subset_sum(w, S); tb = time.time() - t0
# t0 = time.time(); b2 = greedy_subset_sum(w, S); tg = time.time() - t0
# print(f'n={n:2d} brute = {tb*1000:7.1f} ms greedy = {tg*1000:6.3f} ms match={b1==b2}')
Exercise 46.E5 — ★★★★ Build the Lagarias–Odlyzko lattice + a tiny LLL#
Goal. Construct the Lagarias–Odlyzko (LO) lattice for a given public-key / ciphertext pair, run a NumPy implementation of LLL on it, and decode the bit-vector from the resulting short vector.
Theory. §46.7 — Why Merkle–Hellman broke and the upstream cryptanalysis chapter Ch 40 (§40.3–40.7) for the LLL algorithm itself.
Why this matters. This is the W2 lab in miniature. The LO lattice is the bridge from “knapsack as a hard problem” to “LLL as a near-universal cryptanalytic hammer”.
import numpy as np
def build_LO_lattice(a, S):
'''Lagarias-Odlyzko lattice: returns an (n+1) x (n+1) integer basis B.
Rows are the basis vectors; the last column carries the sum constraint.
A short vector with last coord 0 has front entries +/- 1 encoding bits.
'''
n = len(a)
# TODO: build B with the following pattern (rows are vectors):
# for i in 0..n-1: row i = 2 * e_i + 2 * a_i * e_n
# for i = n : row n = 1_n + 2 * S * e_n
# Return as a 2D NumPy array with dtype=object (so coefficients can be big ints).
raise NotImplementedError('your turn')
def lll(B, delta=0.75):
'''Plain LLL on integer-row basis B (NumPy 2D array). Returns reduced B.'''
B = np.array(B, dtype=object)
n = B.shape[0]
def gso():
Bf = B.astype(float)
Q = np.zeros_like(Bf)
mu = np.zeros((n, n))
for i in range(n):
Q[i] = Bf[i].copy()
for j in range(i):
denom = float(Q[j] @ Q[j])
mu[i, j] = float(Bf[i] @ Q[j]) / denom if denom else 0.0
Q[i] -= mu[i, j] * Q[j]
return Q, mu
def size_reduce(k, mu):
for j in range(k - 1, -1, -1):
q = round(mu[k, j])
if q:
B[k] = B[k] - q * B[j]
mu[k, :j+1] -= q * mu[j, :j+1]
mu[k, j] -= q
Q, mu = gso()
k = 1
iters = 0
while k < n and iters < 1000 * n:
size_reduce(k, mu)
if (Q[k] @ Q[k]) >= (delta - mu[k, k-1]**2) * (Q[k-1] @ Q[k-1]):
k += 1
else:
B[[k, k-1]] = B[[k-1, k]]
Q, mu = gso()
k = max(k - 1, 1)
iters += 1
return B
def bits_from_short_vec(v, n):
out = []
for x in v[:n]:
if int(x) == 1: out.append(0)
elif int(x) == -1: out.append(1)
else: return None
return out
def attack_merkle_hellman(a, S):
B = build_LO_lattice(a, S)
Bred = lll(B)
for row in Bred:
if int(row[-1]) != 0: continue
b = bits_from_short_vec(row, len(a))
if b is not None: return b
bneg = bits_from_short_vec(-row, len(a))
if bneg is not None: return bneg
return None
# Build a low-density instance and break it.
# from random import Random
# rnd = Random(42)
# n = 10
# w = [rnd.randrange(2, 10)]
# for _ in range(n - 1):
# w.append(sum(w) + rnd.randrange(1, 10))
# m = (1 << 32) + rnd.randrange(1, 1024); r = 17 # tiny example
# while True:
# try: r_inv = pow(r, -1, m); break
# except ValueError: r += 2
# pk = [(r * wi) % m for wi in w]
# bits = [rnd.randrange(2) for _ in range(n)]
# S = sum(b * ai for b, ai in zip(bits, pk))
# rec = attack_merkle_hellman(pk, S)
# print('density =', n / max(pk).bit_length())
# print('truth =', bits)
# print('recovered =', rec)
# assert rec == bits
# print('E5 OK')
Exercise 46.E6 — ★★★★★ Research: density vs LLL success rate#
Goal. Empirically map the success rate of the Lagarias–Odlyzko
attack as a function of knapsack density. Generate \(T = 50\) random
Merkle–Hellman instances at each density value \(d \in
\{0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0\}\), run your attack_merkle_hellman
from E5, and plot the recovery rate.
Theory. Lagarias–Odlyzko 1985, refined by Coster–Joux–LaMacchia–Odlyzko– Schnorr–Stern 1992 (chapter §46.7 + the upstream Ch 40).
Why this matters. This is exactly the kind of empirical question every PQC paper has to answer for its own scheme: “at what parameter regime does the best known attack succeed, and where is the margin?”. The Coster et al. (1992) bound says success below \(d \approx 0.94\), but real-world LLL is implementation-dependent — measure your own.
Open-ended
This is a small research project. Variations to try: vary \(n\) at fixed \(d\); replace LLL with BKZ-2; replace the LO lattice with the Coster et al. 1992 variant. Bring plots to office hours.
import numpy as np
from random import Random
def random_mh_instance(n, density_target, rnd):
'''Return (pk, ct, bits) at a target density.
Density d ~ n / log2(max a_i). We pick the modulus m ~ 2^(n/d) and
the multiplier r at random coprime to m, then publish a_i = r * w_i mod m.
'''
# TODO step 1: generate a superincreasing secret sequence w of length n.
# TODO step 2: compute the modulus m from the density target.
# TODO step 3: pick a multiplier r coprime to m.
# TODO step 4: build pk and a random plaintext, return (pk, ciphertext, bits).
raise NotImplementedError('your turn')
# Scan densities, run your attack from E5, plot recovery rate.
# (Reuse `attack_merkle_hellman` from E5 in the same notebook.)