Skip to main content

vitaminc_permutation/
key.rs

1use serde::{Deserialize, Serialize};
2use vitaminc_protected::{Controlled, Exportable, Protected, Zeroed};
3use vitaminc_random::{Generatable, RandomError, SafeRand, SeedableRng};
4use zeroize::Zeroize;
5
6use super::private::IsPermutable;
7use crate::{
8    elementwise::{depermute_array, permute_array, Permute},
9    private::identity,
10};
11
12pub(crate) type KeyInner<const N: usize> = Exportable<Protected<[u8; N]>>;
13
14/// One seed in a [`PermutationKey::from_seeds`] batch could not derive a key.
15///
16/// `index` is the seed's position in the iterator passed to `from_seeds`.
17/// For [`RandomError::SeedRejected`] that seed can never derive a key.
18/// `from_seeds` consumed and wiped the batch, so recovery starts from the
19/// caller's own retained seeds: replace the one at `index` with a fresh
20/// seed and derive the whole batch again.
21#[derive(Debug, thiserror::Error)]
22#[error("seed at index {index} could not derive a key: {source}")]
23pub struct BatchSeedError {
24    /// Position of the failing seed in the batch.
25    pub index: usize,
26    /// Why derivation failed for that seed.
27    #[source]
28    pub source: RandomError,
29}
30
31// NOTE: no `Copy` — `KeyInner` is a `Protected` secret, and the reasons it can
32// never be `Copy` (see the `vitaminc_protected::Protected` docs) apply to any
33// wrapper around it. Use `Clone` where a copy is needed.
34//
35// The key IS wiped on drop: `KeyInner` is `Exportable<Protected<[u8; N]>>`, both
36// of which are `ZeroizeOnDrop`, so the field's drop glue zeroizes the bytes. We
37// deliberately do NOT derive `ZeroizeOnDrop` on `PermutationKey` itself: a `Drop`
38// impl would forbid the `.0` field moves in `complement` / `Permute::permute`
39// (E0509), and recovering them would mean duplicating `protected`'s
40// `ptr::read`+`forget` move-out primitive into this crate. The drop-glue
41// guarantee holds as long as `KeyInner` stays `ZeroizeOnDrop`.
42#[derive(Clone, Debug, Serialize, Deserialize, Zeroize)]
43pub struct PermutationKey<const N: usize>(KeyInner<N>);
44
45impl<const N: usize> PermutationKey<N> {
46    /// # Safety
47    ///
48    /// This function is unsafe because it does not check that the key is a valid permutation.
49    ///
50    pub unsafe fn new_unchecked(key: [u8; N]) -> Self {
51        Self(KeyInner::<N>::new(key))
52    }
53
54    /// Creates a new permutation key from a seed.
55    ///
56    /// Derivation is deterministic: a given seed always yields the same key.
57    /// If it returns [`RandomError::SeedRejected`] (probability ≈ N²/2⁵⁷,
58    /// at most ≈ 2⁻⁴³ for N = 128), the seed can *never* derive a key —
59    /// discard it and provision a fresh seed. Only retain seeds whose first
60    /// derivation succeeds.
61    ///
62    /// TODO: Perhaps seed should be protected?
63    pub fn from_seed(seed: [u8; 32]) -> Result<Self, RandomError>
64    where
65        [u8; N]: IsPermutable,
66    {
67        let mut rng = SafeRand::from_seed(seed);
68        Generatable::random(&mut rng)
69    }
70
71    /// Derives one key per seed, in order, wiping each seed once its
72    /// generator is built.
73    ///
74    /// Each seed is derived exactly as [`PermutationKey::from_seed`] would:
75    /// independently and deterministically, so the batch is bit-identical to
76    /// calling `from_seed` per seed. Seeds arrive as [`Controlled`] values
77    /// (for instance `Protected<[u8; 32]>`) rather than bare bytes, so a
78    /// batch of retained secrets is never copied around unwiped.
79    ///
80    /// A rejected seed fails the whole call and [`BatchSeedError`] names its
81    /// lane. No keys are delivered for the lanes before it, so a caller can
82    /// never end up holding a key vector that is out of step with its seeds.
83    ///
84    /// The call consumes the seeds, and every one that was unwrapped is
85    /// wiped whether or not the batch succeeds; the error carries only the
86    /// index. Recovery is therefore the caller's, from its own copy of the
87    /// batch: keep the seeds in their [`Controlled`] containers (a
88    /// `Vec<Protected<[u8; 32]>>`, say) and pass `from_seeds` a view of them
89    /// (clones, or a mapping iterator) rather than the originals; on
90    /// [`RandomError::SeedRejected`] replace the seed at the reported index
91    /// with a fresh one and derive the whole batch again. A caller that
92    /// cannot retain or regenerate its seeds has nothing to retry with.
93    ///
94    /// The batch entry point exists for bulk workloads (e.g. deriving the
95    /// per-block permutations for a batch of ORE encryptions): it fixes the
96    /// API shape so a future vectorized backend can derive lanes in parallel
97    /// while remaining bit-identical to per-seed scalar derivation.
98    pub fn from_seeds<C>(seeds: impl IntoIterator<Item = C>) -> Result<Vec<Self>, BatchSeedError>
99    where
100        [u8; N]: IsPermutable,
101        C: Controlled<Inner = [u8; 32]>,
102    {
103        Self::derive_batch(seeds, Generatable::random)
104    }
105
106    /// The batch loop behind [`from_seeds`](Self::from_seeds), with the
107    /// per-lane derivation injected. Exists so tests can drive a rejected
108    /// lane: the natural rate is ≈ 2⁻⁴³ per seed, unreachable by search.
109    fn derive_batch<C>(
110        seeds: impl IntoIterator<Item = C>,
111        mut derive: impl FnMut(&mut SafeRand) -> Result<Self, RandomError>,
112    ) -> Result<Vec<Self>, BatchSeedError>
113    where
114        C: Controlled<Inner = [u8; 32]>,
115    {
116        seeds
117            .into_iter()
118            .enumerate()
119            .map(|(index, seed)| {
120                let mut rng = SafeRand::from_controlled_seed(seed);
121                derive(&mut rng).map_err(|source| BatchSeedError { index, source })
122            })
123            .collect()
124    }
125
126    /// Returns the inverse of this key.
127    ///
128    /// Borrows `self` — inversion builds a fresh key from the borrowed
129    /// permutation, so there is no need to consume (or clone) the original.
130    pub fn invert(&self) -> Self
131    where
132        [u8; N]: IsPermutable,
133    {
134        Self(KeyInner::new(depermute_array(self, identity())))
135    }
136
137    /// Returns the complement of the key with respect to the target key.
138    /// That is: `C(T) = Self`
139    ///
140    /// # Example
141    ///
142    /// ```
143    /// # mod vitaminc { pub mod permutation { pub use vitaminc_permutation::*; } pub mod random { pub use vitaminc_random::*; } }
144    /// use vitaminc::permutation::{Permute, PermutationKey};
145    /// use vitaminc::random::{Generatable, SafeRand, SeedableRng};
146    /// let mut rng = SafeRand::from_entropy().expect("Failed to seed RNG");
147    /// let key = PermutationKey::random(&mut rng).expect("Random error");
148    /// let target = PermutationKey::random(&mut rng).expect("Random error");
149    /// let complement = key.complement(&target);
150    /// let input: [u8; 16] = Generatable::random(&mut rng).expect("Random error");
151    /// assert_eq!(
152    ///     complement.permute(target).permute(input),
153    ///     key.permute(input)
154    /// );
155    /// ```
156    pub fn complement(&self, target: &Self) -> Self
157    where
158        [u8; N]: IsPermutable + Zeroed,
159    {
160        // `invert` borrows, so we map the inverse of the borrowed `target`
161        // through `permute_array` without ever copying the key.
162        Self(target.invert().0.map(|arr| permute_array(self, arr)))
163    }
164
165    pub(crate) fn iter(&self) -> impl Iterator<Item = Protected<u8>> + '_ {
166        self.0.iter()
167    }
168}
169
170impl<const N: usize> Generatable for PermutationKey<N>
171where
172    [u8; N]: IsPermutable,
173{
174    fn random(rng: &mut SafeRand) -> Result<Self, RandomError> {
175        // Oblivious sort-by-random-key shuffle: unlike Fisher–Yates, whose
176        // `swap(i, j)` addresses memory with the secret draw `j`, timing and
177        // access patterns here are functions of `N` only. See `crate::shuffle`.
178        //
179        // Exactly one batch is attempted: `Err(SeedRejected)` means the seed
180        // behind `rng` is unusable and must be replaced, not retried.
181        //
182        // The permutation is written straight into the key's own wiped-on-
183        // drop slot, so no plain `[u8; N]` copy of it exists at any point;
184        // on failure the zeroed slot is dropped and wiped like any key.
185        let mut key = KeyInner::<N>::generate(|| [0; N]);
186        crate::shuffle::random_permutation(rng, key.inner_mut())?;
187        Ok(Self(key))
188    }
189}
190
191impl<const N: usize> Permute<PermutationKey<N>> for PermutationKey<N>
192where
193    [u8; N]: IsPermutable + Zeroed,
194{
195    fn permute(&self, Self(inner): Self) -> Self {
196        Self(inner.map(|arr| permute_array(self, arr)))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use crate::{
203        elementwise::Permute,
204        key::KeyInner,
205        private::{identity, IsPermutable},
206        BatchSeedError, PermutationKey,
207    };
208    use vitaminc_protected::{Controlled, Protected, Zeroed};
209    use vitaminc_random::{Generatable, RandomError, SafeRand, SeedableRng};
210
211    use crate::tests;
212
213    fn test_key_invert<const N: usize>() -> Result<(), Box<dyn std::error::Error>>
214    where
215        [u8; N]: IsPermutable + Zeroed,
216    {
217        let key: PermutationKey<N> = tests::gen_rand_key()?;
218        let inverted = key.invert();
219
220        // p(p^-1(x)) = x
221        assert_eq!(
222            key.permute(inverted).0.risky_unwrap(),
223            KeyInner::<N>::generate(identity).risky_unwrap(),
224            "Failed to invert key of size {N}"
225        );
226        Ok(())
227    }
228
229    fn test_key_complement<const N: usize>() -> Result<(), Box<dyn std::error::Error>>
230    where
231        [u8; N]: IsPermutable + Zeroed,
232    {
233        let key: PermutationKey<N> = tests::gen_rand_key()?;
234        let target: PermutationKey<N> = tests::gen_rand_key()?;
235        let complement = key.complement(&target);
236
237        let mut rng = SafeRand::from_entropy()?;
238        let input: [u8; N] = Generatable::random(&mut rng)?;
239
240        // c(t)(x) = p(x)
241        assert_eq!(
242            complement.permute(target).permute(input),
243            key.permute(input),
244            "Failed to complement key of size {N}"
245        );
246        Ok(())
247    }
248
249    #[test]
250    fn key_inversion_case() -> Result<(), Box<dyn std::error::Error>> {
251        test_key_invert::<8>()?;
252        test_key_invert::<16>()?;
253        test_key_invert::<32>()?;
254        test_key_invert::<64>()?;
255        test_key_invert::<128>()?;
256        Ok(())
257    }
258
259    /// A generated key is a valid permutation: every value in `0..N` present
260    /// exactly once. The invert / complement round-trips imply this only
261    /// transitively; checking it directly at the `PermutationKey` level fails
262    /// loudly if the generator regresses, whichever shuffle it uses.
263    fn test_key_is_a_permutation<const N: usize>() -> Result<(), Box<dyn std::error::Error>>
264    where
265        [u8; N]: IsPermutable,
266    {
267        let mut rng = SafeRand::from_seed([7u8; 32]);
268        for _ in 0..64 {
269            let key: PermutationKey<N> = Generatable::random(&mut rng)?;
270            let mut seen = [false; N];
271            for v in key.iter() {
272                let v = v.risky_unwrap() as usize;
273                assert!(v < N, "value {v} out of range for N = {N}");
274                assert!(!seen[v], "value {v} appears twice for N = {N}");
275                seen[v] = true;
276            }
277            assert!(seen.iter().all(|&s| s), "missing value for N = {N}");
278        }
279        Ok(())
280    }
281
282    #[test]
283    fn key_is_a_permutation_case() -> Result<(), Box<dyn std::error::Error>> {
284        test_key_is_a_permutation::<8>()?;
285        test_key_is_a_permutation::<16>()?;
286        test_key_is_a_permutation::<32>()?;
287        test_key_is_a_permutation::<64>()?;
288        test_key_is_a_permutation::<128>()?;
289        Ok(())
290    }
291
292    #[test]
293    fn key_position_uniformity() -> Result<(), Box<dyn std::error::Error>> {
294        // Chi-squared test over the position matrix: counts[v][i] tallies how
295        // often value `v` ends up at position `i`. Under a uniform permutation
296        // every cell has the same expectation. This catches the power-of-two
297        // bias in the old inclusive bounded draw (issue #198), where the swap
298        // target at power-of-two Fisher–Yates steps could never equal the step
299        // index itself.
300        const N: usize = 8;
301        const SAMPLES: usize = 20_000;
302        let mut rng = SafeRand::from_seed([7u8; 32]);
303        let mut counts = [[0u32; N]; N];
304        for _ in 0..SAMPLES {
305            let key: PermutationKey<N> = Generatable::random(&mut rng)?;
306            for (i, v) in key.iter().enumerate() {
307                counts[v.risky_unwrap() as usize][i] += 1;
308            }
309        }
310        let expected = (SAMPLES / N) as f64;
311        let raw: f64 = counts
312            .iter()
313            .flatten()
314            .map(|&c| {
315                let d = f64::from(c) - expected;
316                d * d / expected
317            })
318            .sum();
319        // Each sample is a permutation matrix, not N independent draws, so
320        // the raw Pearson sum over the N² cells is not χ² on (N − 1)² = 49
321        // degrees of freedom: its mean is N/(N − 1) times that. Scaling by
322        // (N − 1)/N recovers a χ²(49) statistic (verified by simulation:
323        // mean 49.0, 0.1% above the threshold). p = 0.001 critical value for
324        // χ²(49) is 85.35. The seed is fixed, so the value is reproducible;
325        // an honest generator would exceed the threshold for about one seed
326        // in a thousand.
327        let chi2 = raw * (N - 1) as f64 / N as f64;
328        assert!(chi2 < 85.35, "chi-squared too high: {chi2}");
329        Ok(())
330    }
331
332    #[test]
333    fn batch_matches_single_seed_derivation() -> Result<(), Box<dyn std::error::Error>> {
334        // The batch path must be indistinguishable from calling `from_seed`
335        // per seed: same keys, same order. Distinct seeds pin the ordering —
336        // a reordered or repeated lane would produce a mismatched key.
337        let seeds: Vec<[u8; 32]> = (0u8..5).map(|i| [i; 32]).collect();
338        let batch = PermutationKey::<64>::from_seeds(seeds.iter().copied().map(Protected::new))?;
339        assert_eq!(batch.len(), seeds.len());
340        for (seed, key) in seeds.into_iter().zip(batch) {
341            let expected = PermutationKey::<64>::from_seed(seed)?;
342            assert_eq!(
343                key.0.risky_unwrap(),
344                expected.0.risky_unwrap(),
345                "batch lane diverged from from_seed for seed {:?}",
346                seed[0]
347            );
348        }
349        Ok(())
350    }
351
352    #[test]
353    fn batch_of_nothing_is_empty() -> Result<(), Box<dyn std::error::Error>> {
354        let none = std::iter::empty::<Protected<[u8; 32]>>();
355        assert!(PermutationKey::<8>::from_seeds(none)?.is_empty());
356        Ok(())
357    }
358
359    /// A rejected seed fails the batch with its own lane index, and no lane
360    /// after it is derived. Natural rejection is ≈ 2⁻⁴³ per seed, so it is
361    /// injected through the private seam rather than found by search.
362    #[test]
363    fn rejected_lane_is_reported_by_index_and_stops_the_batch() {
364        let seeds = (0u8..5).map(|i| Protected::new([i; 32]));
365        let mut calls = 0;
366        let err = PermutationKey::<16>::derive_batch(seeds, |rng| {
367            calls += 1;
368            if calls == 3 {
369                Err(RandomError::SeedRejected)
370            } else {
371                Generatable::random(rng)
372            }
373        })
374        .expect_err("lane 2 was rejected");
375        assert_eq!(err.index, 2);
376        assert!(matches!(err.source, RandomError::SeedRejected));
377        assert_eq!(calls, 3, "lanes after the rejected one must not be derived");
378    }
379
380    /// Fixed-seed golden vectors for every supported `N`, captured on the
381    /// commit before batch derivation landed. `from_seed` is a durable
382    /// contract: a key derived from a retained seed must never change, or
383    /// data permuted under it becomes unrecoverable. The README doctest pins
384    /// only `N = 8`, and the relative tests here (batch vs single,
385    /// determinism, validity) cannot see a change that alters every
386    /// derivation the same way. Each value is the key applied to the
387    /// identity array.
388    #[test]
389    fn from_seed_golden_vectors() -> Result<(), Box<dyn std::error::Error>> {
390        fn check<const N: usize>(expected: [u8; N]) -> Result<(), Box<dyn std::error::Error>>
391        where
392            [u8; N]: IsPermutable + Zeroed,
393        {
394            let key = PermutationKey::<N>::from_seed([0u8; 32])?;
395            let identity: [u8; N] = core::array::from_fn(|i| i as u8);
396            assert_eq!(key.permute(identity), expected, "N = {N}");
397            Ok(())
398        }
399        check::<8>([3, 0, 4, 5, 7, 6, 1, 2])?;
400        check::<16>([9, 3, 8, 13, 0, 4, 5, 15, 12, 10, 7, 11, 6, 1, 2, 14])?;
401        check::<32>([
402            9, 3, 28, 31, 30, 26, 8, 13, 0, 4, 23, 5, 15, 12, 24, 20, 18, 10, 7, 16, 11, 27, 22,
403            17, 6, 29, 19, 21, 1, 25, 2, 14,
404        ])?;
405        check::<64>([
406            58, 9, 47, 3, 28, 56, 36, 31, 43, 30, 33, 57, 26, 41, 53, 8, 13, 0, 60, 40, 4, 23, 5,
407            15, 12, 24, 20, 52, 18, 10, 7, 16, 55, 11, 63, 49, 46, 39, 27, 35, 22, 59, 38, 17, 6,
408            44, 48, 29, 45, 19, 42, 21, 54, 1, 25, 32, 50, 37, 51, 2, 62, 34, 14, 61,
409        ])?;
410        check::<128>([
411            58, 76, 72, 87, 9, 119, 47, 3, 28, 56, 36, 31, 43, 110, 30, 89, 33, 112, 57, 26, 41,
412            73, 117, 68, 53, 8, 13, 0, 97, 60, 115, 40, 4, 23, 5, 15, 12, 123, 69, 24, 103, 111,
413            91, 126, 20, 118, 52, 127, 124, 18, 94, 105, 10, 7, 88, 16, 109, 55, 74, 11, 80, 106,
414            63, 77, 83, 49, 113, 82, 46, 39, 114, 98, 27, 116, 104, 35, 90, 22, 59, 38, 17, 6, 75,
415            81, 44, 92, 48, 93, 29, 45, 120, 100, 19, 78, 42, 21, 54, 1, 95, 84, 25, 125, 107, 71,
416            32, 50, 79, 96, 101, 85, 37, 51, 122, 102, 67, 2, 99, 64, 62, 86, 70, 66, 65, 34, 14,
417            61, 108, 121,
418        ])?;
419        Ok(())
420    }
421
422    /// The error names the lane so a caller can discard exactly that seed.
423    #[test]
424    fn batch_error_reports_the_lane() {
425        let err = BatchSeedError {
426            index: 3,
427            source: RandomError::SeedRejected,
428        };
429        assert_eq!(
430            err.to_string(),
431            "seed at index 3 could not derive a key: The seed produced an unusable batch; discard it and generate a fresh seed"
432        );
433        assert!(std::error::Error::source(&err).is_some());
434    }
435
436    #[test]
437    fn key_complement_case() -> Result<(), Box<dyn std::error::Error>> {
438        test_key_complement::<8>()?;
439        test_key_complement::<16>()?;
440        test_key_complement::<32>()?;
441        test_key_complement::<64>()?;
442        test_key_complement::<128>()?;
443        Ok(())
444    }
445}