Cybersecurity-Projects/PROJECTS/beginner/caesar-cipher/learn/03-IMPLEMENTATION.md

616 lines
19 KiB
Markdown

# Implementation Guide
This document walks through the actual code. We'll build key features step by step and explain the decisions along the way.
## File Structure Walkthrough
```
caesar-cipher/
├── src/caesar_cipher/
│ ├── __init__.py # Package exports for CaesarCipher and FrequencyAnalyzer
│ ├── cipher.py # Core encryption/decryption logic (62 lines)
│ ├── analyzer.py # Chi-squared frequency analysis (60 lines)
│ ├── constants.py # English letter frequencies, alphabet (43 lines)
│ ├── main.py # Typer CLI with 3 commands (171 lines)
│ └── utils.py # File I/O and validation (40 lines)
├── tests/
│ ├── test_cipher.py # Cipher tests with edge cases
│ ├── test_analyzer.py # Frequency analysis tests
│ └── test_cli.py # End-to-end CLI tests
└── pyproject.toml # Dependencies and tool config
```
## Building the Core Cipher
### Step 1: Character Shifting
What we're building: The fundamental operation that takes one letter and shifts it by N positions.
In `cipher.py:30-40`:
```python
def _shift_char(self, char: str, shift: int) -> str:
"""
Shift a single character by the specified amount while preserving case
"""
if char in UPPERCASE_LETTERS:
idx = UPPERCASE_LETTERS.index(char)
return UPPERCASE_LETTERS[(idx + shift) % ALPHABET_SIZE]
if char in LOWERCASE_LETTERS:
idx = LOWERCASE_LETTERS.index(char)
return LOWERCASE_LETTERS[(idx + shift) % ALPHABET_SIZE]
return char
```
**Why this code works:**
- Line 35-36: Uppercase letters shift within uppercase. 'A' + 3 = 'D'. The modulo handles wrapping ('Z' + 1 = 'A').
- Line 37-39: Lowercase letters shift within lowercase independently. Preserves case.
- Line 40: Non-letters return unchanged. Spaces, punctuation, numbers pass through.
**Common mistakes here:**
```python
# Wrong approach - converts everything to uppercase
def shift_char(char, shift):
if char.isalpha():
idx = ord(char.upper()) - ord('A')
return chr((idx + shift) % 26 + ord('A'))
return char
# Why this fails: "Hello" becomes "KHOOR" instead of "Khoor"
```
You need separate handling for upper and lower case. The code uses two different alphabet strings (`UPPERCASE_LETTERS` and `LOWERCASE_LETTERS` from constants.py) to preserve case naturally.
### Step 2: Full Message Encryption
Now we need to apply the shift to every character in the message.
In `cipher.py:43-46`:
```python
def encrypt(self, plaintext: str) -> str:
"""
Encrypt plaintext using the configured shift key
"""
return "".join(self._shift_char(char, self.key) for char in plaintext)
```
**What's happening:**
1. Generator expression iterates each character: `for char in plaintext`
2. Calls `_shift_char()` with the instance's key
3. Joins all results into a string
**Why we do it this way:**
This is more memory efficient than building a list and joining. For huge texts, the generator yields characters one at a time instead of storing all shifted characters in memory.
**Alternative approaches:**
- Loop with `result += shifted_char`: Works but creates intermediate strings (O(n²) time in the worst case)
- List comprehension then join: `"".join([...])`: Uses more memory but same speed
### Step 3: Decryption (The Inverse Operation)
In `cipher.py:48-52`:
```python
def decrypt(self, ciphertext: str) -> str:
"""
Decrypt ciphertext using the configured shift key
"""
return "".join(self._shift_char(char, -self.key) for char in ciphertext)
```
**Key parts explained:**
Decryption is just encryption with a negative key. If we encrypted with +3, we decrypt with -3. The `_shift_char()` method handles negative shifts automatically via modulo arithmetic:
```
'K' is position 10
Shift by -3: (10 + (-3)) % 26 = 7 % 26 = 7
Position 7 is 'H'
```
Python's modulo operator handles negatives correctly: `(-1) % 26 = 25`, which wraps 'A' back to 'Z'.
## Building the Brute Force Cracker
### The Problem
You have ciphertext but don't know the key. With only 26 possibilities, try them all.
### The Solution
Generate all 26 decryptions and let frequency analysis pick the best one.
### Implementation
In `cipher.py:53-60`:
```python
@staticmethod
def crack(ciphertext: str) -> list[tuple[int, str]]:
"""
Attempt all possible shifts to decrypt ciphertext without knowing the key
"""
results = []
for shift in range(ALPHABET_SIZE):
cipher = CaesarCipher(key=shift)
decrypted = cipher.decrypt(ciphertext)
results.append((shift, decrypted))
return results
```
**Why static method:**
This doesn't operate on a specific instance. It generates all possible instances. Making it static makes the intent clear: `CaesarCipher.crack(text)` is like asking "what are all Caesar decryptions of this text?"
**Optimization we didn't do:**
We could avoid creating 26 cipher objects and just call `_shift_char()` directly. But clarity beats micro-optimization here. Creating 26 objects is negligible.
## Building Frequency Analysis
### The Problem
When you crack a message, you get 26 candidates. Which one is real English?
### The Solution
Score each candidate by how closely its letter frequencies match known English frequencies. Lower chi-squared scores mean better matches.
### Implementation
In `analyzer.py:27-42`:
```python
def calculate_chi_squared(self, text: str) -> float:
"""
Calculate chi-squared statistic comparing text to expected English frequencies
"""
text_upper = text.upper()
letter_counts = Counter(char for char in text_upper if char.isalpha())
if not letter_counts:
return float("inf")
total_letters = sum(letter_counts.values())
chi_squared = 0.0
for letter, expected_freq in self.reference_frequencies.items():
observed_count = letter_counts.get(letter, 0)
expected_count = (expected_freq / 100) * total_letters
if expected_count > 0:
chi_squared += ((observed_count - expected_count)**2) / expected_count
return chi_squared
```
**Breaking it down:**
**Lines 29-30:** Convert to uppercase and count only letters. `Counter` gives us `{'H': 1, 'E': 1, 'L': 2, 'O': 1}` for "HELLO".
**Lines 32-33:** Empty text has infinite badness. Can't score nothing.
**Line 35:** Total count needed to convert percentages to expected counts.
**Lines 38-41:** For each letter A-Z, compare observed vs expected:
- Expected: If the text has 100 letters and E should be 12.7%, we expect 12.7 letters of E
- Observed: How many E's are actually there
- Chi-squared adds up squared differences normalized by expected values
Lower scores mean closer to English. Gibberish has wild frequency distributions and scores high.
### Ranking Candidates
In `analyzer.py:53-60`:
```python
def rank_candidates(self, candidates: list[tuple[int, str]]) -> list[tuple[int, str, float]]:
"""
Rank decryption candidates by their English frequency score
"""
scored = [
(shift, text, self.score_text(text))
for shift, text in candidates
]
return sorted(scored, key=lambda x: x[2])
```
Takes list of `(shift, decrypted_text)` tuples, adds scores, sorts by score (ascending, so best is first).
## CLI Implementation
### Command Structure
The tool has three commands: encrypt, decrypt, crack. All use similar patterns.
**Encrypt command** (`main.py:24-60`):
```python
@app.command()
def encrypt(
text: Annotated[str | None, typer.Argument(...)] = None,
key: Annotated[int, typer.Option("--key", "-k", ...)] = 3,
input_file: Annotated[Path | None, typer.Option(...)] = None,
output_file: Annotated[Path | None, typer.Option(...)] = None,
quiet: Annotated[bool, typer.Option(...)] = False,
) -> None:
```
**The Annotated syntax:**
Typer uses type hints to generate the CLI. `Annotated[str | None, typer.Argument(...)]` means "this is an optional string argument". The help text is in the `Argument()` call.
**Validation flow:**
```python
try:
validate_key(key) # utils.py:36
plaintext = read_input(text, input_file) # utils.py:11
cipher = CaesarCipher(key=key)
encrypted = cipher.encrypt(plaintext)
# ... output ...
except (ValueError, OSError) as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(code=1) from None
```
**Error handling:**
Catch both `ValueError` (from validation) and `OSError` (from file operations). Print error in red using Rich, then exit with code 1. The `from None` suppresses the exception chain in the output.
### Input Handling
Three ways to provide input: command line argument, file, or stdin.
In `utils.py:11-24`:
```python
def read_input(text: str | None, input_file: Path | None) -> str:
"""
Read input from text argument, file, or stdin
"""
if text:
return text
if input_file:
return input_file.read_text(encoding="utf-8")
if not sys.stdin.isatty():
return sys.stdin.read()
raise ValueError("No input provided. Use TEXT argument, --input-file, or pipe to stdin")
```
**Priority order:**
1. Explicit text argument
2. Input file
3. Stdin (but only if it's piped, not interactive terminal)
**The `sys.stdin.isatty()` check:**
Returns `False` when input is piped: `echo "test" | caesar-cipher encrypt`. Returns `True` when running interactively. We only read stdin if it's piped to avoid hanging waiting for user input that won't come.
### Rich Table Output
The crack command displays results in a formatted table.
In `main.py:135-148`:
```python
table = Table(title="Caesar Cipher Brute Force Results")
table.add_column("Rank", style="cyan", justify="right")
table.add_column("Shift", style="magenta", justify="right")
table.add_column("Score", style="yellow", justify="right")
table.add_column("Decrypted Text", style="green")
display_count = len(ranked) if show_all else min(top, len(ranked))
for rank, (shift, text_result, score) in enumerate(ranked[:display_count], 1):
table.add_row(
str(rank),
str(shift),
f"{score:.2f}",
text_result[:80] # Truncate long text
)
console.print(table)
```
**Why Rich:**
Colored output makes it obvious which columns are which. The table auto-formats and aligns columns. Much nicer than printing tab-separated values.
**Truncation:**
`text_result[:80]` limits displayed text to 80 characters. Otherwise really long decryptions make the table unreadable.
## Testing Strategy
### Unit Tests for Cipher
Example test for encryption (`test_cipher.py:14-16`):
```python
def test_encrypt_basic(self) -> None:
cipher = CaesarCipher(key=3)
assert cipher.encrypt("HELLO") == "KHOOR"
```
**What this tests:**
Basic shift functionality. If this fails, everything is broken.
**Edge cases tested:**
- Wraparound: `test_alphabet_wraparound_uppercase()` checks 'XYZ' → 'ABC'
- Case preservation: `test_encrypt_mixed_case()` checks "Hello World" → "Khoor Zruog"
- Non-letters: `test_encrypt_preserves_punctuation()` checks "Hello!" → "Khoor!"
- Empty string: `test_empty_string()` checks "" → ""
**Roundtrip test** (`test_cipher.py:42-46`):
```python
def test_encrypt_decrypt_roundtrip(self) -> None:
cipher = CaesarCipher(key=13)
original = "The Quick Brown Fox Jumps Over The Lazy Dog!"
encrypted = cipher.encrypt(original)
decrypted = cipher.decrypt(encrypted)
assert decrypted == original
```
This catches asymmetry bugs. If encrypt and decrypt aren't true inverses, this fails.
### Integration Tests for Analyzer
Testing that frequency analysis actually picks the right answer (`test_analyzer.py:44-54`):
```python
def test_rank_candidates_with_actual_cipher(self) -> None:
cipher = CaesarCipher(key=3)
plaintext = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
ciphertext = cipher.encrypt(plaintext)
candidates = CaesarCipher.crack(ciphertext)
analyzer = FrequencyAnalyzer()
ranked = analyzer.rank_candidates(candidates)
best_shift, best_text, _best_score = ranked[0]
assert best_shift == 3
assert best_text == plaintext
```
This is an integration test because it uses both cipher and analyzer together. It verifies the whole crack workflow works end to end.
### CLI Tests
Testing the actual commands (`test_cli.py:14-18`):
```python
def test_encrypt_basic(self) -> None:
result = runner.invoke(app, ["encrypt", "HELLO", "--key", "3"])
assert result.exit_code == 0
assert "KHOOR" in result.stdout
```
Uses Typer's test runner to simulate command line invocation. Checks exit code and output.
**File I/O test** (`test_cli.py:63-73`):
```python
def test_encrypt_from_file(self, tmp_path: Path) -> None:
input_file = tmp_path / "input.txt"
input_file.write_text("HELLO WORLD")
result = runner.invoke(
app,
["encrypt", "--input-file", str(input_file), "--key", "3"]
)
assert result.exit_code == 0
assert "KHOOR ZRUOG" in result.stdout
```
Creates a temp file, reads from it, verifies output. This tests the file handling path without leaving artifacts.
## Common Implementation Pitfalls
### Pitfall 1: Forgetting to Handle Negative Keys
**Symptom:**
Crash or wrong output when using `--key -3`.
**Cause:**
```python
# Problematic code - doesn't normalize negative keys
def __init__(self, key: int):
self.key = key # If key=-3, modulo in _shift_char breaks
```
**Fix:**
```python
# Correct approach (cipher.py:23)
self.key = key % ALPHABET_SIZE
```
Normalizing during construction means all other code can assume key is 0-25.
**Why this matters:**
Caesar cipher with key=-3 is the same as key=23. Both shift left by 3. Normalizing makes them equivalent.
### Pitfall 2: Not Handling Empty Input
**Symptom:**
Division by zero in chi-squared calculation.
**Cause:**
```python
# Bad - crashes if text is empty
def calculate_chi_squared(text):
total = sum(counts.values())
# ... expected_count / total will divide by zero
```
**Fix:**
```python
# Good - early return (analyzer.py:32-33)
if not letter_counts:
return float("inf")
```
Empty text has infinitely bad frequency match. Can't be English if there are no letters.
### Pitfall 3: Mixing Up Encryption and Decryption
**Symptom:**
`decrypt("KHOOR")` with key=3 gives gibberish instead of "HELLO".
**Cause:**
```python
# Wrong - decrypt shifts forward instead of backward
def decrypt(self, ciphertext):
return self.encrypt(ciphertext) # This is just double encryption!
```
**Fix:**
```python
# Right - decrypt shifts backward (cipher.py:51)
return "".join(self._shift_char(char, -self.key) for char in ciphertext)
```
Decryption must reverse the shift. Negative key does this.
## Debugging Tips
### Issue Type 1: Frequency Analysis Gives Wrong Answer
**Problem:** The best ranked candidate isn't the correct decryption.
**How to debug:**
1. Check `constants.py:17-43` - Are the English frequencies correct?
2. Print scores for all 26 candidates - Is the correct one close to the top?
3. Try with longer ciphertext - Short text doesn't have enough letters for reliable frequency analysis
**Common causes:**
- Text is too short (under 50 letters)
- Text isn't English (frequency analysis assumes English)
- Text has unusual word distribution (technical jargon, names)
### Issue Type 2: CLI Hangs When Running Command
**Problem:** `caesar-cipher encrypt --key 3` hangs forever.
**How to debug:**
1. Check if you forgot to provide input text
2. Look at `utils.py:20-21` - If stdin isn't a TTY, it waits for piped input
3. Either provide text: `caesar-cipher encrypt "HELLO" --key 3` or pipe it: `echo "HELLO" | caesar-cipher encrypt --key 3`
**Common cause:**
Running the command without text and without piping stdin. The code waits for input that never comes.
## Code Organization Principles
### Why cipher.py is Independent
```
cipher.py
├── Only imports from constants
├── No CLI dependencies
└── No analyzer dependencies
```
We separate the cipher from its applications because:
- You can unit test encryption without mocking CLI
- The cipher could be used in a web API without changing anything
- Testing focuses on algorithm correctness, not I/O
This makes the codebase easier to reason about. If tests in `test_cipher.py` pass, the algorithm is correct regardless of how it's invoked.
### Naming Conventions
- `_shift_char` (leading underscore) = Internal helper method, not part of public API
- `UPPERCASE_LETTERS` (all caps) = Constant that shouldn't change
- `crack` (verb) = Methods are actions
Following these patterns makes it easier to distinguish between public API, internal implementation, and configuration.
## Extending the Code
### Adding a New Alphabet
Want to support numbers in encryption? Here's the process:
1. **Modify constants** in `constants.py:10-11`
```python
DIGITS = string.digits
ALL_CHARS = UPPERCASE_LETTERS + LOWERCASE_LETTERS + DIGITS
```
2. **Update _shift_char** in `cipher.py:30-40`
```python
def _shift_char(self, char: str, shift: int) -> str:
if char in UPPERCASE_LETTERS:
idx = UPPERCASE_LETTERS.index(char)
return UPPERCASE_LETTERS[(idx + shift) % 26]
if char in LOWERCASE_LETTERS:
idx = LOWERCASE_LETTERS.index(char)
return LOWERCASE_LETTERS[(idx + shift) % 26]
if char in DIGITS:
idx = DIGITS.index(char)
return DIGITS[(idx + shift) % 10] # 10 digits
return char
```
3. **Add tests** in `test_cipher.py`
```python
def test_encrypt_digits(self) -> None:
cipher = CaesarCipher(key=3)
assert cipher.encrypt("Test123") == "Whvw456"
```
The architecture makes this easy because cipher logic is isolated from everything else.
## Dependencies
### Why Each Dependency
- **typer** (0.20.0): CLI framework with automatic help and type hints. Better than argparse for modern Python.
- **rich** (14.2.0): Colored terminal output and table formatting. Makes the crack command output readable.
- **pytest** (9.0.2): Test framework. Standard for Python testing.
- **mypy** (1.19.0): Static type checker. Catches type errors before runtime.
- **ruff** (0.14.8): Fast linter combining many tools. Replaces flake8, isort, black.
### Dependency Security
Check for vulnerabilities:
```bash
pip install pip-audit
pip-audit
```
If you see a vulnerability in typer or rich, check if there's a newer version that fixes it. Update the version constraints in `pyproject.toml:7-9`.
## Build and Deploy
### Building
```bash
# Install in development mode
pip install -e .
# Or build a wheel
pip install build
python -m build
```
This produces a wheel in `dist/` that can be installed anywhere.
### Local Development
```bash
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Run linter
ruff check src/ tests/
# Run type checker
mypy src/
```
The `[dev]` extra includes testing and linting tools from `pyproject.toml:10-17`.
### Production Deployment
For a CLI tool, "production" means publishing to PyPI:
```bash
# Build
python -m build
# Upload to PyPI
python -m twine upload dist/*
```
Then users install with `pip install caesar-salad-cipher` and get the `caesar-cipher` command in their PATH.
## Next Steps
You've seen how the code works. Now:
1. **Try the challenges** - [04-CHALLENGES.md](./04-CHALLENGES.md) has extension ideas like Vigenère cipher
2. **Modify the code** - Change the frequency scoring to use bigrams (two-letter pairs) instead of single letters
3. **Read related projects** - Look at real-world frequency analysis tools for inspiration