<!-- Layout: sample and cipher pickers, key, input, actions, output, frequency chart -->
<p class="hint">Pick a sample text, a cipher and a key, then encrypt. The chart compares the letter frequencies of the text with the usual frequencies of the language (orange marks). Then try the break button on the ciphertext: it uses nothing but statistics. Accents are removed and only the letters A to Z are enciphered.</p>
<div class="row">
<label class="lbl">Sample text
<select id="samplesel"><option value="en">English</option><option value="es">Spanish</option></select>
</label>
<label class="lbl">Cipher
<select id="ciphersel"><option value="caesar">Caesar</option><option value="subst">Substitution</option><option value="vig">Vigenère</option></select>
</label>
</div>
<div class="row">
<label class="lbl grow"><span id="keylbl"></span>
<input id="key" type="text" spellcheck="false" autocomplete="off" aria-label="Cipher key">
</label>
<button id="rnd" type="button" class="ghost">Random key</button>
</div>
<label class="blk">Input text
<textarea id="inp" rows="5" spellcheck="false"></textarea>
</label>
<div class="btns">
<button id="enc" type="button">Encrypt</button>
<button id="dec" type="button">Decrypt with key</button>
<button id="brk" type="button" class="warn"></button>
<button id="swap" type="button" class="ghost">Output to input</button>
</div>
<div class="status" id="status" aria-live="polite"></div>
<label class="blk">Output text
<textarea id="out" rows="5" spellcheck="false"></textarea>
</label>
<div class="row">
<label class="lbl">Chart of
<select id="chartsel"><option value="in">input</option><option value="out">output</option></select>
</label>
<label class="lbl">Reference
<select id="refsel"><option value="en">English</option><option value="es">Spanish</option></select>
</label>
</div>
<div class="chart" id="hist" role="img" aria-label="Letter frequency histogram, A to Z, text against reference"></div>
<div class="legend"><span class="sw bar"></span>text <span class="sw mark"></span>reference <span id="hstat"></span></div>
<div id="analysis" hidden>
<div class="at" id="an1t"></div>
<div class="chart small" id="an1"></div>
<div class="at" id="an2t"></div>
<div class="chart small" id="an2"></div>
</div>
/* Colours follow the light or dark scheme of the page */
:root { --fg:#1f2933; --muted:#52606d; --bg:#ffffff; --box:#eef2f6; --boxb:#cdd9e3; --acc:#1d3557;
--bar:#5a7fa8; --mark:#e76f51; --hi:#e76f51; --ok:#0a7d33; --bad:#c92f3c; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e4e9ee; --muted:#a3b1bf; --bg:#14181d; --box:#1e262f; --boxb:#3a4a5a; --acc:#8fb3dd;
--bar:#6f95bf; --mark:#f4a261; --hi:#f4a261; --ok:#4cc38a; --bad:#ff7b84; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 4px; }
.hint { font-size: .86rem; color: var(--muted); margin: .2rem 0 .6rem; line-height: 1.45; }
.row { display: flex; flex-wrap: wrap; gap: .4rem 1rem; margin-bottom: .45rem; align-items: center; }
.lbl { font-size: .85rem; display: flex; align-items: center; gap: .35rem; }
.lbl.grow { flex: 1 1 200px; }
.lbl.grow input { flex: 1; min-width: 0; }
.blk { display: block; font-size: .85rem; margin-bottom: .4rem; }
select, input, textarea { font: 13px ui-monospace, monospace; color: var(--fg); background: var(--box);
border: 1px solid var(--boxb); border-radius: 6px; padding: .3rem .4rem; }
textarea { display: block; width: 100%; margin-top: .2rem; resize: vertical; line-height: 1.4; }
.btns { display: flex; gap: .45rem; flex-wrap: wrap; margin: .3rem 0 .4rem; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .8rem; border: 1px solid var(--acc);
background: var(--acc); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--acc); }
button.warn { background: var(--hi); border-color: var(--hi); color: #1a1a1a; }
.status { font-size: .88rem; font-weight: 600; min-height: 1.3em; margin: .2rem 0 .4rem; }
.status.ok { color: var(--ok); }
.status.bad { color: var(--bad); }
.chart { display: flex; align-items: flex-end; gap: 2px; height: 96px; padding: 4px 2px 16px; margin: .3rem 0;
background: var(--box); border: 1px solid var(--boxb); border-radius: 8px; }
.chart.small { height: 76px; }
.col { position: relative; flex: 1; height: 100%; min-width: 0; }
.bar { position: absolute; left: 12%; right: 12%; bottom: 0; background: var(--bar); border-radius: 2px 2px 0 0; min-height: 1px; }
.bar.hi { background: var(--hi); }
.mark { position: absolute; left: 0; right: 0; height: 2px; background: var(--mark); }
.lab { position: absolute; left: 0; right: 0; bottom: -15px; text-align: center; font: 9px ui-monospace, monospace; color: var(--muted); }
.legend { font-size: .78rem; color: var(--muted); display: flex; flex-wrap: wrap; align-items: center; gap: .2rem .4rem; }
.sw { display: inline-block; width: 12px; height: 8px; }
.sw.bar { position: static; background: var(--bar); }
.sw.mark { position: static; height: 2px; background: var(--mark); }
.at { font-size: .82rem; font-weight: 600; margin-top: .6rem; }
[hidden] { display: none !important; }
const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const REF = {
en: [8.167,1.492,2.782,4.253,12.702,2.228,2.015,6.094,6.966,0.153,0.772,4.025,2.406,6.749,7.507,1.929,0.095,5.987,6.327,9.056,2.758,0.978,2.360,0.150,1.974,0.074],
es: [12.53,1.42,4.68,5.86,13.68,0.69,1.01,0.70,6.25,0.44,0.02,4.97,3.15,6.71,8.68,2.51,0.88,6.87,7.98,4.63,3.93,0.90,0.01,0.22,0.90,0.52]
};
const norm = (s) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase();
const lettersOf = (s) => norm(s).replace(/[^A-Z]/g, "");
function mapText(s, fn) {
let i = 0;
return norm(s).replace(/[A-Z]/g, (ch) => fn(ch.charCodeAt(0) - 65, i++));
}
const mod26 = (n) => ((n % 26) + 26) % 26;
const caesar = (s, k) => mapText(s, (c) => A[mod26(c + k)]);
function validSubKey(k) {
const u = lettersOf(k);
return u.length === 26 && new Set(u).size === 26;
}
function subst(s, key, dec) {
const k = lettersOf(key);
const inv = [];
for (let i = 0; i < 26; i++) inv[k.charCodeAt(i) - 65] = A[i];
return mapText(s, (c) => (dec ? inv[c] : k[c]));
}
function vig(s, key, dir) {
const k = lettersOf(key).split("").map((ch) => ch.charCodeAt(0) - 65);
if (!k.length) return norm(s);
return mapText(s, (c, i) => A[mod26(c + dir * k[i % k.length])]);
}
function counts(t) {
const c = new Array(26).fill(0);
for (const ch of t) c[ch.charCodeAt(0) - 65]++;
return c;
}
function chiShift(c, n, ref, k) {
const tot = ref.reduce((a, b) => a + b, 0);
let x = 0;
for (let p = 0; p < 26; p++) {
const e = (n * ref[p]) / tot;
const o = c[(p + k) % 26];
x += ((o - e) * (o - e)) / e;
}
return x;
}
function breakCaesar(text, ref) {
const t = lettersOf(text);
const c = counts(t);
const chis = [];
for (let k = 0; k < 26; k++) chis.push(chiShift(c, t.length, ref, k));
let best = 0;
for (let k = 1; k < 26; k++) if (chis[k] < chis[best]) best = k;
return { shift: best, chis };
}
function ic(t) {
const n = t.length;
if (n < 2) return 0;
const c = counts(t);
let s = 0;
for (const v of c) s += v * (v - 1);
return s / (n * (n - 1));
}
function columns(t, L) {
const cols = [];
for (let j = 0; j < L; j++) {
let s = "";
for (let i = j; i < t.length; i += L) s += t[i];
cols.push(s);
}
return cols;
}
function kasiski(t, maxL) {
const pos = {};
for (let i = 0; i + 3 <= t.length; i++) (pos[t.substr(i, 3)] = pos[t.substr(i, 3)] || []).push(i);
const dists = [];
const votes = new Array(maxL + 1).fill(0);
for (const g in pos) {
const p = pos[g];
for (let a = 0; a < p.length; a++)
for (let b = a + 1; b < p.length; b++) {
const d = p[b] - p[a];
dists.push(d);
for (let f = 2; f <= maxL; f++) if (d % f === 0) votes[f]++;
}
}
return { dists, votes };
}
function breakVigenere(text, ref, maxL) {
const t = lettersOf(text);
const kas = kasiski(t, maxL);
const ics = [0];
for (let L = 1; L <= maxL; L++) {
const cols = columns(t, L);
ics.push(cols.reduce((a, s) => a + ic(s), 0) / L);
}
const mx = Math.max(...ics);
let L = 1;
while (L < maxL && ics[L] < 0.9 * mx) L++;
let key = "";
for (const col of columns(t, L)) key += A[breakCaesar(col, ref).shift];
return { kas, ics, L, key, plain: vig(text, key, -1) };
}
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ""));
// Shortcut to find an element by id
const $ = (id) => document.getElementById(id);
const SAMPLES = { en: "Secrecy has always been a race between the people who hide messages and the people who want to read them. A general who sends orders across a battlefield cannot stop the courier from being caught, so he scrambles the words and hopes that the enemy will fail to unscramble them. The simplest scrambling just slides every letter a fixed distance along the alphabet. It is easy to carry out by hand and it feels mysterious, but it has only twenty-five useful keys, and a patient clerk can try them all before lunch. A better idea is to shuffle the alphabet completely, so that every letter has its own private disguise. The number of possible shuffles is astronomically large, far beyond any search, and for centuries that number made such ciphers look unbreakable. The weakness was never in the count of keys. It was in the language itself. In every ordinary text some letters appear far more often than others, and the disguise of a frequent letter is frequent too. A curious reader who counts the symbols and compares them with the usual habits of the language soon sees which symbol stands for the most common letter, and the rest of the message follows like a crossword puzzle. To hide those habits the cipher must use several alphabets in turn, switching according to a secret word, and for a long time that remedy seemed to be perfect. Yet the secret word repeats, and repetition leaves a trace that a careful analyst can measure.", es: "El secreto siempre ha sido una carrera entre quienes ocultan los mensajes y quienes quieren leerlos. Un general que envía órdenes a través de un campo de batalla no puede impedir que el mensajero sea capturado, así que revuelve las palabras y espera que el enemigo no logre ordenarlas de nuevo. La mezcla más sencilla desplaza cada letra una distancia fija a lo largo del alfabeto. Es fácil de hacer a mano y parece misteriosa, pero solo tiene veinticinco claves útiles, y un escribiente paciente puede probarlas todas antes del almuerzo. Una idea mejor consiste en barajar el alfabeto por completo, de modo que cada letra tenga su propio disfraz. El número de barajas posibles es astronómico, mucho más de lo que cualquier búsqueda podría recorrer, y durante siglos esa cifra hizo que estos sistemas parecieran inquebrantables. La debilidad nunca estuvo en la cantidad de claves, sino en el idioma mismo. En cualquier texto corriente algunas letras aparecen mucho más que otras, y el disfraz de una letra frecuente también es frecuente. Un lector curioso que cuenta los símbolos y los compara con las costumbres habituales de la lengua descubre pronto cuál representa a la letra más común, y el resto del mensaje se va resolviendo como un crucigrama. Para ocultar esas costumbres el cifrado debe usar varios alfabetos por turnos, cambiando según una palabra secreta, y durante mucho tiempo ese remedio pareció perfecto. Pero la palabra secreta se repite, y la repetición deja una huella que un analista cuidadoso puede medir." };
const keys = { caesar: "3", subst: "QWERTYUIOPASDFGHJKLZXCVBNM", vig: "QUIPU" };
let cur = "caesar";
// Draws a bar chart; an optional marker line shows the reference value
function drawBars(el, vals, labels, opts) {
opts = opts || {};
const mx = Math.max(...vals, ...(opts.ref || [0]), 1e-9);
el.innerHTML = "";
vals.forEach((v, i) => {
const col = document.createElement("div");
col.className = "col";
const bar = document.createElement("div");
bar.className = "bar" + (opts.hi === i ? " hi" : "");
bar.style.height = (100 * v) / mx + "%";
col.appendChild(bar);
if (opts.ref) {
const m = document.createElement("div");
m.className = "mark";
m.style.bottom = (100 * opts.ref[i]) / mx + "%";
col.appendChild(m);
}
const l = document.createElement("span");
l.className = "lab";
l.textContent = labels[i];
col.appendChild(l);
el.appendChild(col);
});
}
// Shows a short message under the buttons
function say(msg, cls) {
const s = $("status");
s.textContent = msg;
s.className = "status " + (cls || "");
}
const refNow = () => REF[$("refsel").value];
// Letter frequencies of the chosen text against the language reference
function drawHist() {
const t = lettersOf($(($("chartsel").value === "out") ? "out" : "inp").value);
const c = counts(t);
const n = Math.max(t.length, 1);
const ref = refNow();
const tot = ref.reduce((a, b) => a + b, 0);
drawBars($("hist"), c.map((v) => (100 * v) / n), A.split(""), { ref: ref.map((v) => (100 * v) / tot) });
$("hstat").textContent = i18nFmt("%n% letters · index of coincidence %ic%", { n: t.length, ic: ic(t).toFixed(4) });
}
function readKey() {
const raw = $("key").value;
if (cur === "caesar") {
const k = parseInt(raw, 10);
return Number.isNaN(k) ? null : mod26(k);
}
if (cur === "subst") return validSubKey(raw) ? raw : null;
return lettersOf(raw).length ? raw : null;
}
function badKeyMsg() {
return cur === "caesar" ? "The shift must be a whole number." : cur === "subst" ? "The alphabet must use each of the 26 letters exactly once." : "The keyword needs at least one letter.";
}
function run(dec) {
const k = readKey();
if (k === null) { say(badKeyMsg(), "bad"); return; }
const s = $("inp").value;
let o;
if (cur === "caesar") o = caesar(s, dec ? -k : k);
else if (cur === "subst") o = subst(s, k, dec);
else o = vig(s, k, dec ? -1 : 1);
$("out").value = o;
if (!dec) $("chartsel").value = "out";
$("analysis").hidden = true;
say(dec ? "Decrypted with the key." : "Encrypted. The chart now shows the ciphertext.", "ok");
drawHist();
}
// Automatic attacks: chi-squared for Caesar, Kasiski plus index of coincidence for Vigenère
function doBreak() {
const s = $("inp").value;
const t = lettersOf(s);
if (t.length < 40) { say("Put at least 40 letters in the input box so there is something to analyse.", "bad"); return; }
const ref = refNow();
const an = $("analysis");
an.hidden = false;
$("an1").innerHTML = "";
$("an2").innerHTML = "";
if (cur === "subst") {
an.hidden = true;
say("A substitution key has about 4 x 10^26 possibilities, so nobody tries them all. Instead, match the tallest bars of the chart to the most frequent letters of the language, then fix the rest from context.", "");
return;
}
if (cur === "caesar") {
const r = breakCaesar(s, ref);
$("an1t").textContent = "Chi-squared for each possible shift (lowest bar wins)";
$("an2t").textContent = "";
drawBars($("an1"), r.chis, r.chis.map((_, i) => String(i)), { hi: r.shift });
$("out").value = caesar(s, -r.shift);
say(i18nFmt("Best shift: %k% (chi-squared %x%). The output shows the text decrypted with it.", { k: r.shift, x: r.chis[r.shift].toFixed(1) }), "ok");
return;
}
const r = breakVigenere(s, ref, 20);
const votes = r.kas.votes.map((v, i) => [i, v]).filter((p) => p[0] >= 2).sort((a, b) => b[1] - a[1]).slice(0, 3);
$("an1t").textContent = i18nFmt("Kasiski: %n% distances between repeated trigrams, votes per divisor 2 to 20", { n: r.kas.dists.length });
drawBars($("an1"), r.kas.votes.slice(2), r.kas.votes.slice(2).map((_, i) => String(i + 2)), { hi: votes[0][0] - 2 });
$("an2t").textContent = "Average index of coincidence of the columns for key lengths 1 to 20";
drawBars($("an2"), r.ics.slice(1), r.ics.slice(1).map((_, i) => String(i + 1)), { hi: r.L - 1 });
$("out").value = r.plain;
say(i18nFmt("Key length %L% (Kasiski favours %v%); key found: %key%. The output shows the decrypted text.", { L: r.L, key: r.key, v: votes.map((p) => p[0]).join(", ") }), "ok");
}
function setCipher(c) {
keys[cur] = $("key").value;
cur = c;
$("key").value = keys[c];
$("keylbl").textContent = c === "caesar" ? "Shift (0 to 25)" : c === "subst" ? "Alphabet (26 letters)" : "Keyword";
$("analysis").hidden = true;
$("brk").textContent = c === "caesar" ? "Break Caesar (chi-squared)" : c === "vig" ? "Break Vigenère (Kasiski + IC)" : "Break substitution?";
say("", "");
}
function randomKey() {
if (cur === "caesar") $("key").value = String(1 + Math.floor(Math.random() * 25));
else if (cur === "vig") {
let k = "";
const L = 3 + Math.floor(Math.random() * 5);
for (let i = 0; i < L; i++) k += A[Math.floor(Math.random() * 26)];
$("key").value = k;
} else {
const a = A.split("");
for (let i = 25; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
$("key").value = a.join("");
}
}
function loadSample() {
const l = $("samplesel").value;
$("inp").value = SAMPLES[l];
$("refsel").value = l;
$("out").value = "";
$("chartsel").value = "in";
$("analysis").hidden = true;
say("", "");
drawHist();
}
$("samplesel").addEventListener("change", loadSample);
$("ciphersel").addEventListener("change", (e) => setCipher(e.target.value));
$("refsel").addEventListener("change", drawHist);
$("chartsel").addEventListener("change", drawHist);
$("inp").addEventListener("input", drawHist);
$("out").addEventListener("input", drawHist);
$("enc").addEventListener("click", () => run(false));
$("dec").addEventListener("click", () => run(true));
$("brk").addEventListener("click", doBreak);
$("rnd").addEventListener("click", randomKey);
$("swap").addEventListener("click", () => {
$("inp").value = $("out").value;
$("out").value = "";
$("chartsel").value = "in";
$("analysis").hidden = true;
drawHist();
});
$("key").value = keys.caesar;
setCipher("caesar");
loadSample();
<!-- Layout: sample and cipher pickers, key, input, actions, output, frequency chart -->
<p class="hint">Pick a sample text, a cipher and a key, then encrypt. The chart compares the letter frequencies of the text with the usual frequencies of the language (orange marks). Then try the break button on the ciphertext: it uses nothing but statistics. Accents are removed and only the letters A to Z are enciphered.</p>
<div class="row">
<label class="lbl">Sample text
<select id="samplesel"><option value="en">English</option><option value="es">Spanish</option></select>
</label>
<label class="lbl">Cipher
<select id="ciphersel"><option value="caesar">Caesar</option><option value="subst">Substitution</option><option value="vig">Vigenère</option></select>
</label>
</div>
<div class="row">
<label class="lbl grow"><span id="keylbl"></span>
<input id="key" type="text" spellcheck="false" autocomplete="off" aria-label="Cipher key">
</label>
<button id="rnd" type="button" class="ghost">Random key</button>
</div>
<label class="blk">Input text
<textarea id="inp" rows="5" spellcheck="false"></textarea>
</label>
<div class="btns">
<button id="enc" type="button">Encrypt</button>
<button id="dec" type="button">Decrypt with key</button>
<button id="brk" type="button" class="warn"></button>
<button id="swap" type="button" class="ghost">Output to input</button>
</div>
<div class="status" id="status" aria-live="polite"></div>
<label class="blk">Output text
<textarea id="out" rows="5" spellcheck="false"></textarea>
</label>
<div class="row">
<label class="lbl">Chart of
<select id="chartsel"><option value="in">input</option><option value="out">output</option></select>
</label>
<label class="lbl">Reference
<select id="refsel"><option value="en">English</option><option value="es">Spanish</option></select>
</label>
</div>
<div class="chart" id="hist" role="img" aria-label="Letter frequency histogram, A to Z, text against reference"></div>
<div class="legend"><span class="sw bar"></span>text <span class="sw mark"></span>reference <span id="hstat"></span></div>
<div id="analysis" hidden>
<div class="at" id="an1t"></div>
<div class="chart small" id="an1"></div>
<div class="at" id="an2t"></div>
<div class="chart small" id="an2"></div>
</div>
/* Colours follow the light or dark scheme of the page */
:root { --fg:#1f2933; --muted:#52606d; --bg:#ffffff; --box:#eef2f6; --boxb:#cdd9e3; --acc:#1d3557;
--bar:#5a7fa8; --mark:#e76f51; --hi:#e76f51; --ok:#0a7d33; --bad:#c92f3c; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e4e9ee; --muted:#a3b1bf; --bg:#14181d; --box:#1e262f; --boxb:#3a4a5a; --acc:#8fb3dd;
--bar:#6f95bf; --mark:#f4a261; --hi:#f4a261; --ok:#4cc38a; --bad:#ff7b84; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 4px; }
.hint { font-size: .86rem; color: var(--muted); margin: .2rem 0 .6rem; line-height: 1.45; }
.row { display: flex; flex-wrap: wrap; gap: .4rem 1rem; margin-bottom: .45rem; align-items: center; }
.lbl { font-size: .85rem; display: flex; align-items: center; gap: .35rem; }
.lbl.grow { flex: 1 1 200px; }
.lbl.grow input { flex: 1; min-width: 0; }
.blk { display: block; font-size: .85rem; margin-bottom: .4rem; }
select, input, textarea { font: 13px ui-monospace, monospace; color: var(--fg); background: var(--box);
border: 1px solid var(--boxb); border-radius: 6px; padding: .3rem .4rem; }
textarea { display: block; width: 100%; margin-top: .2rem; resize: vertical; line-height: 1.4; }
.btns { display: flex; gap: .45rem; flex-wrap: wrap; margin: .3rem 0 .4rem; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .8rem; border: 1px solid var(--acc);
background: var(--acc); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--acc); }
button.warn { background: var(--hi); border-color: var(--hi); color: #1a1a1a; }
.status { font-size: .88rem; font-weight: 600; min-height: 1.3em; margin: .2rem 0 .4rem; }
.status.ok { color: var(--ok); }
.status.bad { color: var(--bad); }
.chart { display: flex; align-items: flex-end; gap: 2px; height: 96px; padding: 4px 2px 16px; margin: .3rem 0;
background: var(--box); border: 1px solid var(--boxb); border-radius: 8px; }
.chart.small { height: 76px; }
.col { position: relative; flex: 1; height: 100%; min-width: 0; }
.bar { position: absolute; left: 12%; right: 12%; bottom: 0; background: var(--bar); border-radius: 2px 2px 0 0; min-height: 1px; }
.bar.hi { background: var(--hi); }
.mark { position: absolute; left: 0; right: 0; height: 2px; background: var(--mark); }
.lab { position: absolute; left: 0; right: 0; bottom: -15px; text-align: center; font: 9px ui-monospace, monospace; color: var(--muted); }
.legend { font-size: .78rem; color: var(--muted); display: flex; flex-wrap: wrap; align-items: center; gap: .2rem .4rem; }
.sw { display: inline-block; width: 12px; height: 8px; }
.sw.bar { position: static; background: var(--bar); }
.sw.mark { position: static; height: 2px; background: var(--mark); }
.at { font-size: .82rem; font-weight: 600; margin-top: .6rem; }
[hidden] { display: none !important; }
const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const REF = {
en: [8.167,1.492,2.782,4.253,12.702,2.228,2.015,6.094,6.966,0.153,0.772,4.025,2.406,6.749,7.507,1.929,0.095,5.987,6.327,9.056,2.758,0.978,2.360,0.150,1.974,0.074],
es: [12.53,1.42,4.68,5.86,13.68,0.69,1.01,0.70,6.25,0.44,0.02,4.97,3.15,6.71,8.68,2.51,0.88,6.87,7.98,4.63,3.93,0.90,0.01,0.22,0.90,0.52]
};
const norm = (s) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase();
const lettersOf = (s) => norm(s).replace(/[^A-Z]/g, "");
function mapText(s, fn) {
let i = 0;
return norm(s).replace(/[A-Z]/g, (ch) => fn(ch.charCodeAt(0) - 65, i++));
}
const mod26 = (n) => ((n % 26) + 26) % 26;
const caesar = (s, k) => mapText(s, (c) => A[mod26(c + k)]);
function validSubKey(k) {
const u = lettersOf(k);
return u.length === 26 && new Set(u).size === 26;
}
function subst(s, key, dec) {
const k = lettersOf(key);
const inv = [];
for (let i = 0; i < 26; i++) inv[k.charCodeAt(i) - 65] = A[i];
return mapText(s, (c) => (dec ? inv[c] : k[c]));
}
function vig(s, key, dir) {
const k = lettersOf(key).split("").map((ch) => ch.charCodeAt(0) - 65);
if (!k.length) return norm(s);
return mapText(s, (c, i) => A[mod26(c + dir * k[i % k.length])]);
}
function counts(t) {
const c = new Array(26).fill(0);
for (const ch of t) c[ch.charCodeAt(0) - 65]++;
return c;
}
function chiShift(c, n, ref, k) {
const tot = ref.reduce((a, b) => a + b, 0);
let x = 0;
for (let p = 0; p < 26; p++) {
const e = (n * ref[p]) / tot;
const o = c[(p + k) % 26];
x += ((o - e) * (o - e)) / e;
}
return x;
}
function breakCaesar(text, ref) {
const t = lettersOf(text);
const c = counts(t);
const chis = [];
for (let k = 0; k < 26; k++) chis.push(chiShift(c, t.length, ref, k));
let best = 0;
for (let k = 1; k < 26; k++) if (chis[k] < chis[best]) best = k;
return { shift: best, chis };
}
function ic(t) {
const n = t.length;
if (n < 2) return 0;
const c = counts(t);
let s = 0;
for (const v of c) s += v * (v - 1);
return s / (n * (n - 1));
}
function columns(t, L) {
const cols = [];
for (let j = 0; j < L; j++) {
let s = "";
for (let i = j; i < t.length; i += L) s += t[i];
cols.push(s);
}
return cols;
}
function kasiski(t, maxL) {
const pos = {};
for (let i = 0; i + 3 <= t.length; i++) (pos[t.substr(i, 3)] = pos[t.substr(i, 3)] || []).push(i);
const dists = [];
const votes = new Array(maxL + 1).fill(0);
for (const g in pos) {
const p = pos[g];
for (let a = 0; a < p.length; a++)
for (let b = a + 1; b < p.length; b++) {
const d = p[b] - p[a];
dists.push(d);
for (let f = 2; f <= maxL; f++) if (d % f === 0) votes[f]++;
}
}
return { dists, votes };
}
function breakVigenere(text, ref, maxL) {
const t = lettersOf(text);
const kas = kasiski(t, maxL);
const ics = [0];
for (let L = 1; L <= maxL; L++) {
const cols = columns(t, L);
ics.push(cols.reduce((a, s) => a + ic(s), 0) / L);
}
const mx = Math.max(...ics);
let L = 1;
while (L < maxL && ics[L] < 0.9 * mx) L++;
let key = "";
for (const col of columns(t, L)) key += A[breakCaesar(col, ref).shift];
return { kas, ics, L, key, plain: vig(text, key, -1) };
}
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ""));
// Shortcut to find an element by id
const $ = (id) => document.getElementById(id);
const SAMPLES = { en: "Secrecy has always been a race between the people who hide messages and the people who want to read them. A general who sends orders across a battlefield cannot stop the courier from being caught, so he scrambles the words and hopes that the enemy will fail to unscramble them. The simplest scrambling just slides every letter a fixed distance along the alphabet. It is easy to carry out by hand and it feels mysterious, but it has only twenty-five useful keys, and a patient clerk can try them all before lunch. A better idea is to shuffle the alphabet completely, so that every letter has its own private disguise. The number of possible shuffles is astronomically large, far beyond any search, and for centuries that number made such ciphers look unbreakable. The weakness was never in the count of keys. It was in the language itself. In every ordinary text some letters appear far more often than others, and the disguise of a frequent letter is frequent too. A curious reader who counts the symbols and compares them with the usual habits of the language soon sees which symbol stands for the most common letter, and the rest of the message follows like a crossword puzzle. To hide those habits the cipher must use several alphabets in turn, switching according to a secret word, and for a long time that remedy seemed to be perfect. Yet the secret word repeats, and repetition leaves a trace that a careful analyst can measure.", es: "El secreto siempre ha sido una carrera entre quienes ocultan los mensajes y quienes quieren leerlos. Un general que envía órdenes a través de un campo de batalla no puede impedir que el mensajero sea capturado, así que revuelve las palabras y espera que el enemigo no logre ordenarlas de nuevo. La mezcla más sencilla desplaza cada letra una distancia fija a lo largo del alfabeto. Es fácil de hacer a mano y parece misteriosa, pero solo tiene veinticinco claves útiles, y un escribiente paciente puede probarlas todas antes del almuerzo. Una idea mejor consiste en barajar el alfabeto por completo, de modo que cada letra tenga su propio disfraz. El número de barajas posibles es astronómico, mucho más de lo que cualquier búsqueda podría recorrer, y durante siglos esa cifra hizo que estos sistemas parecieran inquebrantables. La debilidad nunca estuvo en la cantidad de claves, sino en el idioma mismo. En cualquier texto corriente algunas letras aparecen mucho más que otras, y el disfraz de una letra frecuente también es frecuente. Un lector curioso que cuenta los símbolos y los compara con las costumbres habituales de la lengua descubre pronto cuál representa a la letra más común, y el resto del mensaje se va resolviendo como un crucigrama. Para ocultar esas costumbres el cifrado debe usar varios alfabetos por turnos, cambiando según una palabra secreta, y durante mucho tiempo ese remedio pareció perfecto. Pero la palabra secreta se repite, y la repetición deja una huella que un analista cuidadoso puede medir." };
const keys = { caesar: "3", subst: "QWERTYUIOPASDFGHJKLZXCVBNM", vig: "QUIPU" };
let cur = "caesar";
// Draws a bar chart; an optional marker line shows the reference value
function drawBars(el, vals, labels, opts) {
opts = opts || {};
const mx = Math.max(...vals, ...(opts.ref || [0]), 1e-9);
el.innerHTML = "";
vals.forEach((v, i) => {
const col = document.createElement("div");
col.className = "col";
const bar = document.createElement("div");
bar.className = "bar" + (opts.hi === i ? " hi" : "");
bar.style.height = (100 * v) / mx + "%";
col.appendChild(bar);
if (opts.ref) {
const m = document.createElement("div");
m.className = "mark";
m.style.bottom = (100 * opts.ref[i]) / mx + "%";
col.appendChild(m);
}
const l = document.createElement("span");
l.className = "lab";
l.textContent = labels[i];
col.appendChild(l);
el.appendChild(col);
});
}
// Shows a short message under the buttons
function say(msg, cls) {
const s = $("status");
s.textContent = msg;
s.className = "status " + (cls || "");
}
const refNow = () => REF[$("refsel").value];
// Letter frequencies of the chosen text against the language reference
function drawHist() {
const t = lettersOf($(($("chartsel").value === "out") ? "out" : "inp").value);
const c = counts(t);
const n = Math.max(t.length, 1);
const ref = refNow();
const tot = ref.reduce((a, b) => a + b, 0);
drawBars($("hist"), c.map((v) => (100 * v) / n), A.split(""), { ref: ref.map((v) => (100 * v) / tot) });
$("hstat").textContent = i18nFmt("%n% letters · index of coincidence %ic%", { n: t.length, ic: ic(t).toFixed(4) });
}
function readKey() {
const raw = $("key").value;
if (cur === "caesar") {
const k = parseInt(raw, 10);
return Number.isNaN(k) ? null : mod26(k);
}
if (cur === "subst") return validSubKey(raw) ? raw : null;
return lettersOf(raw).length ? raw : null;
}
function badKeyMsg() {
return cur === "caesar" ? "The shift must be a whole number." : cur === "subst" ? "The alphabet must use each of the 26 letters exactly once." : "The keyword needs at least one letter.";
}
function run(dec) {
const k = readKey();
if (k === null) { say(badKeyMsg(), "bad"); return; }
const s = $("inp").value;
let o;
if (cur === "caesar") o = caesar(s, dec ? -k : k);
else if (cur === "subst") o = subst(s, k, dec);
else o = vig(s, k, dec ? -1 : 1);
$("out").value = o;
if (!dec) $("chartsel").value = "out";
$("analysis").hidden = true;
say(dec ? "Decrypted with the key." : "Encrypted. The chart now shows the ciphertext.", "ok");
drawHist();
}
// Automatic attacks: chi-squared for Caesar, Kasiski plus index of coincidence for Vigenère
function doBreak() {
const s = $("inp").value;
const t = lettersOf(s);
if (t.length < 40) { say("Put at least 40 letters in the input box so there is something to analyse.", "bad"); return; }
const ref = refNow();
const an = $("analysis");
an.hidden = false;
$("an1").innerHTML = "";
$("an2").innerHTML = "";
if (cur === "subst") {
an.hidden = true;
say("A substitution key has about 4 x 10^26 possibilities, so nobody tries them all. Instead, match the tallest bars of the chart to the most frequent letters of the language, then fix the rest from context.", "");
return;
}
if (cur === "caesar") {
const r = breakCaesar(s, ref);
$("an1t").textContent = "Chi-squared for each possible shift (lowest bar wins)";
$("an2t").textContent = "";
drawBars($("an1"), r.chis, r.chis.map((_, i) => String(i)), { hi: r.shift });
$("out").value = caesar(s, -r.shift);
say(i18nFmt("Best shift: %k% (chi-squared %x%). The output shows the text decrypted with it.", { k: r.shift, x: r.chis[r.shift].toFixed(1) }), "ok");
return;
}
const r = breakVigenere(s, ref, 20);
const votes = r.kas.votes.map((v, i) => [i, v]).filter((p) => p[0] >= 2).sort((a, b) => b[1] - a[1]).slice(0, 3);
$("an1t").textContent = i18nFmt("Kasiski: %n% distances between repeated trigrams, votes per divisor 2 to 20", { n: r.kas.dists.length });
drawBars($("an1"), r.kas.votes.slice(2), r.kas.votes.slice(2).map((_, i) => String(i + 2)), { hi: votes[0][0] - 2 });
$("an2t").textContent = "Average index of coincidence of the columns for key lengths 1 to 20";
drawBars($("an2"), r.ics.slice(1), r.ics.slice(1).map((_, i) => String(i + 1)), { hi: r.L - 1 });
$("out").value = r.plain;
say(i18nFmt("Key length %L% (Kasiski favours %v%); key found: %key%. The output shows the decrypted text.", { L: r.L, key: r.key, v: votes.map((p) => p[0]).join(", ") }), "ok");
}
function setCipher(c) {
keys[cur] = $("key").value;
cur = c;
$("key").value = keys[c];
$("keylbl").textContent = c === "caesar" ? "Shift (0 to 25)" : c === "subst" ? "Alphabet (26 letters)" : "Keyword";
$("analysis").hidden = true;
$("brk").textContent = c === "caesar" ? "Break Caesar (chi-squared)" : c === "vig" ? "Break Vigenère (Kasiski + IC)" : "Break substitution?";
say("", "");
}
function randomKey() {
if (cur === "caesar") $("key").value = String(1 + Math.floor(Math.random() * 25));
else if (cur === "vig") {
let k = "";
const L = 3 + Math.floor(Math.random() * 5);
for (let i = 0; i < L; i++) k += A[Math.floor(Math.random() * 26)];
$("key").value = k;
} else {
const a = A.split("");
for (let i = 25; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
$("key").value = a.join("");
}
}
function loadSample() {
const l = $("samplesel").value;
$("inp").value = SAMPLES[l];
$("refsel").value = l;
$("out").value = "";
$("chartsel").value = "in";
$("analysis").hidden = true;
say("", "");
drawHist();
}
$("samplesel").addEventListener("change", loadSample);
$("ciphersel").addEventListener("change", (e) => setCipher(e.target.value));
$("refsel").addEventListener("change", drawHist);
$("chartsel").addEventListener("change", drawHist);
$("inp").addEventListener("input", drawHist);
$("out").addEventListener("input", drawHist);
$("enc").addEventListener("click", () => run(false));
$("dec").addEventListener("click", () => run(true));
$("brk").addEventListener("click", doBreak);
$("rnd").addEventListener("click", randomKey);
$("swap").addEventListener("click", () => {
$("inp").value = $("out").value;
$("out").value = "";
$("chartsel").value = "in";
$("analysis").hidden = true;
drawHist();
});
$("key").value = keys.caesar;
setCipher("caesar");
loadSample();
Comments
Loading comments...