16 KiB
System Architecture
This document breaks down how the system is designed and why certain architectural decisions were made.
High Level Architecture
┌─────────────────┐
│ CLI Layer │ (main.py)
│ - encrypt cmd │ Typer commands, Rich output
│ - decrypt cmd │
│ - crack cmd │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Cipher Layer │ (cipher.py)
│ - CaesarCipher │ Core algorithm
│ - shift logic │ Encryption/decryption
└────────┬────────┘
│
▼
┌─────────────────┐
│ Analysis Layer │ (analyzer.py)
│ - chi-squared │ Statistical scoring
│ - rank results │ Frequency comparison
└─────────────────┘
│
▼
┌─────────────────┐
│ Utils Layer │ (utils.py, constants.py)
│ - file I/O │ Support functions
│ - validation │ Reference data
└─────────────────┘
Component Breakdown
CLI Layer (main.py)
- Purpose: User-facing commands that parse arguments and call core functions
- Responsibilities: Input validation, file handling, formatted output with Rich tables
- Interfaces: Exposes three commands via Typer (encrypt, decrypt, crack)
Cipher Layer (cipher.py)
- Purpose: Implements the actual Caesar cipher algorithm
- Responsibilities: Character shifting, key validation, brute force generation
- Interfaces:
CaesarCipherclass withencrypt(),decrypt(), and staticcrack()method
Analysis Layer (analyzer.py)
- Purpose: Statistical analysis to identify correct decryptions
- Responsibilities: Chi-squared calculation, candidate ranking by English frequency
- Interfaces:
FrequencyAnalyzerclass that scores and ranks text
Utils Layer (utils.py, constants.py)
- Purpose: Shared functionality and reference data
- Responsibilities: Reading from files/stdin, writing output, storing English letter frequencies
- Interfaces: Standalone functions and constants used across other layers
Data Flow
Encryption Flow
Step by step walkthrough of encrypting text:
1. User Input → CLI Parser (main.py:34-56)
Validates key is 0-25, reads text from arg/file/stdin
2. CLI → CaesarCipher (cipher.py:13-28)
Creates cipher instance with validated key
3. CaesarCipher → Encryption Loop (cipher.py:43-46)
Shifts each character, preserves non-letters
4. Encrypted Text → Output (main.py:56-60)
Writes to file or prints with Rich formatting
Example with code references:
1. User runs: caesar-cipher encrypt "HELLO" --key 3
main.py:34 → validates key with utils.validate_key()
main.py:47 → reads input via read_input()
2. main.py:48 → Creates CaesarCipher(key=3)
cipher.py:16 → Stores key % 26 to handle wrapping
3. main.py:49 → Calls cipher.encrypt("HELLO")
cipher.py:43-46 → Iterates each char with _shift_char()
cipher.py:31-38 → Shifts H→K, E→H, L→O, L→O, O→R
4. main.py:52 → Outputs "KHOOR" via console.print()
Cracking Flow
More complex: tries all keys and ranks by frequency.
1. User Input → CLI (main.py:108-128)
Reads ciphertext, gets options (--top N, --all)
2. CLI → Brute Force (cipher.py:53-60)
Generates all 26 possible decryptions
3. Candidates → Frequency Analysis (analyzer.py:53-60)
Scores each with chi-squared test
4. Ranked Results → Table Output (main.py:135-148)
Displays top matches in Rich table
Design Patterns
Strategy Pattern (Implicit)
What it is: Separating the algorithm (cipher operations) from its application (CLI commands).
Where we use it:
The CaesarCipher class in cipher.py is independent of how it's invoked. You could use it from a web API, GUI, or CLI without changing the cipher code.
Why we chose it:
Separation of concerns makes testing easier. The cipher logic has zero dependencies on Typer or Rich. You can test cipher.py without dealing with command line argument parsing.
Trade-offs:
- Pros: Clean interfaces, easy to test, reusable components
- Cons: More files than a single script, slightly more complex for a simple project
Factory Pattern (Static Methods)
What it is:
Using @staticmethod to create variations without instantiation.
Where we use it:
# cipher.py:52-60
@staticmethod
def crack(ciphertext: str) -> list[tuple[int, str]]:
results = []
for shift in range(ALPHABET_SIZE):
cipher = CaesarCipher(key=shift)
decrypted = cipher.decrypt(ciphertext)
results.append((shift, decrypted))
return results
Why we chose it:
The crack() method doesn't belong to any particular key value. It generates all possible instances. Making it static makes the API clearer: CaesarCipher.crack() reads like "try all Caesar keys" without needing an instance.
Layer Separation
┌────────────────────────────────────┐
│ CLI Layer (main.py) │
│ - User interaction │
│ - Does not do crypto │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ Business Logic (cipher.py) │
│ - Core algorithm │
│ - Does not know about CLI │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ Support (utils.py) │
│ - Generic helpers │
│ - No domain logic │
└────────────────────────────────────┘
Why Layers?
Prevents dependencies from becoming circular. CLI can import cipher, but cipher doesn't import CLI. This means:
- You can test the cipher without mocking Typer
- You could use the cipher in a different interface (web, GUI) without modification
- Changes to output formatting don't affect cryptographic correctness
What Lives Where
CLI Layer (main.py):
- Files:
main.py - Imports: Can import from cipher, analyzer, utils
- Forbidden: No crypto logic, no direct alphabet manipulation
Business Layer (cipher.py, analyzer.py):
- Files:
cipher.py,analyzer.py - Imports: Can import constants, utils. Cannot import main.
- Forbidden: No command line parsing, no Rich formatting
Support Layer (utils.py, constants.py):
- Files:
utils.py,constants.py - Imports: Standard library only
- Forbidden: No domain logic, stays generic
Data Models
CaesarCipher
# cipher.py:13-28
class CaesarCipher:
def __init__(self, key: int, alphabet: str | None = None) -> None:
if not -25 <= key <= 26:
raise ValueError("Key must be between -25 and 26")
self.key = key % ALPHABET_SIZE
self.alphabet = alphabet or (UPPERCASE_LETTERS + LOWERCASE_LETTERS)
Fields explained:
key: The shift amount, normalized to 0-25 via modulo. Storing it this way means encryption never has to handle wrapping again.alphabet: Normally just A-Z + a-z, but configurable for custom alphabets. Not used in CLI but extensible.
Relationships:
- Uses constants from
constants.py(ALPHABET_SIZE, letter sets) - Used by all three CLI commands
- Has no dependencies on analyzer (one-way relationship)
FrequencyAnalyzer
# analyzer.py:11-16
class FrequencyAnalyzer:
def __init__(self) -> None:
self.reference_frequencies = ENGLISH_LETTER_FREQUENCIES
Fields explained:
reference_frequencies: Dictionary mapping 'A'-'Z' to their expected percentages in English text. Loaded fromconstants.py:17-43.
Relationships:
- Used only by the
crackcommand - Operates on output from
CaesarCipher.crack() - No dependencies on the cipher itself
Security Architecture
Threat Model
What we're protecting against:
- None - This is an educational tool. The cipher is intentionally weak.
- Input Validation - Prevents crashes from bad keys or missing input
- File Injection - Uses Path objects and explicit encoding to avoid issues
What we're NOT protecting against (out of scope):
- Cryptanalysis - The cipher is meant to be broken
- Side-channel attacks - This is Python, timing isn't constant
- Key recovery - The key space is trivially small
Defense Layers
This project doesn't have security defenses because it's teaching cryptanalysis, not building secure crypto. But it does have input validation:
Layer 1: Key validation (utils.py:36-40, cipher.py:19-22)
↓
Layer 2: Input source validation (utils.py:11-24)
↓
Layer 3: File encoding (utils.py:17, 32)
The validation ensures the program doesn't crash, but there's no security boundary. Don't use Caesar cipher for actual secrets.
Storage Strategy
No Persistent Storage
This tool is stateless. Everything happens in memory. Input comes from arguments/files/stdin, output goes to stdout/files, and nothing is saved.
Why this choice: Simplicity. There's no need to track history or save state. Each command is independent.
Configuration
Environment Variables
None. The project uses command line arguments exclusively.
Configuration Strategy
Development:
pip install -e . # Editable install
All configuration is in pyproject.toml. Dependencies, linter settings, test config all live there.
Production:
pip install . # Regular install
Same configuration. This isn't deployed to production because it's a teaching tool, but if it were, the config doesn't change.
Performance Considerations
Bottlenecks
Where this system gets slow under load:
- Frequency analysis on huge files - The chi-squared calculation is O(n) where n is text length. For multi-MB files, this adds up when run 26 times.
- Rich table rendering - Printing 26 rows of output is slower than plain text.
Neither matters in practice. The cipher itself is so fast that I/O dominates.
Optimizations
What we did to make it faster:
- List comprehension in encrypt(): Using
"".join(self._shift_char(char, self.key) for char in plaintext)incipher.py:46instead of building a list and joining is more memory efficient. - Early return in chi-squared: If there are no letters at all, return infinity immediately (analyzer.py:33) instead of trying to calculate on empty data.
Scalability
Vertical scaling: Doesn't apply. Single-threaded Python processing text. More CPU doesn't help.
Horizontal scaling: You could parallelize the crack command to try all 26 shifts in parallel, but it's already instant. Not worth the complexity.
Design Decisions
Decision 1: Separate Cipher and Analyzer Classes
What we chose:
Keep encryption logic in CaesarCipher, statistical analysis in FrequencyAnalyzer.
Alternatives considered:
- Put everything in one class - Rejected because mixing crypto and cryptanalysis in the same object is conceptually wrong
- Make analyzer functions instead of a class - Rejected because the reference frequencies are shared state
Trade-offs: We get cleaner separation and better testability. The cost is two imports instead of one when you want to crack messages.
Decision 2: CLI with Typer Instead of argparse
What we chose: Use Typer for automatic help generation and type hints.
Alternatives considered:
- argparse (stdlib) - More verbose, no type hints
- click - Similar to Typer but without the type hint magic
Trade-offs: Typer gives clean code at the cost of an extra dependency. For a learning project, the better code readability is worth it.
Decision 3: Preserve Case and Non-Letters
What we chose: Encrypt only A-Z and a-z, leave spaces/punctuation/numbers unchanged.
Alternatives considered:
- Convert everything to uppercase - Loses information, makes output uglier
- Encrypt spaces too - Historical Caesar didn't do this, less authentic
Trade-offs: Preserving case makes the output more readable but slightly complicates the shifting logic (need to check uppercase vs lowercase separately).
Deployment Architecture
This is a local CLI tool, not a deployed service. Installation is via pip:
pip install caesar-salad-cipher
The pyproject.toml:31-32 entry point makes the command available:
[project.scripts]
caesar-cipher = "caesar_cipher.main:app"
After installation, caesar-cipher is in your PATH and calls main.py:app().
Error Handling Strategy
Error Types
-
Invalid Key - Key outside -25 to 26 range
- Raised by
cipher.py:20-22andutils.py:36-40 - Caught in
main.py:59, 97, 152and printed as error
- Raised by
-
Missing Input - No text provided
- Raised by
utils.py:23if all sources (arg, file, stdin) are empty - Caught in main commands
- Raised by
-
File Not Found - Input file doesn't exist
- Raised by
Path.read_text()inutils.py:17 - Caught generically as OSError
- Raised by
Recovery Mechanisms
There's no automatic recovery. The tool exits with code 1 on error:
# main.py:59-60
except (ValueError, OSError) as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(code=1) from None
Why exit instead of retry: It's a CLI tool. If the user gave a bad key, they need to fix it and run again. No point staying alive.
Extensibility
Where to Add Features
Want to add support for numbers? Here's where it goes:
- Modify
constants.pyto include '0'-'9' in the alphabet - Update
cipher.py:31-40_shift_char() to handle digits - Adjust tests in
test_cipher.pyto verify digit shifting
Want to add Vigenère cipher?
- Create
vigenere.pywith a similar class structure - Add
vigenerecommand inmain.py - Reuse
utils.pyfor I/O, but frequency analysis won't work (Vigenère is polyalphabetic)
Limitations
Current architectural limitations:
- Only works on Latin alphabet - No support for Cyrillic, Arabic, or ideographic scripts. Fixing this would require multi-alphabet constants and different frequency tables.
- No key derivation - The key is literally just a number. Can't use passwords. Would need a KDF (but that's overkill for Caesar).
- Single-threaded - Can't take advantage of multiple cores. Not worth fixing when crack() runs in under a millisecond anyway.
These are not bugs, they're conscious trade-offs. The project is for learning classical crypto, not building production tools.
Comparison to Similar Systems
ROT13 Online Tools
How we're different:
- ROT13 tools only do shift=13. We support any key 0-25.
- We have frequency analysis built in for cracking.
Why we made different choices: ROT13 is a special case. We're teaching the general algorithm and how to break it.
CyberChef
CyberChef is a Swiss Army knife with dozens of encodings including Caesar. Our tool is purpose-built for learning cryptanalysis, so we include the statistical scoring and ranking that CyberChef doesn't emphasize.
Key Files Reference
Quick map of where to find things:
src/caesar_cipher/cipher.py- Core algorithm: _shift_char(), encrypt(), decrypt(), crack()src/caesar_cipher/analyzer.py- Chi-squared calculation and candidate rankingsrc/caesar_cipher/main.py- CLI commands and Rich table formattingsrc/caesar_cipher/constants.py- English letter frequencies (the key to breaking Caesar)tests/test_cipher.py- Encryption/decryption roundtrip teststests/test_analyzer.py- Frequency analysis correctness tests
Next Steps
Now that you understand the architecture:
- Read 03-IMPLEMENTATION.md for code walkthrough
- Try modifying the shift algorithm to rotate in reverse (negative keys already work, but make the default behavior different)