key_vault/codex/dynamic.rs
1//! [`DynamicCodex`] — per-vault randomized involution.
2//!
3//! `DynamicCodex` is functionally a [`StaticCodex`] whose lookup table is
4//! generated at construction by [`StaticCodex::random_involution`]. The
5//! difference is intent: a `StaticCodex` is meant to be built from a
6//! known set of swaps or otherwise reproducibly, while a `DynamicCodex`
7//! is always fresh-random.
8//!
9//! Use `DynamicCodex::new()` once per vault. Sharing one across vaults
10//! defeats the point.
11
12use super::{Codex, StaticCodex};
13use crate::Result;
14
15/// Per-vault randomized involution codex.
16///
17/// Construct with [`DynamicCodex::new`]; each call produces an
18/// independent random involution.
19pub struct DynamicCodex {
20 inner: StaticCodex,
21}
22
23impl core::fmt::Debug for DynamicCodex {
24 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
25 // The table is sensitive; same redaction as `StaticCodex`.
26 f.debug_struct("DynamicCodex")
27 .field("table", &"<redacted>")
28 .finish()
29 }
30}
31
32impl DynamicCodex {
33 /// Construct a new dynamic codex with a fresh random involution.
34 ///
35 /// # Errors
36 ///
37 /// Returns [`Error::Internal`](crate::Error::Internal) if the OS
38 /// CSPRNG fails.
39 ///
40 /// # Examples
41 ///
42 /// ```
43 /// use key_vault::{Codex, DynamicCodex};
44 ///
45 /// let codex = DynamicCodex::new().unwrap();
46 /// for byte in 0u8..=255 {
47 /// assert_eq!(codex.decode(codex.encode(byte)), byte);
48 /// }
49 /// ```
50 pub fn new() -> Result<Self> {
51 Ok(Self {
52 inner: StaticCodex::random_involution()?,
53 })
54 }
55}
56
57impl Codex for DynamicCodex {
58 #[inline]
59 fn encode(&self, byte: u8) -> u8 {
60 self.inner.encode(byte)
61 }
62
63 #[inline]
64 fn decode(&self, byte: u8) -> u8 {
65 self.inner.decode(byte)
66 }
67}
68
69#[cfg(test)]
70#[allow(
71 clippy::unwrap_used,
72 clippy::expect_used,
73 clippy::cast_possible_truncation,
74 clippy::cast_sign_loss
75)]
76mod tests {
77 use super::*;
78
79 #[test]
80 fn involution_holds_for_every_byte() {
81 let codex = DynamicCodex::new().unwrap();
82 for byte in 0u8..=255 {
83 assert_eq!(codex.decode(codex.encode(byte)), byte);
84 }
85 }
86
87 #[test]
88 fn no_fixed_points() {
89 let codex = DynamicCodex::new().unwrap();
90 for byte in 0u8..=255 {
91 assert_ne!(codex.encode(byte), byte);
92 }
93 }
94
95 #[test]
96 fn two_instances_have_different_tables() {
97 let a = DynamicCodex::new().unwrap();
98 let b = DynamicCodex::new().unwrap();
99 // Compare via encoding behavior — at least one byte must differ.
100 let any_diff = (0u8..=255).any(|b_in| a.encode(b_in) != b.encode(b_in));
101 assert!(
102 any_diff,
103 "two random codices encoded identically — broken RNG?"
104 );
105 }
106}