old_crypto_rs/straddling.rs
1//! Straddling Checkerboard cipher implementation.
2//!
3//! The straddling checkerboard is a substitution cipher that uses a 10-column grid
4//! to encode letters into digits. Common letters are encoded as single digits,
5//! while less common letters require two digits. This creates variable-length
6//! ciphertext that appears as a stream of digits.
7//!
8//! The cipher uses:
9//! - A keyword to shuffle the alphabet
10//! - Two "long" cipher digits that prefix two-digit codes
11//! - Eight "short" cipher digits for single-digit codes
12//! - A frequency string to determine which letters get single-digit codes
13//!
14use crate::Block;
15use crate::helpers::{shuffle, SC_ALPHABET};
16
17use eyre::Result;
18use crate::error::Error;
19
20/// Compact encoding entry for a single plaintext byte.
21///
22/// `len` is 0 for unmapped bytes, or 1/2 for the number of output digits.
23/// `bytes` stores the digit bytes for the code.
24#[derive(Copy, Clone, Default)]
25struct EncEntry {
26 len: u8,
27 bytes: [u8; 2],
28}
29
30impl std::fmt::Debug for EncEntry {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 let b0 = if self.bytes[0] == 0 { b'.' } else { self.bytes[0] };
33 let b1 = if self.bytes[1] == 0 { b'.' } else { self.bytes[1] };
34 let code = [b0, b1];
35 f.write_str(std::str::from_utf8(&code).unwrap_or(".."))
36 }
37}
38
39/// All cipher digits from 0 to 9 used in the checkerboard.
40const ALL_CIPHER: &[u8] = b"0123456789";
41
42/// A straddling checkerboard cipher implementation.
43///
44/// This cipher maps plaintext characters to variable-length digit sequences.
45/// High-frequency letters are encoded as single digits, while low-frequency
46/// letters are encoded as two digits prefixed by one of the "long" cipher digits.
47///
48/// # Examples
49///
50/// ```
51/// use old_crypto_rs::StraddlingCheckerboard;
52/// use old_crypto_rs::Block;
53///
54/// let cipher = StraddlingCheckerboard::new("ARABESQUE", "89").unwrap();
55/// let mut encrypted = vec![0u8; 100];
56/// let len = cipher.encrypt(&mut encrypted, b"ATTACK");
57/// encrypted.truncate(len);
58/// ```
59///
60#[derive(Debug)]
61pub struct StraddlingCheckerboard {
62 /// The keyword used to shuffle the alphabet.
63 key: String,
64 /// The two digits used as prefixes for two-digit codes (typically 2 bytes).
65 longc: Vec<u8>,
66 /// The shuffled alphabet after applying the key.
67 full: String,
68 /// Fast encoding table indexed by plaintext byte.
69 enc_table: [EncEntry; 256],
70 /// Fast decoding table for single-digit codes.
71 dec1: [u8; 10],
72 /// Fast decoding table for two-digit codes.
73 dec2: [[u8; 10]; 10],
74 /// Fast lookup for whether a digit is a long-code prefix.
75 longc_mask: [bool; 10],
76}
77
78impl StraddlingCheckerboard {
79 /// Creates a new straddling checkerboard cipher with default frequency.
80 ///
81 /// Uses "ESANTIRU" as the default high-frequency letters and the standard
82 /// alphabet (A-Z plus '/' and '-').
83 ///
84 /// # Arguments
85 ///
86 /// * `key` - The keyword used to shuffle the alphabet (must not be empty)
87 /// * `chrs` - A string of at least 2 digits that will be used as "long" cipher digits
88 ///
89 /// # Returns
90 ///
91 /// Returns `Ok(StraddlingCheckerboard)` on success, or `Err(String)` if validation fails.
92 ///
93 /// # Errors
94 ///
95 /// Returns an error if:
96 /// - `key` is empty
97 /// - `chrs` contains fewer than 2 characters
98 ///
99 /// # Examples
100 ///
101 /// ```
102 /// use old_crypto_rs::StraddlingCheckerboard;
103 ///
104 /// let cipher = StraddlingCheckerboard::new("ARABESQUE", "89").unwrap();
105 /// ```
106 ///
107 pub fn new(key: &str, chrs: &str) -> Result<Self> {
108 Self::new_with_freq(key, chrs, "ESANTIRU", SC_ALPHABET)
109 }
110
111 /// Creates a new straddling checkerboard cipher with custom frequency and alphabet.
112 ///
113 /// Allows full customization of which letters get single-digit codes and
114 /// what alphabet to use.
115 ///
116 /// # Arguments
117 ///
118 /// * `key` - The keyword used to shuffle the alphabet (must not be empty)
119 /// * `chrs` - A string of at least 2 digits for "long" cipher digit prefixes
120 /// * `freq_str` - Letters that should receive single-digit encodings
121 /// * `alphabet` - The alphabet to use for the checkerboard
122 ///
123 /// # Returns
124 ///
125 /// Returns `Ok(StraddlingCheckerboard)` on success, or `Err(String)` if validation fails.
126 ///
127 /// # Errors
128 ///
129 /// Returns an error if:
130 /// - `key` is empty
131 /// - `chrs` contains fewer than 2 characters
132 ///
133 pub fn new_with_freq(key: &str, chrs: &str, freq_str: &str, alphabet: &str) -> Result<Self> {
134 if key.is_empty() {
135 return Err(Error::EmptyKeys.into());
136 }
137 if chrs.len() < 2 {
138 return Err(Error::TooShort(chrs.len(), 2).into());
139 }
140
141 let longc = vec![chrs.as_bytes()[0], chrs.as_bytes()[1]];
142 let full = if key.is_empty() {
143 alphabet.to_string()
144 } else {
145 shuffle(key, alphabet)
146 };
147 // Remove digits from full if they were added by the key but not in the alphabet
148 let full_clean: String = full.chars().filter(|&c| alphabet.contains(c)).collect();
149 let shortc = Self::extract(ALL_CIPHER, &longc);
150
151 let mut c = StraddlingCheckerboard {
152 key: key.to_string(),
153 full: full_clean,
154 longc,
155 enc_table: [EncEntry::default(); 256],
156 dec1: [0; 10],
157 dec2: [[0; 10]; 10],
158 longc_mask: [false; 10],
159 };
160 for &c_digit in &c.longc {
161 if c_digit.is_ascii_digit() {
162 c.longc_mask[(c_digit - b'0') as usize] = true;
163 }
164 }
165 c.expand_key(shortc, freq_str.as_bytes());
166 Ok(c)
167 }
168
169 /// Extracts elements from a set that are not in the exclusion list.
170 ///
171 /// Used to compute the "short" cipher digits by removing the "long" digits
172 /// from all possible cipher digits.
173 ///
174 /// # Arguments
175 ///
176 /// * `set` - The full set of digits (0-9)
177 /// * `two` - The two digits to exclude
178 ///
179 /// # Returns
180 ///
181 /// A vector containing all digits from `set` except those in `two`.
182 ///
183 fn extract(set: &[u8], two: &[u8]) -> Vec<u8> {
184 set.iter().cloned().filter(|&x| !two.contains(&x)).collect()
185 }
186
187 /// Generates all two-digit combinations for a given prefix digit.
188 ///
189 /// Creates strings like "30", "31", ..., "39" for prefix '3'.
190 /// Special case: if prefix is '0', returns single digits "0" through "9".
191 ///
192 /// # Arguments
193 ///
194 /// * `c` - The prefix digit
195 ///
196 /// # Returns
197 ///
198 /// A vector of 10 strings representing all combinations with this prefix.
199 ///
200 fn times10(c: u8) -> Vec<String> {
201 let mut tmp = Vec::with_capacity(10);
202 if c == b'0' {
203 for &b in ALL_CIPHER {
204 tmp.push((b as char).to_string());
205 }
206 } else {
207 for &b in ALL_CIPHER {
208 let mut s = (c as char).to_string();
209 s.push(b as char);
210 tmp.push(s);
211 }
212 }
213 tmp
214 }
215
216 /// Generates all two-digit combinations for both long cipher digits.
217 ///
218 /// Combines the results of `times10()` for both long cipher digits,
219 /// producing 20 total two-digit codes.
220 ///
221 /// # Arguments
222 ///
223 /// * `set` - A slice containing the two long cipher digits
224 ///
225 /// # Returns
226 ///
227 /// A vector of 20 strings representing all two-digit codes.
228 ///
229 fn set_times10(set: &[u8]) -> Vec<String> {
230 let mut longc = Vec::with_capacity(20);
231 longc.extend(Self::times10(set[0]));
232 longc.extend(Self::times10(set[1]));
233 longc
234 }
235
236 /// Builds the encoding and decoding tables based on frequency analysis.
237 ///
238 /// Assigns single-digit codes to high-frequency letters and two-digit
239 /// codes to low-frequency letters. Populates the encode/decode tables.
240 ///
241 /// # Arguments
242 ///
243 /// * `shortc` - The digits available for single-digit encoding
244 /// * `freq` - The high-frequency letters that should get single-digit codes
245 ///
246 fn expand_key(&mut self, shortc: Vec<u8>, freq: &[u8]) {
247 let longc = Self::set_times10(&self.longc);
248
249 let mut i = 0;
250 let mut j = 0;
251 for &ch in self.full.as_bytes() {
252 if freq.contains(&ch) {
253 if i < shortc.len() {
254 let digit = shortc[i];
255 self.enc_table[ch as usize] = EncEntry { len: 1, bytes: [digit, 0] };
256 self.dec1[(digit - b'0') as usize] = ch;
257 i += 1;
258 }
259 } else {
260 if j < longc.len() {
261 let bytes = longc[j].as_bytes();
262 if bytes.len() == 2 {
263 let d0 = bytes[0];
264 let d1 = bytes[1];
265 self.enc_table[ch as usize] = EncEntry { len: 2, bytes: [d0, d1] };
266 self.dec2[(d0 - b'0') as usize][(d1 - b'0') as usize] = ch;
267 }
268 j += 1;
269 }
270 }
271 }
272 }
273}
274
275impl Block for StraddlingCheckerboard {
276 /// Returns the block size, which equals the key length.
277 ///
278 /// # Returns
279 ///
280 /// The length of the cipher key in bytes.
281 ///
282 fn block_size(&self) -> usize {
283 self.key.len()
284 }
285
286 /// Encrypts plaintext into digit ciphertext.
287 ///
288 /// Each plaintext letter is replaced with its corresponding digit code
289 /// (either 1 or 2 digits). Numeric digits in the plaintext are escaped
290 /// by surrounding them with the '/' marker code and duplicating the digit.
291 ///
292 /// # Arguments
293 ///
294 /// * `dst` - Output buffer for the encrypted digit string
295 /// * `src` - Input plaintext bytes to encrypt
296 ///
297 /// # Returns
298 ///
299 /// The number of bytes written to `dst`.
300 ///
301 /// # Examples
302 ///
303 /// Encrypting "ATTACK" with key "ARABESQUE" and long digits "89" produces "07708081".
304 ///
305 fn encrypt(&self, dst: &mut [u8], src: &[u8]) -> usize {
306 let mut offset = 0;
307 let marker = self.enc_table[b'/' as usize];
308 for &ch in src {
309 if ch.is_ascii_digit() {
310 if marker.len != 0 {
311 dst[offset] = marker.bytes[0];
312 if marker.len == 2 {
313 dst[offset + 1] = marker.bytes[1];
314 }
315 offset += marker.len as usize;
316
317 dst[offset] = ch;
318 dst[offset + 1] = ch;
319 offset += 2;
320
321 dst[offset] = marker.bytes[0];
322 if marker.len == 2 {
323 dst[offset + 1] = marker.bytes[1];
324 }
325 offset += marker.len as usize;
326 }
327 } else {
328 let entry = self.enc_table[ch as usize];
329 if entry.len != 0 {
330 dst[offset] = entry.bytes[0];
331 if entry.len == 2 {
332 dst[offset + 1] = entry.bytes[1];
333 }
334 offset += entry.len as usize;
335 }
336 }
337 }
338 offset
339 }
340
341 /// Decrypts digit ciphertext back into plaintext.
342 ///
343 /// Processes the digit stream, recognizing both single-digit and two-digit
344 /// codes. Handles escaped numeric digits by detecting the '/' marker pattern.
345 ///
346 /// # Arguments
347 ///
348 /// * `dst` - Output buffer for the decrypted plaintext
349 /// * `src` - Input ciphertext digit string to decrypt
350 ///
351 /// # Returns
352 ///
353 /// The number of bytes written to `dst`.
354 ///
355 /// # Examples
356 ///
357 /// Decrypting "07708081" with key "ARABESQUE" and long digits "89" produces "ATTACK".
358 ///
359 fn decrypt(&self, dst: &mut [u8], src: &[u8]) -> usize {
360 let mut pt_offset = 0;
361 let mut i = 0;
362 while i < src.len() {
363 let ch = src[i];
364 let ptc;
365 let mut db_len = 1;
366
367 if !ch.is_ascii_digit() {
368 i += 1;
369 continue;
370 }
371
372 let d0 = (ch - b'0') as usize;
373 if self.longc_mask[d0] {
374 if i + 1 < src.len() {
375 let ch1 = src[i + 1];
376 if ch1.is_ascii_digit() {
377 let d1 = (ch1 - b'0') as usize;
378 ptc = self.dec2[d0][d1];
379 db_len = 2;
380 } else {
381 i += 2;
382 continue;
383 }
384 } else {
385 i += 1;
386 continue;
387 }
388 } else {
389 ptc = self.dec1[d0];
390 }
391 i += db_len;
392
393 if ptc == b'/' {
394 if i + 4 <= src.len() && src[i] == src[i + 1] {
395 let row0 = src[i + 2];
396 let row1 = src[i + 3];
397 if row0.is_ascii_digit() && row1.is_ascii_digit() {
398 let rd0 = (row0 - b'0') as usize;
399 let rd1 = (row1 - b'0') as usize;
400 let mut is_match = false;
401 if db_len == 2 {
402 is_match = row0 == src[i - 2] && row1 == src[i - 1];
403 }
404
405 if is_match || self.dec2[rd0][rd1] == b'/' {
406 if pt_offset < dst.len() {
407 dst[pt_offset] = src[i];
408 pt_offset += 1;
409 }
410 i += 4;
411 continue;
412 }
413 }
414 }
415 }
416 if ptc != 0 {
417 if pt_offset < dst.len() {
418 dst[pt_offset] = ptc;
419 pt_offset += 1;
420 }
421 }
422 }
423 pt_offset
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 use rstest::rstest;
432
433 #[test]
434 fn test_encentry_debug_formats_two_chars() {
435 let entry = EncEntry { len: 2, bytes: *b"82" };
436 assert_eq!(format!("{entry:?}"), "82");
437 }
438
439 #[test]
440 fn test_encentry_debug_formats_empty_as_dots() {
441 let entry = EncEntry { len: 0, bytes: [0, 0] };
442 assert_eq!(format!("{entry:?}"), "..");
443 }
444
445 #[test]
446 fn test_new_cipher() {
447 let c = StraddlingCheckerboard::new("ARABESQUE", "89").unwrap();
448 assert_eq!(c.full, "ACKVRDLWBFMXEGNYSHOZQIP/UJT-");
449 assert_eq!(c.longc, b"89");
450 }
451
452 #[test]
453 fn test_new_cipher_bad_keys() {
454 assert!(StraddlingCheckerboard::new("ARABESQUE", "").is_err());
455 assert!(StraddlingCheckerboard::new("", "89").is_err());
456 }
457
458 #[test]
459 fn test_expand_key() {
460 let c = StraddlingCheckerboard::new("ARABESQUE", "89").unwrap();
461 let v = c.enc_table[b'V' as usize];
462 let k = c.enc_table[b'K' as usize];
463 let a = c.enc_table[b'A' as usize];
464 let e = c.enc_table[b'E' as usize];
465 assert_eq!(v.len, 2);
466 assert_eq!(v.bytes, *b"82");
467 assert_eq!(k.len, 2);
468 assert_eq!(k.bytes, *b"81");
469 assert_eq!(a.len, 1);
470 assert_eq!(a.bytes[0], b'0');
471 assert_eq!(e.len, 1);
472 assert_eq!(e.bytes[0], b'2');
473 assert_eq!(c.dec2[8][2], b'V');
474 assert_eq!(c.dec1[0], b'A');
475 }
476
477 #[rstest]
478 #[case(b'3', vec!["30", "31", "32", "33", "34", "35", "36", "37", "38", "39"])]
479 #[case(b'1', vec!["10", "11", "12", "13", "14", "15", "16", "17", "18", "19"])]
480 fn test_times10(#[case] c: u8, #[case] expected: Vec<&str>) {
481 assert_eq!(StraddlingCheckerboard::times10(c), expected);
482 }
483
484 #[rstest]
485 #[case(b"25", b"01346789")]
486 #[case(b"16", b"02345789")]
487 #[case(b"42", b"01356789")]
488 fn test_extract(#[case] two: &[u8], #[case] expected: &[u8]) {
489 assert_eq!(StraddlingCheckerboard::extract(ALL_CIPHER, two), expected);
490 }
491
492 #[rstest]
493 #[case("ARABESQUE", "89", "ATTACKAT2AM", "0770808107972297088")]
494 #[case("ARABESQUE", "36", "ATTACKAT2AM", "0990303109672267038")]
495 #[case("ARABESQUE", "37", "IFYOUCANREADTHIS", "6377173830041203397265")]
496 #[case("ARABESQUE", "89", "ATTACK", "07708081")]
497 #[case("SUBWAY", "89", "TOLKIEN", "6819388137")]
498 #[case("PORTABLE", "89", "RETRIBUTION", "1721693526840")]
499 fn test_straddling_encrypt(#[case] key: &str, #[case] chrs: &str, #[case] pt: &str, #[case] ct: &str) {
500 let c = StraddlingCheckerboard::new(key, chrs).unwrap();
501 let mut dst = vec![0u8; 100];
502 c.encrypt(&mut dst, pt.as_bytes());
503 let sct = String::from_utf8_lossy(&dst).trim_matches('\0').to_string();
504 assert_eq!(sct, ct);
505 }
506
507 #[rstest]
508 #[case("ARABESQUE", "89", "ATTACKAT2AM", "0770808107972297088")]
509 #[case("ARABESQUE", "36", "ATTACKAT2AM", "0990303109672267038")]
510 #[case("ARABESQUE", "37", "IFYOUCANREADTHIS", "6377173830041203397265")]
511 #[case("ARABESQUE", "89", "ATTACK", "07708081")]
512 #[case("SUBWAY", "89", "TOLKIEN", "6819388137")]
513 #[case("PORTABLE", "89", "RETRIBUTION", "1721693526840")]
514 fn test_straddling_decrypt(#[case] key: &str, #[case] chrs: &str, #[case] pt: &str, #[case] ct: &str) {
515 let c = StraddlingCheckerboard::new(key, chrs).unwrap();
516 let mut dst = vec![0u8; 100];
517 c.decrypt(&mut dst, ct.as_bytes());
518 let spt = String::from_utf8_lossy(&dst).trim_matches('\0').to_string();
519 assert_eq!(spt, pt);
520 }
521}