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}