Classical Ciphers With Chi-Squared Frequency Analysis for Auto-Decryption
Caesar cipher is 3 lines. Vigenère is 10. Atbash is 1. Rail fence takes 20. The interesting part isn't implementing them — it's the auto-decryption: given ciphertext with no key, try all 26 Caesar shifts, score each by how "English-like" it is using chi-squared distance vs expected letter frequencies, and report the best match.
Classical ciphers aren't used for real security anymore, but they're perfect for learning about cryptography fundamentals: alphabet operations, keyspaces, brute force, and the first baby steps of cryptanalysis.
🔗 Live demo: https://sen.ltd/portfolio/caesar-cipher/
📦 GitHub: https://github.com/sen-ltd/caesar-cipher
Features:
- 5 ciphers: Caesar, ROT13, Vigenère, Atbash, Rail Fence
- Brute force Caesar (show all 26 shifts)
- Letter frequency bar chart
- Auto-detect likely Caesar shift via chi-squared
- Encode + decode
- Japanese / English UI
- Zero dependencies, 46 tests
Caesar cipher in one function
export function caesar(text, shift) {
shift = ((shift % 26) + 26) % 26; // handle negative
return [...text].map(c => {
if (c >= 'a' && c <= 'z') {
return String.fromCharCode((c.charCodeAt(0) - 97 + shift) % 26 + 97);
}
if (c >= 'A' && c <= 'Z') {
return String.fromCharCode((c.charCodeAt(0) - 65 + shift) % 26 + 65);
}
return c; // non-letter unchanged
}).join('');
}
Preserves case, leaves digits/punctuation/Unicode unchanged. ROT13 is just caesar(text, 13) — and since 13 × 2 = 26 ≡ 0, applying it twice returns the original. That's why ROT13 is an involution — the same function both encrypts and decrypts.
Vigenère: key-shifted Caesar
export function vigenere(text, key, direction = 1) {
if (!key) return text;
const k = key.toUpperCase();
let ki = 0;
return [...text].map(c => {
if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') {
const shift = (k.charCodeAt(ki % k.length) - 65) * direction;
ki++;
return caesar(c, shift);
}
return c;
}).join('');
}
The key "LEMON" shifts by L(11), E(4), M(12), O(14), N(13) cyclically. Encryption uses direction = +1, decryption uses -1. Non-letters don't advance the key index so the alignment stays consistent.
Atbash: one-liner involution
export function atbash(text) {
return [...text].map(c => {
if (c >= 'a' && c <= 'z') return String.fromCharCode(219 - c.charCodeAt(0));
if (c >= 'A' && c <= 'Z') return String.fromCharCode(155 - c.charCodeAt(0));
return c;
}).join('');
}
219 = 'a'.charCodeAt(0) + 'z'.charCodeAt(0) (97 + 122). So 219 - c maps a → z, b → y, ... z → a. Same trick with 155 for uppercase. Atbash is also an involution — applying it twice gives the original.
Chi-squared scoring
The trick for auto-decryption: compare observed letter frequencies against expected English frequencies using chi-squared distance:
const ENGLISH_FREQ = {
A: 8.167, B: 1.492, C: 2.782, D: 4.253, E: 12.702,
// ... 26 total, summing to ~100
};
export function scoreEnglishText(text) {
const counts = letterFrequency(text);
const total = Object.values(counts).reduce((a, b) => a + b, 0);
if (total === 0) return Infinity;
let chiSquared = 0;
for (const [letter, expected] of Object.entries(ENGLISH_FREQ)) {
const observed = counts[letter] || 0;
const expectedCount = (expected / 100) * total;
chiSquared += Math.pow(observed - expectedCount, 2) / expectedCount;
}
return chiSquared;
}
Lower = more English-like. For each of the 26 possible Caesar shifts, decrypt with that shift and compute the score. The shift with the lowest score is probably the correct one.
export function detectBestShift(text) {
let bestShift = 0;
let bestScore = Infinity;
for (let shift = 0; shift < 26; shift++) {
const score = scoreEnglishText(caesar(text, -shift));
if (score < bestScore) {
bestScore = score;
bestShift = shift;
}
}
return bestShift;
}
This is the most basic cryptanalysis technique, but it works reliably on any text longer than a sentence or two. For Vigenère you'd need the Kasiski examination (detect the key length first), then apply chi-squared per column.
Rail fence (columnar transposition)
export function railFence(text, rails, encode = true) {
if (rails === 1) return text;
if (encode) {
const lines = Array(rails).fill('').map(() => '');
let rail = 0, dir = 1;
for (const c of text) {
lines[rail] += c;
rail += dir;
if (rail === 0 || rail === rails - 1) dir = -dir;
}
return lines.join('');
} else {
// decode: figure out the zigzag pattern, place chars
// ...
}
}
Writes the text in a zigzag across N rails, then reads each rail top to bottom. Decoding reverses the process — which is more complex than encoding because you have to figure out which source index each destination index came from.
Series
This is entry #82 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/caesar-cipher
- 🌐 Live: https://sen.ltd/portfolio/caesar-cipher/
- 🏢 Company: https://sen.ltd/

Top comments (0)