Skip to main content

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}