qfall_tools/primitive/psf/
mp_perturbation.rs

1// Copyright © 2025 Niklas Siemer
2//
3// This file is part of qFALL-tools.
4//
5// qFALL-tools is free software: you can redistribute it and/or modify it under
6// the terms of the Mozilla Public License Version 2.0 as published by the
7// Mozilla Foundation. See <https://mozilla.org/en-US/MPL/2.0/>.
8
9//! Implements a [`PSF`] based on perturbation sampling desribed in [\[1\]](<../index.html#:~:text=[1]>)
10//! using G-trapdoors.
11
12use super::PSF;
13use crate::sample::g_trapdoor::{
14    gadget_classical::short_basis_gadget,
15    gadget_classical::{find_solution_gadget_mat, gen_trapdoor},
16    gadget_parameters::GadgetParameters,
17};
18use qfall_math::{
19    integer::{MatZ, Z},
20    integer_mod_q::MatZq,
21    rational::{MatQ, Q},
22    traits::{Concatenate, MatrixDimensions, Pow},
23};
24use serde::{Deserialize, Serialize};
25
26/// A lattice-based implementation of a [`PSF`] according to
27/// [\[1\]](<index.html#:~:text=[1]>) using
28/// G-Trapdoors where D_n = {e ∈ Z^m | |e| <= s sqrt(m)}
29/// and R_n = Z_q^n.
30///
31/// Attributes
32/// - `gp`: Describes the gadget parameters with which the G-Trapdoor is generated
33/// - `r`: The rounding parameter
34/// - `s`: The Gaussian parameter with which is sampled
35///
36/// # Examples
37/// ```
38/// use qfall_tools::primitive::psf::PSFPerturbation;
39/// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
40/// use qfall_math::rational::Q;
41/// use qfall_tools::primitive::psf::PSF;
42///
43/// let psf = PSFPerturbation {
44///     gp: GadgetParameters::init_default(8, 64),
45///     r: Q::from(3),
46///     s: Q::from(25),
47/// };
48///
49/// let (a, td) = psf.trap_gen();
50/// let domain_sample = psf.samp_d();
51/// let range_fa = psf.f_a(&a, &domain_sample);
52/// let preimage = psf.samp_p(&a, &td, &range_fa);
53///
54/// assert!(psf.check_domain(&preimage));
55/// assert_eq!(a * preimage, range_fa);
56/// ```
57#[derive(Serialize, Deserialize)]
58pub struct PSFPerturbation {
59    pub gp: GadgetParameters,
60    pub r: Q,
61    pub s: Q,
62}
63
64impl PSFPerturbation {
65    /// Computes √Σ_2 = √(r^2 * (b^2 + 1) * [R^t | I]^t * [R^t | I] - r^2 * I)
66    /// to perform non-spherical Gaussian sampling according to Algorithm 1 in
67    /// [\[3\]](<index.html#:~:text=[3]>).
68    /// This matrix is the second part of the secret key and needs to be precomputed
69    /// to execute [PSFPerturbation::samp_p].
70    /// [PSFPerturbation::trap_gen] outputs this matrix for `s^2 * I`, i.e. for discrete
71    /// Gaussian preimages centered around `0`. This function enables changing the
72    /// covariance matrix to any covariance matrix s.t. Σ_2 is positive definite.
73    ///
74    /// Parameters:
75    /// - `mat_r`: The trapdoor matrix `R`
76    /// - `mat_sigma`: The covariance matrix `Σ` to sample [`Self::samp_p`] with
77    ///
78    /// Returns a [`MatQ`] containing √Σ_2 = √(r^2 * (b^2 + 1) * [R^t | I]^t * [R^t | I] - r^2 * I)
79    /// if Σ_2 was positive definite.
80    ///
81    /// # Examples
82    /// ```
83    /// use qfall_tools::primitive::psf::PSFPerturbation;
84    /// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
85    /// use qfall_math::rational::{Q, MatQ};
86    /// use qfall_tools::primitive::psf::PSF;
87    /// use qfall_math::traits::*;
88    ///
89    /// let psf = PSFPerturbation {
90    ///     gp: GadgetParameters::init_default(8, 64),
91    ///     r: Q::from(3),
92    ///     s: Q::from(25),
93    /// };
94    /// let different_s: f64 = 35.0;
95    ///
96    /// let (a, td) = psf.trap_gen();
97    ///
98    /// let cov_mat = different_s.powi(2) * MatQ::identity(a.get_num_columns(), a.get_num_columns());
99    /// let mat_sqrt_sigma_2 = psf.compute_sqrt_sigma_2(&td.0, &cov_mat);
100    /// let new_td = (td.0, mat_sqrt_sigma_2, td.2);
101    ///
102    /// let domain_sample = psf.samp_d();
103    /// let range_fa = psf.f_a(&a, &domain_sample);
104    /// let preimage = psf.samp_p(&a, &new_td, &range_fa);
105    ///
106    /// assert!(psf.check_domain(&preimage));
107    /// ```
108    ///
109    /// # Panics ...
110    /// - if Σ_2 is not positive definite.
111    pub fn compute_sqrt_sigma_2(&self, mat_r: &MatZ, mat_sigma: &MatQ) -> MatQ {
112        // Normalization factor according to MP12, Section 2.3
113        let normalization_factor = 1.0 / (2.0 * Q::PI);
114
115        // full_td = [R^t | I]^t
116        let full_td = mat_r
117            .concat_vertical(&MatZ::identity(
118                mat_sigma.get_num_rows() - mat_r.get_num_rows(),
119                mat_r.get_num_columns(),
120            ))
121            .unwrap();
122
123        // Assemble Σ_p = Σ - [R^t|I]^t * Σ_G * [R^t|I] with √Σ_G ≥ η (Λ^⊥(G))
124        // and assumption Σ_G = (base^2 + 1) * I as ||S|| = √(b^2 + 1), Theorem 4.1, MP12
125        let mat_sigma_p: MatQ =
126            mat_sigma - (self.gp.base.pow(2).unwrap() + 1) * &full_td * full_td.transpose();
127
128        // r^2 * Σ_p <=> r * √Σ_p
129        // Compute Σ_2 according to the requirements of Algorithm 1 in [3]
130        // Assume Σ_1 = r^2 * B_1 * B_1^t with B_1 = I as basis for ZZ^n
131        // Then, Σ_2 = Σ - Σ_1, where Σ = r^2 * Σ_p
132        let sigma_2: MatQ = normalization_factor
133            * self.r.pow(2).unwrap()
134            * (&mat_sigma_p
135                - MatQ::identity(mat_sigma_p.get_num_rows(), mat_sigma_p.get_num_columns()));
136
137        // Compute √Σ_2
138        sigma_2.cholesky_decomposition_flint()
139    }
140}
141
142/// Samples a preimage with respect to the gadget matrix `G` of `vec_u`.
143///
144/// Parameters:
145/// - `psf`: The [`PSFPerturbation`] that sets the parameters for this function
146/// - `vec_u`: The target vector
147/// - `short_basis_gadget`: The short basis of the corresponding gadget-matrix - just required to speed up the algorithm
148/// - `short_basis_gadget_gso`: The GSO of the short basis of the gadget-matrix - just required to speed up the algorithm
149///
150/// Returns a discrete Gaussian sampled preimage of `u` under `G` with Gaussian parameter `r * √(b^2 + 1)`,
151/// where `b` is the base used for the G-trapdoor.
152///
153/// # Examples
154/// ```compile_fail
155/// use qfall_tools::primitive::psf::PSFPerturbation;
156/// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
157/// use qfall_tools::sample::g_trapdoor::gadget_classical::short_basis_gadget;
158/// use qfall_math::rational::Q;
159/// use qfall_tools::primitive::psf::PSF;
160///
161/// let psf = PSFPerturbation {
162///     gp: GadgetParameters::init_default(8, 64),
163///     r: Q::from(3),
164///     s: Q::from(25),
165/// };
166///
167/// let short_basis_g = short_basis_gadget(&psf.gp);
168/// let short_basis_g_gso = MatQ::from(&short_basis_g).gso();
169///
170/// let target = MatZq::sample_uniform(8, 1, 64);
171/// let preimage = randomized_nearest_plane_gadget(&osf, &target, &short_basis_g, &short_basis_g_gso);
172/// ```
173pub(crate) fn randomized_nearest_plane_gadget(
174    psf: &PSFPerturbation,
175    vec_u: &MatZq,
176    short_basis_gadget: &MatZ,
177    short_basis_gadget_gso: &MatQ,
178) -> MatZ {
179    // s = r * √(b^2 + 1) according to Algorithm 3 in [1]
180    let s = &psf.r * (psf.gp.base.pow(2).unwrap() + Z::ONE).sqrt();
181
182    // find solution s.t. G * long_solution = vec_u
183    let long_solution = find_solution_gadget_mat(vec_u, &psf.gp.k, &psf.gp.base);
184
185    let center = MatQ::from(&(-1 * &long_solution));
186
187    // just as PSFGPV::samp_p
188    long_solution
189        + MatZ::sample_d_precomputed_gso(short_basis_gadget, short_basis_gadget_gso, &center, &s)
190            .unwrap()
191}
192
193impl PSF for PSFPerturbation {
194    type A = MatZq;
195    type Trapdoor = (MatZ, MatQ, (MatZ, MatQ));
196    type Domain = MatZ;
197    type Range = MatZq;
198
199    /// Computes a G-Trapdoor according to the [`GadgetParameters`] defined in the struct.
200    /// It returns a matrix `A` together with the short trapdoor matrix `R` and a precomputed √Σ_2
201    /// for covariance matrix `s^2 * I`, i.e. for preimage sampling centered around `0`.
202    /// The last part of the trapdoor tuple contains a short basis for the gadget matrix `G` and its
203    /// GSO, which removes the need to recompute the GSO for each iteration and speeds up preimage sampling
204    /// drastically.
205    ///
206    /// # Examples
207    /// ```
208    /// use qfall_tools::primitive::psf::PSFPerturbation;
209    /// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
210    /// use qfall_math::rational::Q;
211    /// use qfall_tools::primitive::psf::PSF;
212    ///
213    /// let psf = PSFPerturbation {
214    ///     gp: GadgetParameters::init_default(8, 64),
215    ///     r: Q::from(3),
216    ///     s: Q::from(25),
217    /// };
218    ///
219    /// let (mat_a, (mat_r, mat_sqrt_sigma_2, (sh_b_g, sh_b_g_gso))) = psf.trap_gen();
220    /// ```
221    fn trap_gen(&self) -> (MatZq, (MatZ, MatQ, (MatZ, MatQ))) {
222        let mat_a_bar = MatZq::sample_uniform(&self.gp.n, &self.gp.m_bar, &self.gp.q);
223        let tag = MatZq::identity(&self.gp.n, &self.gp.n, &self.gp.q);
224
225        let (mat_a, mat_r) = gen_trapdoor(&self.gp, &mat_a_bar, &tag).unwrap();
226
227        let mat_sqrt_sigma_2 = self.compute_sqrt_sigma_2(
228            &mat_r,
229            &(&self.s.pow(2).unwrap()
230                * MatQ::identity(mat_a.get_num_columns(), mat_a.get_num_columns())),
231        );
232
233        let short_basis_gadget = short_basis_gadget(&self.gp);
234        let short_basis_gadget_gso = MatQ::from(&short_basis_gadget).gso();
235
236        (
237            mat_a,
238            (
239                mat_r,
240                mat_sqrt_sigma_2,
241                (short_basis_gadget, short_basis_gadget_gso),
242            ),
243        )
244    }
245
246    /// Samples in the domain using SampleD with the standard basis and center `0`.
247    ///
248    /// # Examples
249    /// ```
250    /// use qfall_tools::primitive::psf::PSFPerturbation;
251    /// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
252    /// use qfall_math::rational::Q;
253    /// use qfall_tools::primitive::psf::PSF;
254    ///
255    /// let psf = PSFPerturbation {
256    ///     gp: GadgetParameters::init_default(8, 64),
257    ///     r: Q::from(3),
258    ///     s: Q::from(25),
259    /// };
260    /// let (mat_a, td) = psf.trap_gen();
261    ///
262    /// let domain_sample = psf.samp_d();
263    /// ```
264    fn samp_d(&self) -> MatZ {
265        let m = &self.gp.n * &self.gp.k + &self.gp.m_bar;
266        MatZ::sample_discrete_gauss(m, 1, 0, &self.s * &self.r).unwrap()
267    }
268
269    /// Samples an `e` in the domain using SampleD that is generated
270    /// from the G-Trapdoor from the conditioned
271    /// discrete Gaussian with `f_a(a,e) = u` for a provided syndrome `u`.
272    ///
273    /// *Note*: the provided parameters `mat_a, mat_r, vec_u` must fit together,
274    /// otherwise unexpected behavior such as panics may occur.
275    ///
276    /// Parameters:
277    /// - `mat_a`: The parity-check matrix
278    /// - `mat_r`: The short trapdoor matrix `R`
279    /// - `mat_sqrt_sigma_2`: The precomputed √Σ_2
280    /// - `vec_u`: The syndrome from the range
281    ///
282    /// Returns a sample `e` from the domain on the conditioned discrete
283    /// Gaussian distribution `f_a(a,e) = u` with covariance matrix Σ depending on `mat_sqrt_sigma_2`.
284    ///
285    /// # Examples
286    /// ```
287    /// use qfall_tools::primitive::psf::PSFPerturbation;
288    /// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
289    /// use qfall_math::rational::Q;
290    /// use qfall_tools::primitive::psf::PSF;
291    ///
292    /// let psf = PSFPerturbation {
293    ///     gp: GadgetParameters::init_default(8, 64),
294    ///     r: Q::from(3),
295    ///     s: Q::from(25),
296    /// };
297    /// let (mat_a, td) = psf.trap_gen();
298    /// let domain_sample = psf.samp_d();
299    /// let range_fa = psf.f_a(&mat_a, &domain_sample);
300    ///
301    /// let preimage = psf.samp_p(&mat_a, &td, &range_fa);
302    /// assert_eq!(range_fa, psf.f_a(&mat_a, &preimage))
303    /// ```
304    fn samp_p(
305        &self,
306        mat_a: &MatZq,
307        (mat_r, mat_sqrt_sigma_2, (short_basis_gadget, short_basis_gadget_gso)): &(
308            MatZ,
309            MatQ,
310            (MatZ, MatQ),
311        ),
312        vec_u: &MatZq,
313    ) -> MatZ {
314        // Sample perturbation p <- D_{ZZ^m, r * √Σ_2}
315        let vec_p = MatZ::sample_d_common_non_spherical(mat_sqrt_sigma_2, &self.r).unwrap();
316
317        // v = u - A * p
318        let vec_v = vec_u - mat_a * &vec_p;
319
320        // z <- D_{Λ_v^⊥(G), r * √Σ_G}
321        let vec_z = randomized_nearest_plane_gadget(
322            self,
323            &vec_v,
324            short_basis_gadget,
325            short_basis_gadget_gso,
326        );
327
328        let full_td = mat_r
329            .concat_vertical(&MatZ::identity(
330                mat_r.get_num_columns(),
331                mat_r.get_num_columns(),
332            ))
333            .unwrap();
334
335        vec_p + full_td * vec_z
336    }
337
338    /// Implements the efficiently computable function `f_a` which here corresponds to
339    /// `mat_a * sigma`. The sigma must be from the domain, i.e. D_n.
340    ///
341    /// Parameters:
342    /// - `mat_a`: The parity-check matrix of dimensions `n x m`
343    /// - `sigma`: A column vector of length `m`
344    ///
345    /// Returns `mat_a * sigma`.
346    ///
347    /// # Examples
348    /// ```
349    /// use qfall_tools::primitive::psf::PSFPerturbation;
350    /// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
351    /// use qfall_math::rational::Q;
352    /// use qfall_tools::primitive::psf::PSF;
353    ///
354    /// let psf = PSFPerturbation {
355    ///     gp: GadgetParameters::init_default(8, 64),
356    ///     r: Q::from(3),
357    ///     s: Q::from(25),
358    /// };
359    /// let (mat_a, td) = psf.trap_gen();
360    /// let domain_sample = psf.samp_d();
361    /// let range_fa = psf.f_a(&mat_a, &domain_sample);
362    /// ```
363    ///
364    /// # Panics ...
365    /// - if `sigma` is not in the domain.
366    fn f_a(&self, mat_a: &MatZq, sigma: &MatZ) -> MatZq {
367        assert!(self.check_domain(sigma));
368        mat_a * sigma
369    }
370
371    /// Checks whether a value `sigma` is in D_n = {e ∈ Z^m | |e| <= s * r * sqrt(m)}.
372    ///
373    /// Parameters:
374    /// - `sigma`: The value for which is checked, if it is in the domain
375    ///
376    /// Returns true, if `sigma` is in D_n.
377    ///
378    /// # Examples
379    /// ```
380    /// use qfall_tools::primitive::psf::PSF;
381    /// use qfall_tools::primitive::psf::PSFPerturbation;
382    /// use qfall_tools::sample::g_trapdoor::gadget_parameters::GadgetParameters;
383    /// use qfall_math::rational::Q;
384    ///
385    /// let psf = PSFPerturbation {
386    ///     gp: GadgetParameters::init_default(8, 64),
387    ///     r: Q::from(3),
388    ///     s: Q::from(25),
389    /// };
390    /// let (mat_a, td) = psf.trap_gen();
391    ///
392    /// let vector = psf.samp_d();
393    ///
394    /// assert!(psf.check_domain(&vector));
395    /// ```
396    fn check_domain(&self, sigma: &MatZ) -> bool {
397        let m = &self.gp.n * &self.gp.k + &self.gp.m_bar;
398        sigma.is_column_vector()
399            && m == sigma.get_num_rows()
400            && sigma.norm_eucl_sqrd().unwrap()
401                <= self.s.pow(2).unwrap() * &m * &self.r.pow(2).unwrap()
402    }
403}
404
405#[cfg(test)]
406mod test_psf_perturbation {
407    use super::PSF;
408    use super::PSFPerturbation;
409    use crate::sample::g_trapdoor::gadget_parameters::GadgetParameters;
410    use qfall_math::integer::MatZ;
411    use qfall_math::rational::Q;
412    use qfall_math::traits::*;
413
414    /// Ensures that `samp_d` actually computes values that are in D_n.
415    #[test]
416    fn samp_d_samples_from_dn() {
417        for (n, q) in [(5, 256), (10, 128), (15, 157)] {
418            let psf = PSFPerturbation {
419                gp: GadgetParameters::init_default(n, q),
420                r: Q::from(n).log(2).unwrap(),
421                s: Q::from(25),
422            };
423
424            for _ in 0..5 {
425                assert!(psf.check_domain(&psf.samp_d()));
426            }
427        }
428    }
429
430    /// Ensures that `samp_p` actually computes preimages that are also in the correct
431    /// domain.
432    #[test]
433    fn samp_p_preimage_and_domain() {
434        for (n, q) in [(5, 256), (6, 128)] {
435            let psf = PSFPerturbation {
436                gp: GadgetParameters::init_default(n, q),
437                r: Q::from(n).log(2).unwrap(),
438                s: Q::from(25),
439            };
440            let (a, r) = psf.trap_gen();
441            let domain_sample = psf.samp_d();
442            let range_fa = psf.f_a(&a, &domain_sample);
443
444            let preimage = psf.samp_p(&a, &r, &range_fa);
445            assert_eq!(range_fa, psf.f_a(&a, &preimage));
446            assert!(psf.check_domain(&preimage));
447        }
448    }
449
450    /// Ensures that `f_a` returns `a * sigma`.
451    #[test]
452    fn f_a_works_as_expected() {
453        for (n, q) in [(5, 256), (6, 128)] {
454            let psf = PSFPerturbation {
455                gp: GadgetParameters::init_default(n, q),
456                r: Q::from(n).log(2).unwrap(),
457                s: Q::from(25),
458            };
459            let (a, _) = psf.trap_gen();
460            let domain_sample = psf.samp_d();
461
462            assert_eq!(&a * &domain_sample, psf.f_a(&a, &domain_sample));
463        }
464    }
465
466    /// Ensures that `f_a` panics if a value is provided, that is not within the domain.
467    /// Sigma is not a vector.
468    #[test]
469    #[should_panic]
470    fn f_a_sigma_not_in_domain_matrix() {
471        let psf = PSFPerturbation {
472            gp: GadgetParameters::init_default(8, 128),
473            r: Q::from(8).log(2).unwrap(),
474            s: Q::from(25),
475        };
476        let (a, _) = psf.trap_gen();
477        let not_in_domain = MatZ::new(a.get_num_columns(), 2);
478
479        let _ = psf.f_a(&a, &not_in_domain);
480    }
481
482    /// Ensures that `f_a` panics if a value is provided, that is not within the domain.
483    /// Sigma is not of the correct length.
484    #[test]
485    #[should_panic]
486    fn f_a_sigma_not_in_domain_incorrect_length() {
487        let psf = PSFPerturbation {
488            gp: GadgetParameters::init_default(8, 128),
489            r: Q::from(8).log(2).unwrap(),
490            s: Q::from(25),
491        };
492        let (a, _) = psf.trap_gen();
493        let not_in_domain = MatZ::new(a.get_num_columns() - 1, 1);
494
495        let _ = psf.f_a(&a, &not_in_domain);
496    }
497
498    /// Ensures that `f_a` panics if a value is provided, that is not within the domain.
499    /// Sigma is too long.
500    #[test]
501    #[should_panic]
502    fn f_a_sigma_not_in_domain_too_long() {
503        let psf = PSFPerturbation {
504            gp: GadgetParameters::init_default(8, 128),
505            r: Q::from(8).log(2).unwrap(),
506            s: Q::from(25),
507        };
508        let (a, _) = psf.trap_gen();
509        let not_in_domain =
510            psf.s.round() * a.get_num_columns() * MatZ::identity(a.get_num_columns(), 1);
511
512        let _ = psf.f_a(&a, &not_in_domain);
513    }
514
515    /// Ensures that `check_domain` works for vectors with the correct length.
516    #[test]
517    fn check_domain_as_expected() {
518        let psf = PSFPerturbation {
519            gp: GadgetParameters::init_default(8, 128),
520            r: Q::from(8).log(2).unwrap(),
521            s: Q::from(25),
522        };
523        let (a, _) = psf.trap_gen();
524        let value = psf.s.round();
525        let mut in_domain = MatZ::new(a.get_num_columns(), 1);
526        for i in 0..in_domain.get_num_rows() {
527            in_domain.set_entry(i, 0, &value).unwrap();
528        }
529
530        assert!(psf.check_domain(&MatZ::new(a.get_num_columns(), 1)));
531        assert!(psf.check_domain(&in_domain));
532    }
533
534    /// Ensures that `check_domain` returns false for values that are not in the domain.
535    #[test]
536    fn check_domain_not_in_dn() {
537        let psf = PSFPerturbation {
538            gp: GadgetParameters::init_default(8, 128),
539            r: Q::from(8).log(2).unwrap(),
540            s: Q::from(25),
541        };
542        let (a, _) = psf.trap_gen();
543
544        let matrix = MatZ::new(a.get_num_columns(), 2);
545        let too_short = MatZ::new(a.get_num_columns() - 1, 1);
546        let too_long = MatZ::new(a.get_num_columns() + 1, 1);
547        let entry_too_large =
548            psf.s.round() * a.get_num_columns() * MatZ::identity(a.get_num_columns(), 1);
549
550        assert!(!psf.check_domain(&matrix));
551        assert!(!psf.check_domain(&too_long));
552        assert!(!psf.check_domain(&too_short));
553        assert!(!psf.check_domain(&entry_too_large));
554    }
555}