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}