Skip to main content

key_vault/codex/
static_codex.rs

1//! [`StaticCodex`] — 256-byte involution lookup table.
2//!
3//! `StaticCodex` is the canonical Layer-5 implementation. It is a fixed
4//! permutation of `[0, 256)` chosen so that applying it twice returns the
5//! original byte — an involution. The permutation is stored as a 256-byte
6//! lookup table inside a [`LockedBytes`] buffer so the table itself is
7//! mlock'd and zeroed-on-drop just like every other piece of key-adjacent
8//! state.
9//!
10//! # Construction
11//!
12//! - [`StaticCodex::from_swaps`] — declarative: give it a list of swap
13//!   pairs like `&[(b'A', b'#'), (b'B', b'!')]` and it builds the table.
14//!   Useful for private builds that want a stable, build-time-known
15//!   transformation.
16//! - [`StaticCodex::random_involution`] — programmatic: pair up every
17//!   byte randomly with another distinct byte (no fixed points). Useful
18//!   for tests and as the engine behind
19//!   [`DynamicCodex`](super::DynamicCodex).
20//!
21//! # Lookup-table cost
22//!
23//! Encoding or decoding one byte is exactly one memory load
24//! (`table[byte as usize]`). Branch-free, constant-time, ~1 cycle on
25//! modern CPUs.
26
27use alloc::vec::Vec;
28
29use super::Codex;
30use crate::Result;
31use crate::error::Error;
32use crate::fragment::util::fisher_yates;
33use crate::memory::LockedBytes;
34
35/// Involution-based byte-swap codex backed by a 256-byte lookup table.
36///
37/// The table is held in a crate-internal `LockedBytes` buffer (mlock'd,
38/// zeroed on drop) since knowledge of the table is equivalent to
39/// knowledge of the transformation.
40pub struct StaticCodex {
41    table: LockedBytes,
42}
43
44impl core::fmt::Debug for StaticCodex {
45    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
46        // The table itself is sensitive (it's the transformation). Debug
47        // only reports the type name.
48        f.debug_struct("StaticCodex")
49            .field("table", &"<redacted>")
50            .finish()
51    }
52}
53
54impl StaticCodex {
55    /// Construct a codex from a list of swap pairs.
56    ///
57    /// Each `(a, b)` in `swaps` means "byte `a` encodes to `b` and `b`
58    /// encodes to `a`." Bytes not mentioned in any pair are fixed points
59    /// (encode to themselves). Self-swaps (`(x, x)`) are accepted as
60    /// no-ops.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`Error::Codex`](crate::Error::Codex) if a byte appears in
65    /// more than one swap pair — that would force the table to disagree
66    /// with itself and break the involution property.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use key_vault::{Codex, StaticCodex};
72    ///
73    /// // Swap the ASCII digit '0' with '#' and 'A' with '@'.
74    /// let codex = StaticCodex::from_swaps(&[(b'0', b'#'), (b'A', b'@')]).unwrap();
75    /// assert_eq!(codex.encode(b'0'), b'#');
76    /// assert_eq!(codex.encode(b'#'), b'0');
77    /// assert_eq!(codex.encode(b'B'), b'B'); // not in any swap, fixed point
78    /// // Involution holds:
79    /// for byte in 0u8..=255 {
80    ///     assert_eq!(codex.decode(codex.encode(byte)), byte);
81    /// }
82    /// ```
83    pub fn from_swaps(swaps: &[(u8, u8)]) -> Result<Self> {
84        let mut table = [0u8; 256];
85        // Initialize as the identity permutation (each byte maps to itself).
86        for (i, slot) in table.iter_mut().enumerate() {
87            #[allow(clippy::cast_possible_truncation)]
88            {
89                *slot = i as u8;
90            }
91        }
92
93        let mut used = [false; 256];
94        for &(a, b) in swaps {
95            if a == b {
96                // Self-swap: equivalent to no swap. Allowed.
97                used[a as usize] = true;
98                continue;
99            }
100            if used[a as usize] || used[b as usize] {
101                return Err(Error::Codex(alloc::string::ToString::to_string(
102                    "byte appears in more than one swap pair",
103                )));
104            }
105            table[a as usize] = b;
106            table[b as usize] = a;
107            used[a as usize] = true;
108            used[b as usize] = true;
109        }
110
111        Ok(Self {
112            table: LockedBytes::from_slice(&table),
113        })
114    }
115
116    /// Generate a random involution with no fixed points.
117    ///
118    /// Pairs up all 256 bytes uniformly at random and writes the
119    /// resulting permutation into the lookup table. Every byte transforms
120    /// to a different byte.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`Error::Internal`](crate::Error::Internal) if the OS CSPRNG
125    /// fails — same failure mode as everywhere else in the crate.
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use key_vault::{Codex, StaticCodex};
131    ///
132    /// let codex = StaticCodex::random_involution().unwrap();
133    /// // Involution: applying it twice returns the original byte.
134    /// for byte in 0u8..=255 {
135    ///     assert_eq!(codex.decode(codex.encode(byte)), byte);
136    /// }
137    /// // No fixed points: every byte transforms to a different byte.
138    /// for byte in 0u8..=255 {
139    ///     assert_ne!(codex.encode(byte), byte);
140    /// }
141    /// ```
142    pub fn random_involution() -> Result<Self> {
143        // Start with the identity table.
144        let mut table = [0u8; 256];
145        // Initialize as the identity permutation (each byte maps to itself).
146        for (i, slot) in table.iter_mut().enumerate() {
147            #[allow(clippy::cast_possible_truncation)]
148            {
149                *slot = i as u8;
150            }
151        }
152
153        // Build a permutation of all 256 byte values.
154        let mut perm: Vec<u8> = (0u8..=255).collect();
155        fisher_yates(&mut perm)?;
156
157        // Pair adjacent elements: perm[0]↔perm[1], perm[2]↔perm[3], ...,
158        // perm[254]↔perm[255]. Every byte is in exactly one pair, so
159        // every byte is in exactly one swap — a perfect involution with
160        // no fixed points.
161        let mut i = 0usize;
162        while i + 1 < perm.len() {
163            let a = perm[i];
164            let b = perm[i + 1];
165            table[a as usize] = b;
166            table[b as usize] = a;
167            i += 2;
168        }
169
170        Ok(Self {
171            table: LockedBytes::from_slice(&table),
172        })
173    }
174}
175
176impl Codex for StaticCodex {
177    #[inline]
178    fn encode(&self, byte: u8) -> u8 {
179        self.table.as_bytes()[byte as usize]
180    }
181
182    #[inline]
183    fn decode(&self, byte: u8) -> u8 {
184        // Involution: decode == encode.
185        self.table.as_bytes()[byte as usize]
186    }
187}
188
189#[cfg(test)]
190#[allow(
191    clippy::unwrap_used,
192    clippy::expect_used,
193    clippy::cast_possible_truncation,
194    clippy::cast_sign_loss
195)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn identity_when_no_swaps() {
201        let codex = StaticCodex::from_swaps(&[]).unwrap();
202        for byte in 0u8..=255 {
203            assert_eq!(codex.encode(byte), byte);
204            assert_eq!(codex.decode(byte), byte);
205        }
206    }
207
208    #[test]
209    fn explicit_swap_pair() {
210        let codex = StaticCodex::from_swaps(&[(0x42, 0x99)]).unwrap();
211        assert_eq!(codex.encode(0x42), 0x99);
212        assert_eq!(codex.encode(0x99), 0x42);
213        assert_eq!(codex.decode(0x99), 0x42);
214        assert_eq!(codex.decode(0x42), 0x99);
215        // Untouched bytes stay fixed.
216        for byte in 0u8..=255 {
217            if byte != 0x42 && byte != 0x99 {
218                assert_eq!(codex.encode(byte), byte);
219            }
220        }
221    }
222
223    #[test]
224    fn rejects_byte_in_two_swap_pairs() {
225        let err = StaticCodex::from_swaps(&[(0x42, 0x99), (0x42, 0xab)]).unwrap_err();
226        assert!(matches!(err, Error::Codex(_)));
227    }
228
229    #[test]
230    fn rejects_byte_in_two_swap_pairs_either_side() {
231        let err = StaticCodex::from_swaps(&[(0x42, 0x99), (0xab, 0x99)]).unwrap_err();
232        assert!(matches!(err, Error::Codex(_)));
233    }
234
235    #[test]
236    fn self_swap_is_a_noop() {
237        let codex = StaticCodex::from_swaps(&[(0x42, 0x42)]).unwrap();
238        assert_eq!(codex.encode(0x42), 0x42);
239    }
240
241    #[test]
242    fn involution_holds_for_every_byte_from_swaps() {
243        let codex = StaticCodex::from_swaps(&[(0x00, 0xff), (0x10, 0xa1), (0x42, 0x88)]).unwrap();
244        for byte in 0u8..=255 {
245            assert_eq!(codex.decode(codex.encode(byte)), byte);
246        }
247    }
248
249    #[test]
250    fn random_involution_holds_for_every_byte() {
251        let codex = StaticCodex::random_involution().unwrap();
252        for byte in 0u8..=255 {
253            assert_eq!(codex.decode(codex.encode(byte)), byte);
254        }
255    }
256
257    #[test]
258    fn random_involution_has_no_fixed_points() {
259        let codex = StaticCodex::random_involution().unwrap();
260        for byte in 0u8..=255 {
261            assert_ne!(
262                codex.encode(byte),
263                byte,
264                "byte {byte:#04x} is a fixed point"
265            );
266        }
267    }
268
269    #[test]
270    fn random_involutions_differ_across_calls() {
271        let a = StaticCodex::random_involution().unwrap();
272        let b = StaticCodex::random_involution().unwrap();
273        // 256-byte tables match identically with probability ~ 1/256!
274        // Astronomically improbable for a working RNG.
275        assert_ne!(a.table.as_bytes(), b.table.as_bytes());
276    }
277
278    #[test]
279    fn debug_redacts_table() {
280        let codex = StaticCodex::from_swaps(&[(0x00, 0xff)]).unwrap();
281        let rendered = alloc::format!("{codex:?}");
282        assert!(rendered.contains("<redacted>"));
283    }
284}