Skip to main content

p3_binary_field/aes/
frobenius.rs

1//! The Frobenius orbit and linearized polynomials over the AES field.
2//!
3//! Squaring is additive in characteristic 2, so every power of it is an `F_2`-linear map.
4//!
5//! So is any sum `c_0 x + c_1 x^2 + c_2 x^4 + ...`, which is what linearized means.
6//!
7//! Tabulated as a matrix, any of them costs one instruction per register.
8//!
9//! Evaluated term by term instead, each element costs a chain of squarings and products.
10
11use serde::{Deserialize, Serialize};
12
13use super::engine::ByteMatrix;
14use super::{Rijndael8b, mul_bytes};
15
16/// The number of distinct powers of squaring, which is the field's degree over `GF(2)`.
17const ORBIT: usize = 8;
18
19/// The squaring map, as a matrix.
20const SQUARE: ByteMatrix = {
21    let mut images = [0u8; 8];
22    let mut b = 0;
23    while b < 8 {
24        images[b] = mul_bytes(1 << b, 1 << b);
25        b += 1;
26    }
27    ByteMatrix::from_images(images)
28};
29
30/// The map raising to `2^k`, for every `k` below the field's degree.
31///
32/// Squaring has order eight as a map, so entry zero is the identity and the orbit closes.
33const FROBENIUS: [ByteMatrix; ORBIT] = {
34    let mut maps = [ByteMatrix::IDENTITY; ORBIT];
35    let mut k = 1;
36    while k < ORBIT {
37        maps[k] = SQUARE.compose(maps[k - 1]);
38        k += 1;
39    }
40    maps
41};
42
43impl Rijndael8b {
44    /// The map raising every element to the power `2^k`, for `k` the argument.
45    ///
46    /// The orbit closes after eight steps, so the argument is taken modulo that.
47    pub const fn frobenius_map(power_log: usize) -> ByteMatrix {
48        FROBENIUS[power_log % ORBIT]
49    }
50
51    /// This element raised to `2^k`, for every `k` below the field's degree.
52    ///
53    /// These are the element's conjugates with multiplicity, so a subfield element repeats.
54    pub fn frobenius_orbit(self) -> [Self; ORBIT] {
55        core::array::from_fn(|k| Self::from_byte(FROBENIUS[k].apply(self.to_byte())))
56    }
57}
58
59/// An `F_2`-linear map written as `sum_j c_j x^(2^j)`.
60///
61/// Every `F_2`-linear map on the field has exactly one such form.
62///
63/// Both sides are 64-dimensional over `GF(2)`, and the correspondence is injective.
64///
65/// Held as coefficients rather than as a matrix, because that is the form a protocol states a
66/// Frobenius-twisted weight in.
67///
68/// The width in the name is the field's, since the form is specific to it.
69#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(transparent)]
71#[repr(transparent)]
72#[must_use]
73pub struct LinearizedPoly8b([Rijndael8b; ORBIT]);
74
75impl LinearizedPoly8b {
76    /// The map with the given coefficients, in order of increasing squaring power.
77    pub const fn new(coefficients: [Rijndael8b; ORBIT]) -> Self {
78        Self(coefficients)
79    }
80
81    /// The coefficients, in order of increasing squaring power.
82    pub const fn coefficients(&self) -> [Rijndael8b; ORBIT] {
83        self.0
84    }
85
86    /// The value at one point, summed term by term.
87    ///
88    /// This is the definition, kept for a single point and for checking the tabulated form.
89    pub fn eval(&self, x: Rijndael8b) -> Rijndael8b {
90        let orbit = x.frobenius_orbit();
91        self.0
92            .iter()
93            .zip(orbit)
94            .fold(Rijndael8b::from_byte(0), |acc, (&c, power)| acc + c * power)
95    }
96
97    /// The same map, tabulated for the byte-wise engine.
98    ///
99    /// # Algorithm
100    ///
101    /// A linear map is fixed by the images of the eight basis vectors.
102    ///
103    /// Each image is that vector's own Frobenius orbit summed against the coefficients.
104    ///
105    /// ```text
106    ///     image(v)  =  sum over j of  c_j * v^(2^j)
107    /// ```
108    pub const fn to_matrix(&self) -> ByteMatrix {
109        let mut images = [0u8; 8];
110        let mut b = 0;
111        while b < 8 {
112            let vector = 1u8 << b;
113            let mut sum = 0u8;
114            let mut j = 0;
115            while j < ORBIT {
116                sum ^= mul_bytes(self.0[j].to_byte(), FROBENIUS[j].apply(vector));
117                j += 1;
118            }
119            images[b] = sum;
120            b += 1;
121        }
122        ByteMatrix::from_images(images)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use alloc::vec::Vec;
129
130    use p3_field::{Field, PackedValue, PrimeCharacteristicRing};
131    use proptest::prelude::*;
132
133    use super::{FROBENIUS, LinearizedPoly8b, ORBIT};
134    use crate::aes::mul_bytes;
135    use crate::{PackedRijndael8b, Rijndael8b};
136
137    /// The `k`-th power of squaring, by squaring `k` times through the field product.
138    ///
139    /// The tabulated maps are built by composing matrices instead.
140    ///
141    /// The two therefore share nothing beyond the field product.
142    fn repeated_square(mut byte: u8, count: usize) -> u8 {
143        for _ in 0..count {
144            byte = mul_bytes(byte, byte);
145        }
146        byte
147    }
148
149    #[test]
150    fn the_tabulated_orbit_matches_repeated_squaring() {
151        // Exhaustive over the field: 8 maps by 256 inputs settles every case.
152        for (k, map) in FROBENIUS.iter().enumerate() {
153            for byte in 0..=u8::MAX {
154                assert_eq!(map.apply(byte), repeated_square(byte, k), "power {k}");
155            }
156        }
157    }
158
159    #[test]
160    fn the_orbit_closes_and_starts_at_the_identity() {
161        // Invariant: squaring has order 8 as a map, since the field has 2^8 elements.
162        assert_eq!(FROBENIUS[0], crate::ByteMatrix::IDENTITY);
163        for byte in 0..=u8::MAX {
164            assert_eq!(repeated_square(byte, ORBIT), byte, "{byte:#x}");
165        }
166
167        // The orbit of an element is its conjugate set, starting from the element itself.
168        let x = Rijndael8b::from_byte(0x53);
169        let orbit = x.frobenius_orbit();
170        assert_eq!(orbit[0], x);
171        for k in 1..ORBIT {
172            assert_eq!(orbit[k], orbit[k - 1].square(), "power {k}");
173        }
174    }
175
176    #[test]
177    fn the_frobenius_fixes_only_the_prime_subfield() {
178        // Invariant: x^2 = x holds exactly on the roots of `x^2 + x`, which are 0 and 1.
179        let fixed: Vec<u8> = (0..=u8::MAX)
180            .filter(|&b| Rijndael8b::frobenius_map(1).apply(b) == b)
181            .collect();
182        assert_eq!(fixed, alloc::vec![0, 1]);
183    }
184
185    #[test]
186    fn the_square_root_is_the_seventh_power_of_squaring() {
187        // Invariant: squaring has order 8, so its inverse is its seventh power.
188        for byte in 0..=u8::MAX {
189            let x = Rijndael8b::from_byte(byte);
190            let root = x
191                .try_sqrt()
192                .expect("every element of a binary field is a square");
193            assert_eq!(root.square(), x, "{byte:#x}");
194            assert_eq!(root.to_byte(), Rijndael8b::frobenius_map(7).apply(byte));
195        }
196    }
197
198    proptest! {
199        #![proptest_config(ProptestConfig::with_cases(512))]
200
201        /// The tabulated map must agree with summing the linearized terms at every point.
202        #[test]
203        fn the_tabulated_linearized_map_matches_its_definition(coefficients: [u8; ORBIT], x: u8) {
204            let poly = LinearizedPoly8b::new(coefficients.map(Rijndael8b::from_byte));
205            let point = Rijndael8b::from_byte(x);
206            prop_assert_eq!(poly.to_matrix().apply(x), poly.eval(point).to_byte());
207        }
208
209        /// A linearized polynomial is additive, which is the whole point of the form.
210        #[test]
211        fn a_linearized_polynomial_is_additive(coefficients: [u8; ORBIT], x: u8, y: u8) {
212            let poly = LinearizedPoly8b::new(coefficients.map(Rijndael8b::from_byte));
213            let (a, b) = (Rijndael8b::from_byte(x), Rijndael8b::from_byte(y));
214            prop_assert_eq!(poly.eval(a + b), poly.eval(a) + poly.eval(b));
215        }
216
217        /// A single constant coefficient must be scaling, which is tabulated elsewhere.
218        ///
219        /// Neither the coefficient order nor the exponent of the orbit reaches this route.
220        #[test]
221        fn one_constant_coefficient_is_scaling(c: u8) {
222            let scalar = Rijndael8b::from_byte(c);
223            let mut coefficients = [Rijndael8b::ZERO; ORBIT];
224            coefficients[0] = scalar;
225            prop_assert_eq!(
226                LinearizedPoly8b::new(coefficients).to_matrix(),
227                scalar.scaling_matrix()
228            );
229        }
230
231        /// The packed sweep must agree with the scalar map, position by position.
232        #[test]
233        fn the_packed_sweep_matches_the_scalar_map(
234            coefficients: [u8; ORBIT],
235            values in prop::collection::vec(any::<u8>(), 64),
236            power_log in 0usize..16,
237        ) {
238            let poly = LinearizedPoly8b::new(coefficients.map(Rijndael8b::from_byte));
239            let block: PackedRijndael8b<64> =
240                PackedValue::from_fn(|i| Rijndael8b::from_byte(values[i]));
241
242            let expected: PackedRijndael8b<64> =
243                PackedValue::from_fn(|i| poly.eval(Rijndael8b::from_byte(values[i])));
244            prop_assert_eq!(block.apply(poly.to_matrix()), expected);
245
246            let expected: PackedRijndael8b<64> = PackedValue::from_fn(|i| {
247                Rijndael8b::from_byte(repeated_square(values[i], power_log % ORBIT))
248            });
249            prop_assert_eq!(block.frobenius(power_log), expected);
250
251            // The packed override must match the scalar one it mirrors.
252            prop_assert_eq!(block.exp_power_of_2(power_log), expected);
253        }
254    }
255
256    #[test]
257    fn a_single_unit_coefficient_is_one_map_of_the_orbit() {
258        // Invariant: with `c_j = 1` and every other coefficient zero the sum is `x^(2^j)`.
259        //
260        // A reversed coefficient order would place the map at `7 - j` instead.
261        for j in 0..ORBIT {
262            let mut coefficients = [Rijndael8b::ZERO; ORBIT];
263            coefficients[j] = Rijndael8b::ONE;
264            assert_eq!(
265                LinearizedPoly8b::new(coefficients).to_matrix(),
266                Rijndael8b::frobenius_map(j),
267                "term {j}"
268            );
269        }
270    }
271
272    #[test]
273    fn a_known_answer_with_coefficients_outside_the_prime_subfield() {
274        // A reversed coefficient order and a `c_j^(2^j)` misreading agree on `GF(2)`.
275        //
276        // So the weight below has four coefficients that are neither zero nor one.
277        //
278        // Fixture state: `0x02 x + 0x03 x^2 + 0x8d x^8 + 0x1f x^64`, evaluated elsewhere.
279        //
280        // - x = 0x01 gives 0x93, and x = 0x53 gives 0xa2,
281        // - x = 0xff gives 0x34, and x = 0x80 gives 0x30.
282        let poly = LinearizedPoly8b::new(
283            [0x02, 0x03, 0x00, 0x8d, 0x00, 0x00, 0x1f, 0x00].map(Rijndael8b::from_byte),
284        );
285        for (x, want) in [(0x01, 0x93), (0x53, 0xa2), (0xff, 0x34), (0x80, 0x30)] {
286            assert_eq!(
287                poly.eval(Rijndael8b::from_byte(x)).to_byte(),
288                want,
289                "{x:#04x}"
290            );
291            assert_eq!(poly.to_matrix().apply(x), want, "{x:#04x}");
292        }
293    }
294
295    #[test]
296    fn repeated_squaring_matches_the_reference_at_every_exponent() {
297        // Invariant: the orbit closes after eight steps, so the exponent reduces modulo eight.
298        //
299        // The bound sweeps one full turn and a little past it.
300        //
301        // The largest index a caller can pass is covered separately.
302        for byte in [0u8, 1, 2, 0x1b, 0x80, 0x53, u8::MAX] {
303            let x = Rijndael8b::from_byte(byte);
304            for power_log in 0..=(2 * ORBIT + 3) {
305                assert_eq!(
306                    x.exp_power_of_2(power_log).to_byte(),
307                    repeated_square(byte, power_log % ORBIT),
308                    "{byte:#04x} at {power_log}"
309                );
310            }
311            assert_eq!(
312                x.exp_power_of_2(usize::MAX),
313                x.exp_power_of_2(usize::MAX % ORBIT),
314                "{byte:#04x} at the largest exponent"
315            );
316        }
317    }
318
319    /// A worked example small enough to check by hand.
320    ///
321    /// The polynomial `x + x^2` is the Artin-Schreier map, whose kernel is the prime subfield.
322    #[test]
323    fn a_hand_checkable_linearized_polynomial() {
324        let one = Rijndael8b::ONE;
325        let zero = Rijndael8b::ZERO;
326        let poly = LinearizedPoly8b::new([one, one, zero, zero, zero, zero, zero, zero]);
327
328        // Fixture state: the map sends `x` to `x + x^2`.
329        assert_eq!(poly.eval(zero), zero);
330        assert_eq!(poly.eval(one), zero);
331
332        // x = 2: 2 + 4 = 6, since squaring the polynomial variable is still below the modulus.
333        assert_eq!(poly.eval(Rijndael8b::from_byte(2)).to_byte(), 6);
334
335        // The tabulated form must be the same map.
336        assert_eq!(poly.to_matrix().apply(2), 6);
337    }
338
339    #[test]
340    fn the_coefficients_round_trip_through_serde() {
341        // A protocol states a twisted weight in this form, so the encoding has to carry it.
342        let poly = LinearizedPoly8b::new(
343            [0x02, 0x03, 0x00, 0x8d, 0x00, 0x00, 0x1f, 0x00].map(Rijndael8b::from_byte),
344        );
345        let encoded = serde_json::to_string(&poly).unwrap();
346        assert_eq!(
347            serde_json::from_str::<LinearizedPoly8b>(&encoded).unwrap(),
348            poly
349        );
350        assert_eq!(poly.coefficients()[3].to_byte(), 0x8d);
351    }
352}