DEV Community

SEN LLC
SEN LLC

Posted on

Classical Ciphers With Chi-Squared Frequency Analysis for Auto-Decryption

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

Screenshot

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('');
}
Enter fullscreen mode Exit fullscreen mode

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('');
}
Enter fullscreen mode Exit fullscreen mode

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('');
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)