Skip to main content

logicaffeine_proof/
fp2.rs

1//! # 𝔽_{p²} arithmetic and the supersingular isogeny graph
2//!
3//! The quadratic extension `𝔽_{p²} = 𝔽_p[i]/(i²+1)` (for `p ≡ 3 (mod 4)`, so `−1` is a non-residue and
4//! `i² = −1` is irreducible) is where the supersingular curves live — and so where CSIDH, SIKE, and the
5//! 2022 Castryck–Decru break happen. This module gives the field arithmetic and builds the **supersingular
6//! ℓ=2 isogeny graph**: vertices are supersingular j-invariants in `𝔽_{p²}`, edges the 2-isogenies. We
7//! build it not through Vélu but through the classical **level-2 modular polynomial** `Φ₂(X,Y)` — the three
8//! roots of `Φ₂(j, ·)` are exactly the j-invariants 2-isogenous to `j`. So the whole graph is field
9//! arithmetic + cubic root-finding + a breadth-first walk from a known supersingular vertex (`j = 1728`,
10//! supersingular whenever `p ≡ 3 (mod 4)`). The result is a 3-regular **Ramanujan graph** whose size is the
11//! supersingular mass `⌊p/12⌋ + ε`.
12
13use crate::factor::mod_inverse;
14use logicaffeine_base::BigInt;
15
16/// An element `a + b·i` of `𝔽_{p²} = 𝔽_p[i]/(i²+1)`.
17pub type Fp2 = (BigInt, BigInt);
18
19#[inline]
20fn ib(x: i64) -> BigInt {
21    BigInt::from_i64(x)
22}
23
24#[inline]
25fn rem_pos(a: &BigInt, p: &BigInt) -> BigInt {
26    let r = a.div_rem(p).map(|(_, r)| r).unwrap_or_else(|| a.clone());
27    if r.is_negative() {
28        r.add(p)
29    } else {
30        r
31    }
32}
33
34#[inline]
35fn m(a: &BigInt, b: &BigInt, p: &BigInt) -> BigInt {
36    rem_pos(&a.mul(b), p)
37}
38#[inline]
39fn s(a: &BigInt, b: &BigInt, p: &BigInt) -> BigInt {
40    rem_pos(&a.sub(b), p)
41}
42#[inline]
43fn a_(a: &BigInt, b: &BigInt, p: &BigInt) -> BigInt {
44    rem_pos(&a.add(b), p)
45}
46
47/// A rational constant `k` as an element of `𝔽_{p²}`.
48pub fn fp2_const(k: i64, p: &BigInt) -> Fp2 {
49    (rem_pos(&ib(k), p), ib(0))
50}
51
52pub fn fp2_add(x: &Fp2, y: &Fp2, p: &BigInt) -> Fp2 {
53    (a_(&x.0, &y.0, p), a_(&x.1, &y.1, p))
54}
55pub fn fp2_sub(x: &Fp2, y: &Fp2, p: &BigInt) -> Fp2 {
56    (s(&x.0, &y.0, p), s(&x.1, &y.1, p))
57}
58pub fn fp2_neg(x: &Fp2, p: &BigInt) -> Fp2 {
59    (s(&ib(0), &x.0, p), s(&ib(0), &x.1, p))
60}
61
62/// `(a+bi)(c+di) = (ac − bd) + (ad + bc)i`.
63pub fn fp2_mul(x: &Fp2, y: &Fp2, p: &BigInt) -> Fp2 {
64    let ac = m(&x.0, &y.0, p);
65    let bd = m(&x.1, &y.1, p);
66    let ad = m(&x.0, &y.1, p);
67    let bc = m(&x.1, &y.0, p);
68    (s(&ac, &bd, p), a_(&ad, &bc, p))
69}
70
71/// `(a+bi)⁻¹ = (a − bi)/(a² + b²)`. `None` only for `0`.
72pub fn fp2_inv(x: &Fp2, p: &BigInt) -> Option<Fp2> {
73    let norm = a_(&m(&x.0, &x.0, p), &m(&x.1, &x.1, p), p);
74    let ninv = mod_inverse(&norm, p)?;
75    Some((m(&x.0, &ninv, p), m(&s(&ib(0), &x.1, p), &ninv, p)))
76}
77
78pub fn fp2_is_zero(x: &Fp2) -> bool {
79    x.0.is_zero() && x.1.is_zero()
80}
81
82// ---- The level-2 modular polynomial and the supersingular graph -------------------------------------
83
84/// `Φ₂(j, Y)` as the cubic `c₃Y³ + c₂Y² + c₁Y + c₀` over `𝔽_{p²}` (returned low-degree first). The classical
85/// polynomial `Φ₂(X,Y) = X³ + Y³ − X²Y² + 1488(X²Y+XY²) − 162000(X²+Y²) + 40773375·XY + 8748000000(X+Y) −
86/// 157464000000000`, with `X = j` fixed.
87fn phi2_in_y(j: &Fp2, p: &BigInt) -> [Fp2; 4] {
88    let j2 = fp2_mul(j, j, p);
89    let j3 = fp2_mul(&j2, j, p);
90    let k = |c: i64| fp2_const(c, p);
91    let c3 = k(1);
92    // c2 = −j² + 1488·j − 162000
93    let c2 = fp2_sub(&fp2_add(&fp2_neg(&j2, p), &fp2_mul(&k(1488), j, p), p), &k(162000), p);
94    // c1 = 1488·j² + 40773375·j + 8748000000
95    let c1 = fp2_add(
96        &fp2_add(&fp2_mul(&k(1488), &j2, p), &fp2_mul(&k(40773375), j, p), p),
97        &k(8_748_000_000),
98        p,
99    );
100    // c0 = j³ − 162000·j² + 8748000000·j − 157464000000000
101    let c0 = fp2_sub(
102        &fp2_add(&fp2_sub(&j3, &fp2_mul(&k(162000), &j2, p), p), &fp2_mul(&k(8_748_000_000), j, p), p),
103        &k(157_464_000_000_000),
104        p,
105    );
106    [c0, c1, c2, c3]
107}
108
109/// Evaluate a polynomial (low-degree-first coefficients) at `y` over `𝔽_{p²}` by Horner's rule.
110fn poly_eval(coeffs: &[Fp2], y: &Fp2, p: &BigInt) -> Fp2 {
111    let mut acc = fp2_const(0, p);
112    for c in coeffs.iter().rev() {
113        acc = fp2_add(&fp2_mul(&acc, y, p), c, p);
114    }
115    acc
116}
117
118/// Divide a polynomial by `(Y − r)` over `𝔽_{p²}` (synthetic division); returns `(quotient, remainder)`.
119fn poly_div_linear(coeffs: &[Fp2], r: &Fp2, p: &BigInt) -> (Vec<Fp2>, Fp2) {
120    let mut q = vec![fp2_const(0, p); coeffs.len().saturating_sub(1)];
121    let mut carry = fp2_const(0, p);
122    for k in (0..coeffs.len()).rev() {
123        let cur = fp2_add(&coeffs[k], &fp2_mul(&carry, r, p), p);
124        if k == 0 {
125            return (q, cur); // remainder
126        }
127        q[k - 1] = cur.clone();
128        carry = cur;
129    }
130    (q, fp2_const(0, p))
131}
132
133/// All elements of `𝔽_{p²}` (small `p` only).
134fn fp2_elements(p: &BigInt) -> Vec<Fp2> {
135    let pu = p.to_i64().expect("small prime") as i64;
136    let mut v = Vec::new();
137    for a in 0..pu {
138        for b in 0..pu {
139            v.push((ib(a), ib(b)));
140        }
141    }
142    v
143}
144
145/// The roots of a cubic over `𝔽_{p²}`, each paired with its multiplicity (found by brute force over the
146/// field, then divided out). For a supersingular `j`, `Φ₂(j,·)` splits completely, so the multiplicities
147/// sum to 3.
148fn cubic_roots(coeffs: &[Fp2; 4], p: &BigInt) -> Vec<(Fp2, usize)> {
149    let mut out = Vec::new();
150    for y in fp2_elements(p) {
151        if fp2_is_zero(&poly_eval(coeffs, &y, p)) {
152            // multiplicity by repeated division
153            let mut poly: Vec<Fp2> = coeffs.to_vec();
154            let mut mult = 0usize;
155            loop {
156                let (q, rem) = poly_div_linear(&poly, &y, p);
157                if !fp2_is_zero(&rem) {
158                    break;
159                }
160                mult += 1;
161                poly = q;
162                if poly.len() <= 1 {
163                    break;
164                }
165            }
166            out.push((y, mult));
167        }
168    }
169    out
170}
171
172/// A vertex of the supersingular isogeny graph: a j-invariant and its 2-isogenous neighbours (as a
173/// multiset — a double root of `Φ₂` is a double edge).
174#[derive(Clone, Debug)]
175pub struct SsVertex {
176    pub j: Fp2,
177    pub neighbors: Vec<Fp2>,
178}
179
180/// Build the **supersingular ℓ=2 isogeny graph** over `𝔽_{p²}` (requires `p ≡ 3 (mod 4)`). Breadth-first
181/// from the supersingular vertex `j = 1728`, following the roots of `Φ₂(j,·)`. Because the graph is
182/// connected, this reaches *every* supersingular j-invariant; its size is the supersingular mass
183/// `⌊p/12⌋ + ε` and every vertex has out-degree 3. `None` if `p ≢ 3 (mod 4)`.
184pub fn supersingular_graph(p: &BigInt) -> Option<Vec<SsVertex>> {
185    if rem_pos(p, &ib(4)) != ib(3) {
186        return None;
187    }
188    let start = fp2_const(1728, p);
189    let mut visited: Vec<Fp2> = Vec::new();
190    let mut queue: Vec<Fp2> = vec![start];
191    let mut graph: Vec<SsVertex> = Vec::new();
192    while let Some(j) = queue.pop() {
193        if visited.iter().any(|v| v == &j) {
194            continue;
195        }
196        visited.push(j.clone());
197        let mut neighbors = Vec::new();
198        for (root, mult) in cubic_roots(&phi2_in_y(&j, p), p) {
199            for _ in 0..mult {
200                neighbors.push(root.clone());
201            }
202            if !visited.iter().any(|v| v == &root) && !queue.iter().any(|v| v == &root) {
203                queue.push(root);
204            }
205        }
206        graph.push(SsVertex { j, neighbors });
207    }
208    Some(graph)
209}
210
211// ---- Elliptic curves over 𝔽_{p²} — SIKE scale ------------------------------------------------------
212//
213// Supersingular curves, and their FULL ℓ-torsion (`E[ℓ] ≅ (ℤ/ℓ)²`), are rational only over the quadratic
214// extension: `#E(𝔽_{p²}) = (p ∓ 1)²`, so `E(𝔽_{p²}) ≅ (ℤ/(p∓1))²` carries the torsion SIKE's basis lives
215// in. This is the concrete curve layer over 𝔽_{p²}: the same Weierstrass group law as over the prime
216// field, with every coordinate an `Fp2`.
217
218/// A Weierstrass curve `y² = x³ + ax + b` over `𝔽_{p²}`.
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub struct Curve2 {
221    pub a: Fp2,
222    pub b: Fp2,
223    pub p: BigInt,
224}
225
226/// An affine point over `𝔽_{p²}`, or the identity.
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub enum Point2 {
229    Infinity,
230    Affine(Fp2, Fp2),
231}
232
233impl Curve2 {
234    pub fn new(a: Fp2, b: Fp2, p: BigInt) -> Curve2 {
235        Curve2 { a, b, p }
236    }
237
238    pub fn is_on_curve(&self, pt: &Point2) -> bool {
239        match pt {
240            Point2::Infinity => true,
241            Point2::Affine(x, y) => {
242                let lhs = fp2_mul(y, y, &self.p);
243                let x3 = fp2_mul(&fp2_mul(x, x, &self.p), x, &self.p);
244                let rhs = fp2_add(&fp2_add(&x3, &fp2_mul(&self.a, x, &self.p), &self.p), &self.b, &self.p);
245                lhs == rhs
246            }
247        }
248    }
249
250    pub fn negate(&self, pt: &Point2) -> Point2 {
251        match pt {
252            Point2::Infinity => Point2::Infinity,
253            Point2::Affine(x, y) => Point2::Affine(x.clone(), fp2_neg(y, &self.p)),
254        }
255    }
256
257    pub fn add(&self, p1: &Point2, p2: &Point2) -> Point2 {
258        match (p1, p2) {
259            (Point2::Infinity, _) => p2.clone(),
260            (_, Point2::Infinity) => p1.clone(),
261            (Point2::Affine(x1, y1), Point2::Affine(x2, y2)) => {
262                if x1 == x2 {
263                    if fp2_is_zero(&fp2_add(y1, y2, &self.p)) {
264                        return Point2::Infinity; // P = −Q
265                    }
266                    return self.double(p1); // P = Q
267                }
268                let den = fp2_inv(&fp2_sub(x2, x1, &self.p), &self.p).expect("distinct x invertible");
269                let lam = fp2_mul(&fp2_sub(y2, y1, &self.p), &den, &self.p);
270                let x3 = fp2_sub(&fp2_sub(&fp2_mul(&lam, &lam, &self.p), x1, &self.p), x2, &self.p);
271                let y3 = fp2_sub(&fp2_mul(&lam, &fp2_sub(x1, &x3, &self.p), &self.p), y1, &self.p);
272                Point2::Affine(x3, y3)
273            }
274        }
275    }
276
277    pub fn double(&self, pt: &Point2) -> Point2 {
278        match pt {
279            Point2::Infinity => Point2::Infinity,
280            Point2::Affine(x, y) => {
281                if fp2_is_zero(y) {
282                    return Point2::Infinity;
283                }
284                let num = fp2_add(&fp2_mul(&fp2_const(3, &self.p), &fp2_mul(x, x, &self.p), &self.p), &self.a, &self.p);
285                let den = fp2_inv(&fp2_mul(&fp2_const(2, &self.p), y, &self.p), &self.p).expect("2y invertible");
286                let lam = fp2_mul(&num, &den, &self.p);
287                let x3 = fp2_sub(&fp2_mul(&lam, &lam, &self.p), &fp2_mul(&fp2_const(2, &self.p), x, &self.p), &self.p);
288                let y3 = fp2_sub(&fp2_mul(&lam, &fp2_sub(x, &x3, &self.p), &self.p), y, &self.p);
289                Point2::Affine(x3, y3)
290            }
291        }
292    }
293
294    /// Scalar multiplication `k·P` by double-and-add (`k ≥ 0`).
295    pub fn mul(&self, k: &BigInt, pt: &Point2) -> Point2 {
296        let mut result = Point2::Infinity;
297        let mut addend = pt.clone();
298        let (_, bytes) = k.to_le_bytes();
299        for byte in bytes {
300            for bit in 0..8 {
301                if (byte >> bit) & 1 == 1 {
302                    result = self.add(&result, &addend);
303                }
304                addend = self.double(&addend);
305            }
306        }
307        result
308    }
309
310    /// The j-invariant in `𝔽_{p²}` — the supersingular-graph vertex this curve sits at.
311    pub fn j_invariant(&self) -> Option<Fp2> {
312        let a3 = fp2_mul(&fp2_mul(&self.a, &self.a, &self.p), &self.a, &self.p);
313        let four_a3 = fp2_mul(&fp2_const(4, &self.p), &a3, &self.p);
314        let disc = fp2_add(&four_a3, &fp2_mul(&fp2_const(27, &self.p), &fp2_mul(&self.b, &self.b, &self.p), &self.p), &self.p);
315        if fp2_is_zero(&disc) {
316            return None;
317        }
318        Some(fp2_mul(&fp2_mul(&fp2_const(1728, &self.p), &four_a3, &self.p), &fp2_inv(&disc, &self.p)?, &self.p))
319    }
320}
321
322// ---- Square roots, torsion, Vélu, and the Weil pairing OVER 𝔽_{p²} (SIKE scale) --------------------
323
324/// `√a` in `𝔽_p` for `p ≡ 3 (mod 4)`: `a^{(p+1)/4}`; `None` for a non-residue.
325fn fp_sqrt(a: &BigInt, p: &BigInt) -> Option<BigInt> {
326    let ap = rem_pos(a, p);
327    if ap.is_zero() {
328        return Some(ib(0));
329    }
330    let exp = p.add(&ib(1)).div_rem(&ib(4))?.0;
331    let r = crate::factor::modpow(&ap, &exp, p);
332    (m(&r, &r, p) == ap).then_some(r)
333}
334
335/// `√α` in `𝔽_{p²}` (`p ≡ 3 mod 4`), or `None` if `α` is not a square. From `β² = α` with `β = x + yi`:
336/// `x² − y² = a`, `2xy = b`, whence `x² = (a ± √(a²+b²))/2`; a final `β² = α` check makes it exact.
337pub fn fp2_sqrt(alpha: &Fp2, p: &BigInt) -> Option<Fp2> {
338    let (a, b) = (&alpha.0, &alpha.1);
339    if b.is_zero() {
340        if let Some(r) = fp_sqrt(a, p) {
341            return Some((r, ib(0)));
342        }
343        return fp_sqrt(&s(&ib(0), a, p), p).map(|r| (ib(0), r)); // a non-residue ⟹ √a = √(−a)·i
344    }
345    let norm = a_(&m(a, a, p), &m(b, b, p), p);
346    let sn = fp_sqrt(&norm, p)?;
347    let inv2 = crate::factor::mod_inverse(&ib(2), p)?;
348    for signed in [sn.clone(), s(&ib(0), &sn, p)] {
349        let t = m(&a_(a, &signed, p), &inv2, p); // (a ± √N)/2
350        if let Some(x) = fp_sqrt(&t, p) {
351            if x.is_zero() {
352                continue;
353            }
354            let y = m(b, &crate::factor::mod_inverse(&m(&ib(2), &x, p), p)?, p);
355            let cand = (x, y);
356            if fp2_mul(&cand, &cand, p) == *alpha {
357                return Some(cand);
358            }
359        }
360    }
361    None
362}
363
364/// Every affine point of a curve over `𝔽_{p²}`, found by an `𝔽_{p²}` square root per x — `O(p²)`, not the
365/// `O(p⁴)` of brute enumeration, so it reaches SIKE-shaped primes.
366fn points_on_curve2(c: &Curve2) -> Vec<Point2> {
367    let pu = c.p.to_i64().expect("small prime");
368    let mut v = vec![Point2::Infinity];
369    for xa in 0..pu {
370        for xb in 0..pu {
371            let x = (ib(xa), ib(xb));
372            let x3 = fp2_mul(&fp2_mul(&x, &x, &c.p), &x, &c.p);
373            let rhs = fp2_add(&fp2_add(&x3, &fp2_mul(&c.a, &x, &c.p), &c.p), &c.b, &c.p);
374            if fp2_is_zero(&rhs) {
375                v.push(Point2::Affine(x, fp2_const(0, &c.p)));
376            } else if let Some(y) = fp2_sqrt(&rhs, &c.p) {
377                v.push(Point2::Affine(x.clone(), fp2_neg(&y, &c.p)));
378                v.push(Point2::Affine(x, y));
379            }
380        }
381    }
382    v
383}
384
385/// A point of prime order `ell` over `𝔽_{p²}` (a candidate isogeny kernel), or `None`.
386pub fn point_of_order2(c: &Curve2, ell: u64) -> Option<Point2> {
387    points_on_curve2(c).into_iter().find(|pt| *pt != Point2::Infinity && c.mul(&ib(ell as i64), pt) == Point2::Infinity)
388}
389
390/// A torsion basis of `E[n]` over `𝔽_{p²}` (`n` prime) — the full `n`-torsion is `𝔽_{p²}`-rational on a
391/// supersingular curve when `n | p+1`. `None` if it is not fully rational.
392pub fn torsion_basis2(c: &Curve2, n: u64) -> Option<(Point2, Point2)> {
393    let onp: Vec<Point2> = points_on_curve2(c)
394        .into_iter()
395        .filter(|pt| *pt != Point2::Infinity && c.mul(&ib(n as i64), pt) == Point2::Infinity)
396        .collect();
397    for p in &onp {
398        let span: Vec<Point2> = (0..n).map(|k| c.mul(&ib(k as i64), p)).collect();
399        if let Some(q) = onp.iter().find(|q| !span.contains(q)) {
400            return Some((p.clone(), q.clone()));
401        }
402    }
403    None
404}
405
406/// A separable `ell`-isogeny over `𝔽_{p²}` by Vélu's formulas (the `Curve2` analogue of `elliptic::Isogeny`).
407#[derive(Clone, Debug)]
408pub struct Isogeny2 {
409    pub domain: Curve2,
410    pub codomain: Curve2,
411    pub degree: u64,
412    kernel: Vec<(Fp2, Fp2, Fp2)>, // (xQ, vQ, uQ)
413}
414
415impl Isogeny2 {
416    pub fn from_kernel(curve: &Curve2, gen: &Point2, ell: u64) -> Option<Isogeny2> {
417        if ell < 3 || ell % 2 == 0 {
418            return None;
419        }
420        let p = &curve.p;
421        let mut kernel = Vec::new();
422        let mut cur = gen.clone();
423        for _ in 0..((ell - 1) / 2) {
424            match &cur {
425                Point2::Affine(xq, yq) => {
426                    let vq = fp2_add(
427                        &fp2_mul(&fp2_const(6, p), &fp2_mul(xq, xq, p), p),
428                        &fp2_mul(&fp2_const(2, p), &curve.a, p),
429                        p,
430                    );
431                    let uq = fp2_mul(&fp2_const(4, p), &fp2_mul(yq, yq, p), p);
432                    kernel.push((xq.clone(), vq, uq));
433                }
434                Point2::Infinity => return None,
435            }
436            cur = curve.add(&cur, gen);
437        }
438        if matches!(gen, Point2::Infinity) || curve.mul(&ib(ell as i64), gen) != Point2::Infinity {
439            return None;
440        }
441        let (mut vsum, mut wsum) = (fp2_const(0, p), fp2_const(0, p));
442        for (xq, vq, uq) in &kernel {
443            vsum = fp2_add(&vsum, vq, p);
444            wsum = fp2_add(&wsum, &fp2_add(uq, &fp2_mul(xq, vq, p), p), p);
445        }
446        let a2 = fp2_sub(&curve.a, &fp2_mul(&fp2_const(5, p), &vsum, p), p);
447        let b2 = fp2_sub(&curve.b, &fp2_mul(&fp2_const(7, p), &wsum, p), p);
448        Some(Isogeny2 { domain: curve.clone(), codomain: Curve2::new(a2, b2, p.clone()), degree: ell, kernel })
449    }
450
451    pub fn eval(&self, pt: &Point2) -> Point2 {
452        let p = &self.domain.p;
453        match pt {
454            Point2::Infinity => Point2::Infinity,
455            Point2::Affine(x, y) => {
456                let mut xnew = x.clone();
457                let mut yfac = fp2_const(1, p);
458                for (xq, vq, uq) in &self.kernel {
459                    let d = fp2_sub(x, xq, p);
460                    if fp2_is_zero(&d) {
461                        return Point2::Infinity;
462                    }
463                    let di = fp2_inv(&d, p).expect("nonzero");
464                    let di2 = fp2_mul(&di, &di, p);
465                    let di3 = fp2_mul(&di2, &di, p);
466                    xnew = fp2_add(&xnew, &fp2_add(&fp2_mul(vq, &di, p), &fp2_mul(uq, &di2, p), p), p);
467                    let tail = fp2_add(&fp2_mul(vq, &di2, p), &fp2_mul(&fp2_mul(&fp2_const(2, p), uq, p), &di3, p), p);
468                    yfac = fp2_sub(&yfac, &tail, p);
469                }
470                Point2::Affine(xnew, fp2_mul(y, &yfac, p))
471            }
472        }
473    }
474}
475
476fn mline_double2(c: &Curve2, t: &Point2, xq: &Fp2, yq: &Fp2) -> Option<(Fp2, Fp2)> {
477    let p = &c.p;
478    let (xt, yt) = match t {
479        Point2::Affine(x, y) => (x, y),
480        Point2::Infinity => return None,
481    };
482    if fp2_is_zero(yt) {
483        return None;
484    }
485    let num = fp2_add(&fp2_mul(&fp2_const(3, p), &fp2_mul(xt, xt, p), p), &c.a, p);
486    let lam = fp2_mul(&num, &fp2_inv(&fp2_mul(&fp2_const(2, p), yt, p), p)?, p);
487    let ell = fp2_sub(&fp2_sub(yq, yt, p), &fp2_mul(&lam, &fp2_sub(xq, xt, p), p), p);
488    let vert = match c.double(t) {
489        Point2::Affine(x2, _) => fp2_sub(xq, &x2, p),
490        Point2::Infinity => fp2_const(1, p),
491    };
492    Some((ell, vert))
493}
494
495fn mline_add2(c: &Curve2, t: &Point2, pp: &Point2, xq: &Fp2, yq: &Fp2) -> Option<(Fp2, Fp2)> {
496    let p = &c.p;
497    let ((xt, yt), (xpp, ypp)) = match (t, pp) {
498        (Point2::Affine(a, b), Point2::Affine(cc, d)) => ((a, b), (cc, d)),
499        _ => return None,
500    };
501    if xt == xpp {
502        if yt == ypp {
503            return mline_double2(c, t, xq, yq);
504        }
505        return Some((fp2_sub(xq, xt, p), fp2_const(1, p)));
506    }
507    let lam = fp2_mul(&fp2_sub(ypp, yt, p), &fp2_inv(&fp2_sub(xpp, xt, p), p)?, p);
508    let ell = fp2_sub(&fp2_sub(yq, yt, p), &fp2_mul(&lam, &fp2_sub(xq, xt, p), p), p);
509    let vert = match c.add(t, pp) {
510        Point2::Affine(x3, _) => fp2_sub(xq, &x3, p),
511        Point2::Infinity => fp2_const(1, p),
512    };
513    Some((ell, vert))
514}
515
516fn miller2(c: &Curve2, pp: &Point2, qq: &Point2, n: u64) -> Option<Fp2> {
517    let p = &c.p;
518    let (xq, yq) = match qq {
519        Point2::Affine(x, y) => (x, y),
520        Point2::Infinity => return None,
521    };
522    let (mut num, mut den) = (fp2_const(1, p), fp2_const(1, p));
523    let mut t = pp.clone();
524    for bit in (0..(64 - n.leading_zeros() - 1)).rev() {
525        let (ell, vert) = mline_double2(c, &t, xq, yq)?;
526        num = fp2_mul(&fp2_mul(&num, &num, p), &ell, p);
527        den = fp2_mul(&fp2_mul(&den, &den, p), &vert, p);
528        t = c.double(&t);
529        if (n >> bit) & 1 == 1 {
530            let (ell, vert) = mline_add2(c, &t, pp, xq, yq)?;
531            num = fp2_mul(&num, &ell, p);
532            den = fp2_mul(&den, &vert, p);
533            t = c.add(&t, pp);
534        }
535    }
536    if fp2_is_zero(&den) {
537        return None;
538    }
539    Some(fp2_mul(&num, &fp2_inv(&den, p)?, p))
540}
541
542/// The **Weil pairing over `𝔽_{p²}`** — the `Curve2` analogue of `elliptic::weil_pairing`, via Miller.
543pub fn weil_pairing2(c: &Curve2, pp: &Point2, qq: &Point2, n: u64) -> Option<Fp2> {
544    if pp == qq || matches!(pp, Point2::Infinity) || matches!(qq, Point2::Infinity) {
545        return Some(fp2_const(1, &c.p));
546    }
547    let fp = miller2(c, pp, qq, n)?;
548    let fq = miller2(c, qq, pp, n)?;
549    let ratio = fp2_mul(&fp, &fp2_inv(&fq, &c.p)?, &c.p);
550    Some(if n % 2 == 1 { fp2_neg(&ratio, &c.p) } else { ratio })
551}
552
553/// The Weil pairing on a **product surface** `E₁ × E₂`: `e((P₁,P₂),(Q₁,Q₂)) = e_N(P₁,Q₁)·e_N(P₂,Q₂)`. A
554/// subgroup on which this vanishes (`= 1`) is **isotropic**; a *maximal* isotropic (Lagrangian) subgroup is
555/// the kernel of an `(N,N)`-isogeny of abelian surfaces — the gluing at the heart of Kani's theorem and the
556/// Castryck–Decru attack.
557pub fn product_weil_pairing(
558    c1: &Curve2,
559    c2: &Curve2,
560    p: (&Point2, &Point2),
561    q: (&Point2, &Point2),
562    n: u64,
563) -> Option<Fp2> {
564    let e1 = weil_pairing2(c1, p.0, q.0, n)?;
565    let e2 = weil_pairing2(c2, p.1, q.1, n)?;
566    Some(fp2_mul(&e1, &e2, &c1.p))
567}
568
569/// `α^k` in `𝔽_{p²}` by square-and-multiply.
570pub fn fp2_pow(alpha: &Fp2, k: u64, p: &BigInt) -> Fp2 {
571    let mut result = fp2_const(1, p);
572    let mut base = alpha.clone();
573    let mut e = k;
574    while e > 0 {
575        if e & 1 == 1 {
576            result = fp2_mul(&result, &base, p);
577        }
578        base = fp2_mul(&base, &base, p);
579        e >>= 1;
580    }
581    result
582}
583
584// ===== SIDH keyspace over 𝔽_{p²}: unfolding, images → generator recovery, the ℓ-adic tree, Aut-orbits =====
585
586/// The order of a point over `𝔽_{p²}` (least `k ≥ 1` with `k·pt = O`), searched up to `bound`.
587pub fn point_order2(c: &Curve2, pt: &Point2, bound: u64) -> Option<u64> {
588    let mut cur = pt.clone();
589    for k in 1..=bound {
590        if cur == Point2::Infinity {
591            return Some(k);
592        }
593        cur = c.add(&cur, pt);
594    }
595    None
596}
597
598/// The SIDH kernel generator `P + [s]·Q` over `𝔽_{p²}`.
599pub fn kernel_generator2(c: &Curve2, p_pt: &Point2, q_pt: &Point2, s: &BigInt) -> Point2 {
600    c.add(p_pt, &c.mul(s, q_pt))
601}
602
603/// A rank-2 basis `(P, Q)` of `E[ℓᵃ] = (ℤ/ℓᵃ)²` — both of order exactly `ℓᵃ`, independent. The full
604/// `ℓᵃ`-torsion is `𝔽_{p²}`-rational on a supersingular curve when `ℓᵃ | p+1`.
605pub fn full_order_basis2(c: &Curve2, ell: u64, a: u32) -> Option<(Point2, Point2)> {
606    let n = ell.pow(a);
607    let full: Vec<Point2> = points_on_curve2(c)
608        .into_iter()
609        .filter(|pt| point_order2(c, pt, n + 1) == Some(n))
610        .collect();
611    for p in &full {
612        let span: Vec<Point2> = (0..n).map(|k| c.mul(&ib(k as i64), p)).collect();
613        if let Some(q) = full.iter().find(|q| !span.contains(q)) {
614            return Some((p.clone(), q.clone()));
615        }
616    }
617    None
618}
619
620/// A single `ℓ`-isogeny step over `𝔽_{p²}`: domain, the order-`ℓ` kernel, and codomain.
621#[derive(Clone, Debug, PartialEq)]
622pub struct IsogenyStep2 {
623    pub domain: Curve2,
624    pub kernel: Point2,
625    pub codomain: Curve2,
626}
627
628/// Unfold one order-`ℓᵃ` kernel generator into the chain of `a` prime-degree `ℓ`-isogenies over `𝔽_{p²}` —
629/// the `Curve2` analogue of [`crate::elliptic::derive_isogeny_path`]. `ℓ` an odd prime.
630pub fn derive_isogeny_path2(c: &Curve2, gen: &Point2, ell: u64, a: u32) -> Option<Vec<IsogenyStep2>> {
631    let mut steps = Vec::with_capacity(a as usize);
632    let mut e = c.clone();
633    let mut g = gen.clone();
634    for step in 0..a {
635        let mut mult = ib(1);
636        for _ in 0..(a - 1 - step) {
637            mult = mult.mul(&ib(ell as i64));
638        }
639        let k = e.mul(&mult, &g);
640        let iso = Isogeny2::from_kernel(&e, &k, ell)?;
641        steps.push(IsogenyStep2 { domain: e.clone(), kernel: k, codomain: iso.codomain.clone() });
642        g = iso.eval(&g);
643        e = iso.codomain;
644    }
645    Some(steps)
646}
647
648/// Push a point through an entire isogeny chain, rebuilding each step's Vélu map.
649pub fn push_through2(steps: &[IsogenyStep2], ell: u64, pt: &Point2) -> Option<Point2> {
650    let mut cur = pt.clone();
651    for step in steps {
652        cur = Isogeny2::from_kernel(&step.domain, &step.kernel, ell)?.eval(&cur);
653    }
654    Some(cur)
655}
656
657/// **Images → generator reconstruction (flat oracle).** The secret isogeny `φ: E₀ → E` of degree `ℓᵃ` has
658/// kernel `⟨P_A + [s]Q_A⟩`; the published torsion images `φ(P_B), φ(Q_B)` pin `s` down. Enumerate the whole
659/// keyspace and return the scalar whose unfolded chain reproduces both images — a genuine images → generator
660/// inversion at SIDH scale. `O(ℓᵃ)`; the correctness oracle for the structured recovery below.
661pub fn recover_secret2(
662    e0: &Curve2,
663    basis_a: (&Point2, &Point2),
664    ell: u64,
665    a: u32,
666    basis_b: (&Point2, &Point2),
667    images: (&Point2, &Point2),
668) -> Option<(BigInt, Point2, Vec<IsogenyStep2>)> {
669    let n = ell.pow(a);
670    for s in 0..n {
671        let sb = ib(s as i64);
672        let gen = kernel_generator2(e0, basis_a.0, basis_a.1, &sb);
673        if point_order2(e0, &gen, n + 1) != Some(n) {
674            continue;
675        }
676        let Some(path) = derive_isogeny_path2(e0, &gen, ell, a) else { continue };
677        match (push_through2(&path, ell, basis_b.0), push_through2(&path, ell, basis_b.1)) {
678            (Some(ip), Some(iq)) if &ip == images.0 && &iq == images.1 => return Some((sb, gen, path)),
679            _ => {}
680        }
681    }
682    None
683}
684
685/// The digit-tree walk: `s = Σ dₖ·ℓᵏ`, so the secret's `ℓ`-adic digits `d₀,…,d_{a-1}` index a depth-`a`
686/// `ℓ`-ary tree whose depth-`k` node fixes `s mod ℓᵏ`. All secrets under one node share their first `k`
687/// isogeny steps — the auto-partition. DFS over the digits, testing each leaf's chain against the images.
688#[allow(clippy::too_many_arguments)]
689fn walk_keyspace_tree(
690    e0: &Curve2,
691    ba: (&Point2, &Point2),
692    ell: u64,
693    a: u32,
694    n: u64,
695    bb: (&Point2, &Point2),
696    images: (&Point2, &Point2),
697    digits: &mut Vec<u64>,
698) -> Option<(BigInt, Point2)> {
699    if digits.len() as u32 == a {
700        let (mut s, mut place) = (ib(0), ib(1));
701        for &d in digits.iter() {
702            s = s.add(&place.mul(&ib(d as i64)));
703            place = place.mul(&ib(ell as i64));
704        }
705        let gen = kernel_generator2(e0, ba.0, ba.1, &s);
706        if point_order2(e0, &gen, n + 1) != Some(n) {
707            return None;
708        }
709        let path = derive_isogeny_path2(e0, &gen, ell, a)?;
710        let (ip, iq) = (push_through2(&path, ell, bb.0)?, push_through2(&path, ell, bb.1)?);
711        return (&ip == images.0 && &iq == images.1).then_some((s, gen));
712    }
713    for d in 0..ell {
714        digits.push(d);
715        if let Some(found) = walk_keyspace_tree(e0, ba, ell, a, n, bb, images, digits) {
716            return Some(found);
717        }
718        digits.pop();
719    }
720    None
721}
722
723/// **Structured images → generator recovery.** Walks the `ℓ`-adic keyspace *tree* (auto-partitioning by
724/// digit) rather than the flat list, returning the recovered `ℓ`-adic digits, the secret `s = Σ dₖ·ℓᵏ`, and
725/// its generator. Same answer as [`recover_secret2`] — but the recursion **is** the digit-by-digit structure
726/// the Castryck–Decru Kani oracle prunes with a per-digit split-test.
727pub fn recover_secret_recursive2(
728    e0: &Curve2,
729    basis_a: (&Point2, &Point2),
730    ell: u64,
731    a: u32,
732    basis_b: (&Point2, &Point2),
733    images: (&Point2, &Point2),
734) -> Option<(Vec<u64>, BigInt, Point2)> {
735    let n = ell.pow(a);
736    let mut digits: Vec<u64> = Vec::with_capacity(a as usize);
737    let (s, gen) = walk_keyspace_tree(e0, basis_a, ell, a, n, basis_b, images, &mut digits)?;
738    Some((digits, s, gen))
739}
740
741/// The order-4 automorphism `ι:(x,y) ↦ (−x, i·y)` of the `j = 1728` curve `y² = x³ + x` (`i² = −1` lives in
742/// `𝔽_{p²}` for `p ≡ 3 mod 4`). `ι² = [−1]`, so `⟨ι⟩ ≅ ℤ/4 = Aut(E₀)`. It permutes the keyspace, and
743/// `E₀/⟨K⟩ ≅ E₀/⟨ι(K)⟩` — the symmetry that makes recovering one kernel recover its whole orbit.
744pub fn aut_1728(p: &BigInt, pt: &Point2) -> Point2 {
745    match pt {
746        Point2::Infinity => Point2::Infinity,
747        Point2::Affine(x, y) => {
748            let i_unit = (ib(0), ib(1)); // i ∈ 𝔽_{p²}
749            Point2::Affine(fp2_neg(x, p), fp2_mul(&i_unit, y, p))
750        }
751    }
752}
753
754/// Partition the keyspace by **codomain `j`-invariant**: secrets whose isogeny lands on the same curve (up to
755/// isomorphism). These classes are exactly the `Aut(E₀)`-orbits — so if one secret in a class is invertible
756/// (its codomain is the target `E`), every secret in the class maps to a curve `≅ E`. Returns
757/// `(j, [secrets])` groups.
758pub fn keyspace_codomain_classes(
759    e0: &Curve2,
760    basis_a: (&Point2, &Point2),
761    ell: u64,
762    a: u32,
763) -> Vec<(Fp2, Vec<u64>)> {
764    let n = ell.pow(a);
765    let mut classes: Vec<(Fp2, Vec<u64>)> = Vec::new();
766    for s in 0..n {
767        let gen = kernel_generator2(e0, basis_a.0, basis_a.1, &ib(s as i64));
768        if point_order2(e0, &gen, n + 1) != Some(n) {
769            continue;
770        }
771        let Some(path) = derive_isogeny_path2(e0, &gen, ell, a) else { continue };
772        let Some(j) = path.last().and_then(|st| st.codomain.j_invariant()) else { continue };
773        match classes.iter().position(|(cj, _)| *cj == j) {
774            Some(idx) => classes[idx].1.push(s),
775            None => classes.push((j, vec![s])),
776        }
777    }
778    classes
779}
780
781/// The `ℓ+1` `ℓ`-isogenous neighbours of a curve: the codomains of the `ℓ+1` order-`ℓ` subgroups of `E[ℓ]`.
782fn ell_isogeny_neighbors(c: &Curve2, ell: u64) -> Vec<Curve2> {
783    let Some((r, s)) = full_order_basis2(c, ell, 1) else { return Vec::new() };
784    let mut gens: Vec<Point2> = (0..ell).map(|i| c.add(&r, &c.mul(&ib(i as i64), &s))).collect();
785    gens.push(s);
786    gens.into_iter().filter_map(|g| Isogeny2::from_kernel(c, &g, ell).map(|iso| iso.codomain)).collect()
787}
788
789/// Backward BFS on the supersingular `ℓ`-isogeny graph from `target`: the minimum `ℓ`-isogeny distance to each
790/// reachable `j`-invariant, out to `max_depth`. This is a **sound per-digit oracle** — a partial curve farther
791/// from the target than its remaining step budget cannot lie on the secret path, so its whole subtree is dead.
792pub fn isogeny_graph_distances(target: &Curve2, ell: u64, max_depth: u32) -> Vec<(Fp2, u32)> {
793    let Some(j0) = target.j_invariant() else { return Vec::new() };
794    let mut dist: Vec<(Fp2, u32)> = vec![(j0, 0)];
795    let mut frontier = vec![target.clone()];
796    for d in 1..=max_depth {
797        let mut next = Vec::new();
798        for c in &frontier {
799            for nbr in ell_isogeny_neighbors(c, ell) {
800                if let Some(j) = nbr.j_invariant() {
801                    if !dist.iter().any(|(jj, _)| *jj == j) {
802                        dist.push((j, d));
803                        next.push(nbr);
804                    }
805                }
806            }
807        }
808        if next.is_empty() {
809            break;
810        }
811        frontier = next;
812    }
813    dist
814}
815
816/// Oracle-pruned digit-tree walk: at each depth-`k` node the partial curve `E_k` (the codomain after `k`
817/// steps of the prefix) is checked against the reachability oracle `dists`; if it is not within `a−k` steps of
818/// the target, the subtree is pruned. Sound (the true prefix always satisfies the budget), so the answer is
819/// unchanged — but leaf image-tests are confined to curves that can still reach `E`, which is exactly the
820/// target's `Aut`-orbit. `leaves` counts the image-tests actually performed.
821#[allow(clippy::too_many_arguments)]
822fn walk_pruned(
823    e0: &Curve2,
824    ba: (&Point2, &Point2),
825    ell: u64,
826    a: u32,
827    n: u64,
828    bb: (&Point2, &Point2),
829    images: (&Point2, &Point2),
830    dists: &[(Fp2, u32)],
831    digits: &mut Vec<u64>,
832    leaves: &mut usize,
833) -> Option<(BigInt, Point2)> {
834    let k = digits.len() as u32;
835    let (mut p, mut place) = (ib(0), ib(1));
836    for &d in digits.iter() {
837        p = p.add(&place.mul(&ib(d as i64)));
838        place = place.mul(&ib(ell as i64));
839    }
840    if k > 0 {
841        let gen_p = kernel_generator2(e0, ba.0, ba.1, &p);
842        let path = derive_isogeny_path2(e0, &gen_p, ell, a)?;
843        let jk = path[(k - 1) as usize].codomain.j_invariant()?;
844        match dists.iter().find(|(jj, _)| *jj == jk).map(|(_, d)| *d) {
845            Some(dk) if dk <= a - k => {}    // still reachable within budget — keep going
846            _ => return None,                // too far (or off-graph) ⟹ prune the whole subtree
847        }
848    }
849    if k == a {
850        *leaves += 1;
851        let gen = kernel_generator2(e0, ba.0, ba.1, &p);
852        if point_order2(e0, &gen, n + 1) != Some(n) {
853            return None;
854        }
855        let path = derive_isogeny_path2(e0, &gen, ell, a)?;
856        let (ip, iq) = (push_through2(&path, ell, bb.0)?, push_through2(&path, ell, bb.1)?);
857        return (&ip == images.0 && &iq == images.1).then_some((p, gen));
858    }
859    for d in 0..ell {
860        digits.push(d);
861        if let Some(found) = walk_pruned(e0, ba, ell, a, n, bb, images, dists, digits, leaves) {
862            return Some(found);
863        }
864        digits.pop();
865    }
866    None
867}
868
869/// **Oracle-pruned images → generator recovery.** Walks the `ℓ`-adic keyspace tree but consults the isogeny
870/// graph reachability oracle (backward BFS from the *public* codomain `target`) at every node, pruning any
871/// branch that can no longer reach the target. Returns the secret, its generator, and the number of leaf
872/// image-tests performed (the pruned search visits only the target's `Aut`-orbit). Same answer as
873/// [`recover_secret2`] — soundness guarantees the true path is never pruned.
874pub fn recover_secret_pruned2(
875    e0: &Curve2,
876    basis_a: (&Point2, &Point2),
877    ell: u64,
878    a: u32,
879    basis_b: (&Point2, &Point2),
880    images: (&Point2, &Point2),
881    target: &Curve2,
882) -> Option<(BigInt, Point2, usize)> {
883    let n = ell.pow(a);
884    let dists = isogeny_graph_distances(target, ell, a);
885    let mut digits: Vec<u64> = Vec::with_capacity(a as usize);
886    let mut leaves = 0usize;
887    let (s, gen) = walk_pruned(e0, basis_a, ell, a, n, basis_b, images, &dists, &mut digits, &mut leaves)?;
888    Some((s, gen, leaves))
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894
895    fn big(s: &str) -> BigInt {
896        BigInt::parse_decimal(s).unwrap()
897    }
898
899    #[test]
900    fn fp2_is_a_field() {
901        let p = big("103"); // 103 ≡ 3 (mod 4)
902        let one = fp2_const(1, &p);
903        let ii = (ib(0), ib(1)); // the element i
904        // i² = −1.
905        assert_eq!(fp2_mul(&ii, &ii, &p), fp2_neg(&one, &p), "i² = −1");
906        // Commutativity, distributivity, and inverses over a sweep.
907        let elts: Vec<Fp2> = (0..7)
908            .flat_map(|a| (0..7).map(move |b| (ib(a * 13 + 1), ib(b * 11 + 2))))
909            .collect();
910        for x in &elts {
911            for y in &elts {
912                assert_eq!(fp2_mul(x, y, &p), fp2_mul(y, x, &p), "× commutes");
913                assert_eq!(fp2_add(x, y, &p), fp2_add(y, x, &p), "+ commutes");
914                // x·(y+1) = x·y + x
915                let lhs = fp2_mul(x, &fp2_add(y, &one, &p), &p);
916                let rhs = fp2_add(&fp2_mul(x, y, &p), x, &p);
917                assert_eq!(lhs, rhs, "distributive");
918            }
919            if !fp2_is_zero(x) {
920                assert_eq!(fp2_mul(x, &fp2_inv(x, &p).unwrap(), &p), one, "x·x⁻¹ = 1");
921            }
922        }
923    }
924
925    // The supersingular mass: #supersingular j-invariants = ⌊p/12⌋ + ε, ε from p mod 12.
926    fn ss_mass(p: u64) -> u64 {
927        p / 12
928            + match p % 12 {
929                1 => 0,
930                5 | 7 => 1,
931                11 => 2,
932                _ => 0,
933            }
934    }
935
936    #[test]
937    fn supersingular_graph_has_the_right_mass_is_3_regular_and_connected() {
938        for &pu in &[11u64, 19, 23, 31, 43, 47] {
939            let p = big(&pu.to_string());
940            let g = supersingular_graph(&p).expect("p ≡ 3 (mod 4)");
941            // Mass: BFS reached every supersingular j-invariant (the graph is connected, so this proves
942            // connectivity too — a disconnected graph would under-count).
943            assert_eq!(g.len() as u64, ss_mass(pu), "supersingular mass for p = {pu}");
944            // 3-regular: Φ₂ is a cubic and splits completely over 𝔽_{p²} at every supersingular vertex.
945            for v in &g {
946                assert_eq!(v.neighbors.len(), 3, "each vertex has out-degree 3 (p = {pu})");
947            }
948            // The graph is undirected/consistent: every neighbour is itself a vertex in the graph.
949            let js: Vec<&Fp2> = g.iter().map(|v| &v.j).collect();
950            for v in &g {
951                for nb in &v.neighbors {
952                    assert!(js.iter().any(|j| *j == nb), "neighbour is a supersingular vertex");
953                }
954            }
955            // j = 1728 is in the graph (our supersingular starting point).
956            assert!(js.iter().any(|j| **j == fp2_const(1728, &p)), "1728 is supersingular for p = {pu}");
957        }
958    }
959
960    fn embed(pt: &crate::elliptic::Point) -> Point2 {
961        match pt {
962            crate::elliptic::Point::Infinity => Point2::Infinity,
963            crate::elliptic::Point::Affine(x, y) => Point2::Affine((x.clone(), ib(0)), (y.clone(), ib(0))),
964        }
965    }
966
967    #[test]
968    fn curve2_agrees_with_the_prime_field_on_rational_points() {
969        // The 𝔽_{p²} group law, restricted to 𝔽_p-rational points, must reproduce the prime-field layer.
970        let p = big("43");
971        let cfp = crate::elliptic::Curve::new(ib(2), ib(25), p.clone());
972        let base = crate::elliptic::point_of_order(&cfp, 5).expect("a rational 5-torsion point");
973        let c2 = Curve2::new(fp2_const(2, &p), fp2_const(25, &p), p.clone());
974        let base2 = embed(&base);
975        assert!(c2.is_on_curve(&base2), "the embedded point lies on the 𝔽_{{p²}} curve");
976        for k in 1..=5i64 {
977            let via_fp = embed(&cfp.mul(&ib(k), &base));
978            let via_fp2 = c2.mul(&ib(k), &base2);
979            assert_eq!(via_fp2, via_fp, "Curve2 matches Curve on {k}·P");
980        }
981    }
982
983    // Every affine point of a curve over 𝔽_{p²} (brute force; small p only).
984    fn all_points2(c: &Curve2) -> Vec<Point2> {
985        let pu = c.p.to_i64().unwrap();
986        let mut v = vec![Point2::Infinity];
987        for xa in 0..pu {
988            for xb in 0..pu {
989                let x = (ib(xa), ib(xb));
990                for ya in 0..pu {
991                    for yb in 0..pu {
992                        let pt = Point2::Affine(x.clone(), (ib(ya), ib(yb)));
993                        if c.is_on_curve(&pt) {
994                            v.push(pt);
995                        }
996                    }
997                }
998            }
999        }
1000        v
1001    }
1002
1003    #[test]
1004    fn supersingular_curve_over_fp2_carries_full_torsion() {
1005        // y² = x³ + x is supersingular for p ≡ 3 (mod 4); over 𝔽_{p²} it has #E = (p+1)² and E ≅ (ℤ/(p+1))²,
1006        // so its full ℓ-torsion (ℓ | p+1) is 𝔽_{p²}-rational — the property SIKE's torsion basis needs.
1007        let p = big("11");
1008        let c = Curve2::new(fp2_const(1, &p), fp2_const(0, &p), p.clone());
1009        assert_eq!(c.j_invariant().unwrap(), fp2_const(1728, &p), "j(y²=x³+x) = 1728, a supersingular vertex");
1010        let pts = all_points2(&c);
1011        assert_eq!(pts.len(), 144, "#E(𝔽_{{p²}}) = (p+1)² = 12²");
1012        // Group law is a real abelian group.
1013        for a in pts.iter().take(6) {
1014            assert_eq!(c.add(a, &c.negate(a)), Point2::Infinity, "P + (−P) = O");
1015            for b in pts.iter().take(6) {
1016                for d in pts.iter().take(6) {
1017                    assert_eq!(c.add(&c.add(a, b), d), c.add(a, &c.add(b, d)), "associative");
1018                }
1019            }
1020        }
1021        // FULL 3-torsion is 𝔽_{p²}-rational: E[3] ≅ (ℤ/3)² has exactly 9 points (3 | p+1 = 12).
1022        let e3 = pts.iter().filter(|pt| c.mul(&ib(3), pt) == Point2::Infinity).count();
1023        assert_eq!(e3, 9, "E[3] ≅ (ℤ/3)² is fully rational over 𝔽_{{p²}}");
1024    }
1025
1026    #[test]
1027    fn fp2_sqrt_round_trips() {
1028        let p = big("59");
1029        for (a, b) in [(3i64, 7), (1, 0), (0, 5), (11, 2), (23, 41)] {
1030            let x = (ib(a), ib(b));
1031            let sq = fp2_mul(&x, &x, &p);
1032            let r = fp2_sqrt(&sq, &p).expect("a square has a root");
1033            assert_eq!(fp2_mul(&r, &r, &p), sq, "(√α)² = α");
1034        }
1035    }
1036
1037    #[test]
1038    fn torsion_image_certificate_lifts_to_fp2_at_sike_scale() {
1039        // p = 59 ≡ 3 (mod 4), p+1 = 60 = 2²·3·5 — a SIKE-shaped prime: E[3] and E[5] are fully
1040        // 𝔽_{p²}-rational on the supersingular curve y² = x³ + x. Build a 3-isogeny, take a 5-torsion basis,
1041        // and verify the torsion-image compatibility law e_5(φP,φQ) = e_5(P,Q)^{deg φ} OVER 𝔽_{p²}.
1042        let p = big("59");
1043        let c = Curve2::new(fp2_const(1, &p), fp2_const(0, &p), p.clone());
1044        let kernel = point_of_order2(&c, 3).expect("3-torsion over 𝔽_{p²}");
1045        let iso = Isogeny2::from_kernel(&c, &kernel, 3).expect("Vélu over 𝔽_{p²}");
1046        let (pp, qq) = torsion_basis2(&c, 5).expect("full 5-torsion basis over 𝔽_{p²}");
1047        let ep = weil_pairing2(&c, &pp, &qq, 5).expect("basis pairing");
1048        assert_ne!(ep, fp2_const(1, &p), "independent basis ⟹ a nontrivial (primitive 5th root) pairing");
1049        let (fp_, fq_) = (iso.eval(&pp), iso.eval(&qq));
1050        assert!(iso.codomain.is_on_curve(&fp_) && iso.codomain.is_on_curve(&fq_), "images land on the codomain");
1051        let eq = weil_pairing2(&iso.codomain, &fp_, &fq_, 5).expect("image pairing");
1052        assert_eq!(eq, fp2_pow(&ep, iso.degree, &p), "e_5(φP,φQ) = e_5(P,Q)^{{deg φ}} over 𝔽_{{p²}}");
1053    }
1054
1055    // A SIDH-scale supersingular curve over 𝔽_{107²}: y²=x³+x (j=1728), #E=(p+1)²=108², so E[3³] and E[2²]
1056    // are both rank-2 rational — a genuine 27-kernel keyspace with the order-4 automorphism ι acting on it.
1057    fn sidh_scale_setup() -> (BigInt, Curve2, (Point2, Point2), (Point2, Point2)) {
1058        let p = big("107");
1059        let e0 = Curve2::new(fp2_const(1, &p), fp2_const(0, &p), p.clone());
1060        let basis_a = full_order_basis2(&e0, 3, 3).expect("rank-2 E[3³] basis");
1061        let basis_b = full_order_basis2(&e0, 2, 2).expect("rank-2 E[2²] basis");
1062        (p, e0, basis_a, basis_b)
1063    }
1064
1065    #[test]
1066    fn images_reconstruct_the_secret_generator_at_sidh_scale() {
1067        let (_p, e0, (pa, qa), (pb, qb)) = sidh_scale_setup();
1068        // Secret scalar → kernel generator → 3³-isogeny → published torsion images.
1069        let secret = ib(7);
1070        let gen = kernel_generator2(&e0, &pa, &qa, &secret);
1071        assert_eq!(point_order2(&e0, &gen, 28), Some(27), "the secret kernel generates order 3³");
1072        let path = derive_isogeny_path2(&e0, &gen, 3, 3).expect("the secret 3³-isogeny");
1073        let images =
1074            (push_through2(&path, 3, &pb).expect("push P_B"), push_through2(&path, 3, &qb).expect("push Q_B"));
1075        // Invert: recover a generator from ONLY (E₀, the two bases, the images).
1076        let (rs, rgen, rpath) =
1077            recover_secret2(&e0, (&pa, &qa), 3, 3, (&pb, &qb), (&images.0, &images.1)).expect("recovery");
1078        // The recovered isogeny reproduces the published images and lands on the same codomain — a genuine
1079        // images → generator inversion. (The scalar may be symmetry-equivalent to the planted one, not
1080        // identical: that is exactly the Aut-orbit structure exercised below.)
1081        assert_eq!(push_through2(&rpath, 3, &pb).unwrap(), images.0, "recovered chain reproduces φ(P_B)");
1082        assert_eq!(push_through2(&rpath, 3, &qb).unwrap(), images.1, "recovered chain reproduces φ(Q_B)");
1083        assert_eq!(rpath.last().unwrap().codomain, path.last().unwrap().codomain, "and lands on the target E");
1084        assert_eq!(kernel_generator2(&e0, &pa, &qa, &rs), rgen, "the reported scalar yields the reported gen");
1085        let _ = gen; // the planted generator is one valid preimage among its orbit
1086    }
1087
1088    #[test]
1089    fn the_structured_tree_recovery_matches_the_flat_oracle() {
1090        let (_p, e0, (pa, qa), (pb, qb)) = sidh_scale_setup();
1091        let secret = ib(11);
1092        let gen = kernel_generator2(&e0, &pa, &qa, &secret);
1093        let path = derive_isogeny_path2(&e0, &gen, 3, 3).unwrap();
1094        let images = (push_through2(&path, 3, &pb).unwrap(), push_through2(&path, 3, &qb).unwrap());
1095        // The ℓ-adic tree recursion recovers a generator digit by digit, walking the keyspace partition.
1096        let (digits, rs, rgen) =
1097            recover_secret_recursive2(&e0, (&pa, &qa), 3, 3, (&pb, &qb), (&images.0, &images.1)).expect("tree");
1098        assert_eq!(digits.len(), 3, "one ℓ-adic digit per isogeny step");
1099        assert!(digits.iter().all(|&d| d < 3), "each digit lies in 0..ℓ");
1100        // The reported scalar is exactly the digit reconstruction s = Σ dₖ·ℓᵏ.
1101        let s_check: i64 = digits.iter().enumerate().map(|(k, &d)| d as i64 * 3i64.pow(k as u32)).sum();
1102        assert_eq!(rs, ib(s_check), "the reported scalar is the ℓ-adic digit reconstruction");
1103        // And it reproduces the images on the same codomain — the tree walk inverts, like the flat oracle.
1104        let rpath = derive_isogeny_path2(&e0, &rgen, 3, 3).unwrap();
1105        assert_eq!(push_through2(&rpath, 3, &pb).unwrap(), images.0, "the recovered generator reproduces φ(P_B)");
1106        assert_eq!(push_through2(&rpath, 3, &qb).unwrap(), images.1, "and φ(Q_B)");
1107        assert_eq!(rpath.last().unwrap().codomain, path.last().unwrap().codomain, "and lands on the target E");
1108        let _ = secret; // planted secret is a valid solution; the tree may return a symmetry-equivalent one
1109    }
1110
1111    #[test]
1112    fn the_keyspace_partitions_into_aut_orbits_so_one_recovery_yields_the_orbit() {
1113        let (p, e0, (pa, qa), _b) = sidh_scale_setup();
1114        // Partition the 27-kernel keyspace by codomain j-invariant.
1115        let classes = keyspace_codomain_classes(&e0, (&pa, &qa), 3, 3);
1116        let total: usize = classes.iter().map(|(_, ss)| ss.len()).sum();
1117        assert_eq!(total, 27, "the classes partition the whole keyspace");
1118        assert!(classes.len() < 27, "the keyspace COLLAPSES — kernels share codomains ⟹ nontrivial symmetry");
1119        // The rule, verified across the entire keyspace: E₀/⟨K⟩ ≅ E₀/⟨ι(K)⟩. So if ONE kernel is invertible
1120        // (its codomain is the target E), its whole ι-orbit maps to curves ≅ E — recovering one recovers the
1121        // orbit. This is symmetry forcing the answer out, the same lever as our SAT clause-orbit quotients.
1122        for s in 0..27u64 {
1123            let gen = kernel_generator2(&e0, &pa, &qa, &ib(s as i64));
1124            let j_gen = derive_isogeny_path2(&e0, &gen, 3, 3).unwrap().last().unwrap().codomain.j_invariant();
1125            let igen = aut_1728(&p, &gen);
1126            assert!(e0.is_on_curve(&igen), "ι(K) lands back on E₀");
1127            assert_eq!(point_order2(&e0, &igen, 28), Some(27), "ι preserves the order-27 kernel");
1128            let j_igen = derive_isogeny_path2(&e0, &igen, 3, 3).unwrap().last().unwrap().codomain.j_invariant();
1129            assert_eq!(j_gen, j_igen, "ι(K) shares K's codomain j — the orbit shares its target curve");
1130        }
1131    }
1132
1133    #[test]
1134    fn the_graph_reachability_oracle_prunes_the_tree_to_the_aut_orbit() {
1135        let (_p, e0, (pa, qa), (pb, qb)) = sidh_scale_setup();
1136        let secret = ib(11);
1137        let gen = kernel_generator2(&e0, &pa, &qa, &secret);
1138        let path = derive_isogeny_path2(&e0, &gen, 3, 3).unwrap();
1139        let target = path.last().unwrap().codomain.clone(); // the PUBLIC codomain E
1140        let images = (push_through2(&path, 3, &pb).unwrap(), push_through2(&path, 3, &qb).unwrap());
1141
1142        // Backward BFS maps the reachable neighbourhood of E in the supersingular ℓ-isogeny graph.
1143        let dists = isogeny_graph_distances(&target, 3, 3);
1144        assert!(dists.len() > 1, "the ℓ-isogeny graph around E has genuine structure (a Ramanujan expander)");
1145        assert!(dists.iter().any(|(_, d)| *d == 0), "E sits at distance 0 from itself");
1146
1147        // Oracle-pruned recovery still inverts (sound: the true path is never pruned) — reproduces the images
1148        // and lands on E.
1149        let (_rs, rgen, leaves) =
1150            recover_secret_pruned2(&e0, (&pa, &qa), 3, 3, (&pb, &qb), (&images.0, &images.1), &target)
1151                .expect("pruned recovery");
1152        let rpath = derive_isogeny_path2(&e0, &rgen, 3, 3).unwrap();
1153        assert_eq!(push_through2(&rpath, 3, &pb).unwrap(), images.0, "pruned recovery reproduces φ(P_B)");
1154        assert_eq!(push_through2(&rpath, 3, &qb).unwrap(), images.1, "and φ(Q_B)");
1155        assert_eq!(rpath.last().unwrap().codomain, target, "and lands on the target E");
1156
1157        // The oracle confines leaf image-tests to the target's Aut-orbit: the reachability prune and the
1158        // symmetry collapse are the SAME cut. Only orbit members (same codomain j as E) can survive to a leaf.
1159        let classes = keyspace_codomain_classes(&e0, (&pa, &qa), 3, 3);
1160        let tj = target.j_invariant().unwrap();
1161        let orbit_size = classes.iter().find(|(j, _)| *j == tj).map(|(_, ss)| ss.len()).unwrap();
1162        assert!(orbit_size < 27, "the keyspace collapses to a strictly smaller orbit");
1163        assert!(leaves <= orbit_size, "the graph oracle leaf-tests only the target's Aut-orbit, not all 27");
1164    }
1165}