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}