verticallines

Loading...

RustCipher: Building a Classical Cryptography CLI in Rust

Rahul Hathwar - 06/18/2021 - Implementing the Vigenère Cipher with Whitespace Preservation

Overview

RustCipher is a command-line encryption tool that implements classical cryptographic algorithms from scratch in Rust. While modern cryptography relies on battle-tested libraries, this project explores the mathematical foundations of encryption by building a complete Vigenère cipher implementation, handling edge cases like whitespace preservation, and creating an intuitive interactive CLI experience.

Technologies: Rust, structopt, dialoguer, modular arithmetic

The Challenge: Beyond Basic Encryption

The initial goal was straightforward: create a CLI tool that could encrypt and decrypt text using classical ciphers. However, real-world text encryption presents challenges that textbook examples often ignore:

  • Whitespace handling: How do you preserve the original spacing of multi-word phrases while maintaining cryptographic integrity?
  • Key repetition: When the plaintext is longer than the key, how do you extend the key without introducing patterns?
  • Negative modular arithmetic: Decryption requires handling negative remainders correctly in Rust.
  • User experience: How do you make cryptographic operations accessible through an intuitive CLI?

Architecture & Design Decisions

CLI Design with structopt and dialoguer

Rather than forcing users to remember complex command syntax, I opted for a two-tier interaction model:

#[derive(StructOpt)]
struct Cli {
    action: String,
}

The first argument specifies the action (encrypt, decrypt, or info), then dialoguer provides an interactive selection menu:

let items = vec!["Vigenere Cipher", "DES"];
let selection = Select::with_theme(&ColorfulTheme::default())
    .items(&items)
    .default(0)
    .interact_on_opt(&Term::stderr());

This design achieves two goals: it keeps the command invocation simple (./rust_text_encryptor encrypt) while providing guided choices for algorithm selection and parameter input.

Implementing the Vigenère Cipher

The Vigenère cipher extends the Caesar cipher by using a repeating keyword to determine shift values. Each character in the plaintext is shifted by the corresponding character in the key.

Character Position Mapping

The foundation is a constant-time character lookup:

const ALPHABETS: [char; 26] = [
    'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
    'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
    'u', 'v', 'w', 'x', 'y', 'z'
];

fn get_char_position(search_char: &char) -> i8 {
    for (i, x) in ALPHABETS.iter(). enumerate() {
        if x == search_char {
            return i as i8;
        }
    }
    return 0;
}

This linear search through a 26-element array is intentionally simple. For a small, fixed alphabet, the performance difference between this and a hash map is negligible, and the code remains readable.

Key Repetition Strategy

When the plaintext exceeds the key length, the key must repeat. The calculation needs to account for both complete repetitions and partial coverage:

fn repeat_key_char(word: &String, key: &String) -> usize {
    return (word.chars().count() / key.chars().count()) 
        + word.chars().count(). rem_euclid(key.chars().count());
}

For example, encrypting "hello world" (10 letters after removing spaces) with key "key" (3 letters) requires (10 / 3) + (10 % 3) = 3 + 1 = 4 repetitions: "keykeykeyk".

The Core Encryption Algorithm

The encryption logic applies modular arithmetic to shift each character:

fn encrypt(word: String, key: String) -> String {
    let mut resulting_string: String = String::new();
    let new_key: String = key.repeat(repeat_key_char(&word, &key));
    let (usable_word, space_index) = remove_whitespace(&word);
    
    for (i, c) in usable_word.chars().enumerate() {
        let pos_of_c: i8 = get_char_position(&c);
        let pos_of_key_c: i8 = get_char_position(&new_key. chars().nth(i).unwrap());
        let result: i8 = (pos_of_c + pos_of_key_c) % 26;
        let encrypted_char: char = ALPHABETS[result as usize];
        resulting_string.push(encrypted_char);
    }
    
    resulting_string = add_whitespace(&resulting_string, &space_index);
    return resulting_string;
}

The formula (pos_of_c + pos_of_key_c) % 26 shifts each character forward in the alphabet by the key character's position. For instance, encrypting 'h' (position 7) with key 'k' (position 10) yields position (7 + 10) % 26 = 17 → 'r'.

The Whitespace Preservation Problem

The original implementation likely couldn't handle spaces in the input text. This creates a user experience problem: encrypting "hello world" should produce output with a space at the same position, not a continuous string.

The solution required two complementary functions:

fn remove_whitespace(word: &String) -> (String, Vec<usize>) {
    let mut new_string: String = String::new();
    let mut space_index: Vec<usize> = vec![];
    
    for (i, c) in word.chars().enumerate() {
        if c == ' ' {
            space_index.push(i); 
        } else {
            new_string.push(c);
        }
    }
    (new_string, space_index)
}

fn add_whitespace(word: &String, space_index: &Vec<usize>) -> String {
    let mut new_string: String = String::from(word);
    for (_, x) in space_index.iter(). enumerate() {
        new_string.insert(*x, ' ');
    }
    new_string
}

The remove_whitespace function extracts all non-space characters into a new string while recording the original positions of spaces in a vector. After encryption, add_whitespace reconstructs the spacing by inserting spaces at their original indices.

This approach ensures that spaces don't affect the cryptographic operation (they're not encrypted) but are preserved for readability.

Handling Decryption with Negative Modular Arithmetic

Decryption reverses the encryption by subtracting the key position instead of adding it. However, this creates a subtle bug in languages like Rust:

fn decrypt(word: String, key: String) -> String {
    let mut resulting_string: String = String::new();
    let new_key: String = key.repeat(repeat_key_char(&word, &key));
    let (usable_word, space_index) = remove_whitespace(&word);
    
    for (i, c) in usable_word.chars().enumerate() {
        let pos_of_c: i8 = get_char_position(&c);
        let pos_of_key_c: i8 = get_char_position(&new_key. chars().nth(i).unwrap());
        let result: i8 = (pos_of_c - pos_of_key_c). rem_euclid(26);
        let encrypted_char: char = ALPHABETS[result. abs() as usize];
        resulting_string.push(encrypted_char);
    }
    
    resulting_string = add_whitespace(&resulting_string, &space_index);
    return resulting_string;
}

The critical line is (pos_of_c - pos_of_key_c).rem_euclid(26). In Rust, the standard % operator can return negative values. For example, (-3) % 26 = -3, but we need the positive equivalent: 23. The rem_euclid method handles this correctly, always returning a positive remainder in the range [0, 26).

Development Evolution

The commit history reveals an iterative refinement process:

  1. Initial Implementation (June 18, 2021): Core Vigenère cipher with basic encryption/decryption
  2. Whitespace Support (June 19, 2021): Added the remove/add whitespace functionality after recognizing the limitation

This second commit demonstrates the debugging cycle: implement → test with real input → identify edge case → design solution → implement fix. The whitespace preservation feature required careful indexing to maintain position accuracy.

Trade-offs and Limitations

What's Implemented:

  • Full Vigenère cipher encryption and decryption
  • Whitespace preservation
  • Interactive CLI with menu selection
  • Educational information about cipher algorithms

Intentional Limitations:

  • Only lowercase letters are supported (no uppercase, numbers, or special characters)
  • DES implementation remains a work-in-progress placeholder
  • No file I/O; operates only on user-inputted strings
  • Linear search for character positions (acceptable for 26-character alphabet)

These limitations are acceptable for a learning project focused on understanding cryptographic principles rather than production deployment.

Key Takeaways

This project deepened my understanding of:

  1. Modular arithmetic in practice: The difference between % and rem_euclid() in Rust, and why it matters for cryptographic operations
  2. String manipulation efficiency: Balancing readability vs. performance in character-by-character operations
  3. User experience design: Creating intuitive CLI interactions using Rust's ecosystem (structopt, dialoguer)
  4. Edge case handling: Discovering and solving the whitespace preservation problem through testing

Building cryptographic algorithms from first principles provided invaluable insight into the mathematical foundations that underpin modern security systems, even if production systems should always rely on audited cryptographic libraries.

Copyright © Rahul Hathwar. All Rights Reserved.