Chapter 48 — Practical Exercises#
Companion sheet for Chapter 48 — NTRU, Multivariate, and Isogeny Cryptography. Six exercises spanning all three minority families.
Exercise 48.E1 — ★ Sample short ternary polynomials#
Goal. Write sample_ternary(N, n_plus, n_minus) returning a length-\(N\)
list with exactly n_plus entries equal to \(+1\), n_minus entries equal
to \(-1\), and the rest zero, in random order.
Theory. §48.2 — NTRU keygen.
import random as _r
rng = _r.Random(20260504)
def sample_ternary(N, n_plus, n_minus):
'''Return a length-N list with exactly n_plus +1, n_minus -1, rest 0.'''
# TODO: build a list with the right multiplicities, then shuffle.
raise NotImplementedError('your turn')
# Tests.
# v = sample_ternary(11, 4, 3)
# assert sum(1 for x in v if x == 1) == 4
# assert sum(1 for x in v if x == -1) == 3
# assert sum(1 for x in v if x == 0) == 4
# print('E1 OK')
Exercise 48.E2 — ★★ Centred reduction in \(\mathbb{Z}_q\)#
Goal. Write centred(a, q) that reduces every entry of a to the
unique representative in \((-q/2, q/2]\).
Theory. §48.2 — NTRU decrypt step “centred”.
Why this matters. NTRU decryption hinges on lifting a polynomial that looks random mod \(q\) back to its small-coefficient integer representation. The centred lift is the only choice that preserves the “small coefficient” property.
def centred(a, q):
'''Reduce each entry of a to (-q/2, q/2].'''
# TODO: take entries mod q, then subtract q from anything strictly above q/2.
raise NotImplementedError('your turn')
# assert centred([0, 1, 15, 16, 17, 30, 31], 31) == [0, 1, 15, -15, -14, -1, 0]
# print('E2 OK')
Exercise 48.E3 — ★★ NTRU decryption-failure rate#
Goal. For toy NTRU with \(N = 11\), \(p = 3\), sweep \(q \in \{17, 31, 61, 127\}\) and Monte-Carlo-estimate the decryption failure rate over 500 random keys × 1 plaintext each.
Theory. §48.2 — NTRU plus Ex 48.1 of the chapter.
Why this matters. Decryption-failure rate (DFR) is the correctness parameter of NTRU and Module-LWE schemes; it is exactly the input to the D’Anvers et al. (2019) attack you saw in W4.
# Bring in the NTRU toy from Chapter 48 §48.2. For brevity we duplicate the
# polynomial helpers here (in the real notebook you can import them).
def cyclic_mul(a, b, N):
c = [0] * N
for i in range(N):
for j in range(N):
c[(i + j) % N] += a[i] * b[j]
return c
def vec_mod(v, m): return [((x % m) + m) % m for x in v]
def vec_centred(v, m):
v = vec_mod(v, m)
return [x - m if x > m // 2 else x for x in v]
# (1) Polynomial inversion in Z_q[x] / (x^N - 1) via extended Euclidean
# (chapter 48 ships a pure-Python implementation). Re-use that from the
# main chapter -- copy it across or 'from <your-module> import poly_inv'.
# Stub: a NumPy-free poly_inv is provided in the solution cell below.
def estimate_decryption_failure_rate(N, p, q, trials=200, rng=None):
rng = rng or _r.Random(20260504)
fails = 0
for _ in range(trials):
# TODO step 1: sample short ternary f, g until f is invertible mod p and q.
# TODO step 2: compute h = p * F_q * g mod q.
# TODO step 3: encrypt a fresh random m with fresh random r.
# TODO step 4: decrypt; if recovered != m, increment `fails`.
raise NotImplementedError('your turn')
return fails / trials
# for q in (17, 31, 61, 127):
# dfr = estimate_decryption_failure_rate(11, 3, q, trials=200)
# print(f'q = {q:3d} DFR ~ {dfr:.3%}')
Exercise 48.E4 — ★★★ UOV signing — fill in the linear solve#
Goal. Complete the UOV signer. The setup gives you central-polynomial matrices \(\alpha_k\) (vinegar²) and \(\beta_k\) (vinegar × oil); you must build the \(o \times o\) system in the \(o\) oil unknowns and solve it.
Theory. §48.5 — Oil and Vinegar: UOV and the toy code in §48.5 of the main chapter.
Q = 31
V, O = 6, 4
def gauss_solve(M, b, q):
n = len(b)
A = [list(r) + [bi] for r, bi in zip(M, b)]
for c in range(n):
pi = next((r for r in range(c, n) if A[r][c] % q), None)
if pi is None: return None
A[c], A[pi] = A[pi], A[c]
inv = pow(A[c][c], -1, q)
A[c] = [(x * inv) % q for x in A[c]]
for r in range(n):
if r != c and A[r][c] % q:
f = A[r][c]
A[r] = [(A[r][k] - f * A[c][k]) % q for k in range(n + 1)]
return [r[-1] % q for r in A]
def uov_sign(alpha, beta, target):
'''alpha[k]: V x V vinegar^2 matrix. beta[k]: V x O vinegar*oil matrix.
target[k]: the message-hash residue we want f_k(v, o) to equal in GF(Q).
Returns a (V+O)-length list (vinegar || oil), or None on bad luck.
'''
rng = _r.Random(2026)
for _ in range(20):
v = [rng.randrange(Q) for _ in range(V)]
# TODO step 1: compute the o-by-o coefficient matrix M whose entry
# M[k][j] = sum_i beta[k][i][j] * v[i] (mod Q).
# TODO step 2: compute the right-hand side
# b[k] = target[k] - sum_{i,j} alpha[k][i][j] * v[i] * v[j] (mod Q).
# TODO step 3: solve M @ o = b in GF(Q). If singular, retry with fresh v.
# Return v + o.
raise NotImplementedError('your turn')
return None
# Test on a random instance (with a random target).
# rng = _r.Random(99)
# alpha = [[[rng.randrange(Q) for _ in range(V)] for _ in range(V)] for _ in range(O)]
# beta = [[[rng.randrange(Q) for _ in range(O)] for _ in range(V)] for _ in range(O)]
# target = [rng.randrange(Q) for _ in range(O)]
# x = uov_sign(alpha, beta, target)
# # Verify.
# def f_k(k, x):
# v, o = x[:V], x[V:]
# val = 0
# for i in range(V):
# for jp in range(V): val = (val + alpha[k][i][jp] * v[i] * v[jp]) % Q
# for jp in range(O): val = (val + beta[k][i][jp] * v[i] * o[jp]) % Q
# return val % Q
# assert all(f_k(k, x) == target[k] for k in range(O))
# print('E4 OK')
Exercise 48.E5 — ★★★★ Toy MinRank modelling#
Goal. Given two \(3 \times 3\) matrices \(M_1, M_2\) over \(\mathrm{GF}(7)\), find a non-trivial linear combination \(\lambda_1 M_1 + \lambda_2 M_2\) that has rank 1. This is the smallest-non-trivial MinRank instance behind the Beullens 2022 break of Rainbow.
Theory. §48.6 — Rainbow and the Beullens 2022 break.
# Hand-crafted instance: two 3x3 matrices over GF(7) such that some non-
# trivial combination has rank 1. Construction (don't peek):
# pick u = (1,2,3), v = (4,5,6); the outer product u v^T = R is rank 1.
# pick M2 freely; set M1 = R - M2 mod 7. Then 1*M1 + 1*M2 = R has rank 1.
# Your job: find ANY rank-1 combination.
M1 = [[1, 4, 6], [3, 1, 1], [4, 4, 1]]
M2 = [[3, 1, 0], [5, 2, 4], [1, 4, 3]]
P = 7
def rank_mod(M, p):
A = [r[:] for r in M]
n = len(A); m = len(A[0]); rnk = 0
col = 0
for r in range(n):
if col >= m: break
pi = next((s for s in range(r, n) if A[s][col] % p), None)
if pi is None:
col += 1; continue
A[r], A[pi] = A[pi], A[r]
inv = pow(A[r][col], -1, p)
A[r] = [(x * inv) % p for x in A[r]]
for s in range(n):
if s != r and A[s][col] % p:
f = A[s][col]
A[s] = [(A[s][k] - f * A[r][k]) % p for k in range(m)]
rnk += 1; col += 1
return rnk
def min_rank_brute(M1, M2, p, target_rank=1):
'''Return (l1, l2) such that l1*M1 + l2*M2 has rank target_rank, or None.'''
# TODO: enumerate (l1, l2) in GF(p) x GF(p) excluding (0, 0); compute the
# combination and its rank. Return the first match.
raise NotImplementedError('your turn')
# coefs = min_rank_brute(M1, M2, P, target_rank=1)
# print('found combination:', coefs)
# print('E5 OK')
Exercise 48.E6 — ★★★★★ Research: walk a small supersingular isogeny graph#
Goal. Pick a small prime \(p \equiv 3 \pmod 4\) (e.g. \(p = 71\)). Use SymPy or pure-Python elliptic-curve arithmetic over \(\mathrm{GF}(p^2)\) to:
List all supersingular \(j\)-invariants.
For each pair, decide whether they are connected by a 2-isogeny (via the modular polynomial \(\Phi_2(j_1, j_2) = 0\)).
Plot the resulting graph and confirm it is connected and \(\le \log_2 p\) in diameter.
Theory. §48.7 — Supersingular isogeny graphs.
Why this matters. The Castryck–Decru attack (§48.8) operates on this graph. Building it once for a small \(p\) — even if you cheat by using \(\Phi_2\) rather than running Vélu — gives you a concrete picture of what SIDH was protecting and what the attack glued.
Sage power-up
SageMath has EllipticCurve(GF(p^2), [...]) and supersingular-curve
enumeration built in. If you have it (conda activate sage), this whole
exercise is ~25 lines.
# (Open-ended research project.) Skeleton:
#
# 1. Define p = 71 and the modular polynomial Phi_2(X, Y) over Z[X, Y]:
# Phi_2 = (X+Y)^3 - X^2 Y^2 + 1488 X Y (X + Y) - 162000 (X^2 + Y^2)
# + 40773375 X Y + 8748000000 (X + Y) - 157464000000000
# (Source: any standard reference on modular polynomials.)
# 2. Find supersingular j-invariants over GF(p^2) -- there are exactly
# (p / 12) of them up to small corrections. For p = 71, expect ~6 vertices.
# 3. For each pair (j1, j2), evaluate Phi_2(j1, j2) mod p; non-zero -> no edge.
# 4. Draw the graph (matplotlib + a tiny custom layout, or networkx if you
# install it).