The demo below simulates a ChaCha20-style stream cipher (with a simplified but faithful keystream generator). Two different messages are encrypted under the same key and the same nonce — exactly the mistake that destroyed Sony's PlayStation 3 master signing key in 2010.
<p class="hint">Two messages are encrypted under the <b>same key + nonce</b>. XOR the ciphertexts and the keystream cancels — revealing <code>P1 ⊕ P2</code>. Type a guess for message 1 to recover message 2 character-by-character.</p>
<div class="panel">
<div class="row"><span class="label">Message 1 (P1)</span><span class="mono" id="p1"></span></div>
<div class="row"><span class="label">Message 2 (P2)</span><span class="mono secret" id="p2-hidden">??? (secret)</span></div>
</div>
<div class="panel">
<div class="row"><span class="label">Ciphertext 1 (C1)</span><span class="mono hex" id="c1"></span></div>
<div class="row"><span class="label">Ciphertext 2 (C2)</span><span class="mono hex" id="c2"></span></div>
<div class="row highlight"><span class="label">C1 ⊕ C2</span><span class="mono hex" id="xor12"></span></div>
</div>
<div class="panel">
<label class="label" for="guess">Your guess for P1:</label>
<input id="guess" type="text" maxlength="32" placeholder="type here…" autocomplete="off"/>
<div class="row highlight2"><span class="label">Recovered P2</span><span class="mono" id="recovered"></span></div>
</div>
<div class="btns">
<button id="revealBtn" type="button">Reveal P2</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.panel { background: #f3f6f9; border: 1px solid #d0d8e2; border-radius: 8px; padding: .65rem .85rem; margin-bottom: .55rem; }
.row { display: flex; align-items: baseline; gap: .5rem; margin: .25rem 0; flex-wrap: wrap; }
.label { font-size: .78rem; font-weight: 700; color: #556; min-width: 130px; text-transform: uppercase; letter-spacing: .04em; }
.mono { font-family: ui-monospace, monospace; font-size: .82rem; word-break: break-all; }
.hex { color: #1a4d7a; }
.secret { color: #999; font-style: italic; }
.highlight { background: #e0f0ff; border-radius: 5px; padding: 2px 4px; }
.highlight2 { background: #e4f9e7; border-radius: 5px; padding: 2px 4px; margin-top: .3rem; }
label.label { display: block; margin-bottom: .3rem; }
input { width: 100%; padding: .4rem .6rem; font: 600 .88rem ui-monospace,monospace;
border: 1px solid #aab; border-radius: 6px; outline: none; }
input:focus { border-color: #1a4d7a; }
.btns { display: flex; gap: .5rem; margin-top: .4rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1a4d7a;
background: #1a4d7a; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1a4d7a; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1a4d7a; }
.char-match { color: #0a7d33; }
.char-unknown { color: #999; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulates a ChaCha20-style keystream via a simple but representative PRG.
// Key insight demonstrated: C1 XOR C2 = P1 XOR P2 (keystream cancels).
// This is the "two-time pad" attack — the same nonce reuse vulnerability
// that broke Sony PS3's ECDSA signing key in 2010.
const P1 = "ATTACK AT DAWN "; // 16 chars — known (or guessable)
const P2 = "RETREAT AT DUSK!"; // 16 chars — the secret
// A simple but deterministic keystream generator (not real ChaCha20,
// but the algebraic property is identical: same key+nonce => same stream).
function makeKeystream(key, nonce, length) {
const stream = new Uint8Array(length);
// Simulated ARX-style mixing: good enough to look random in the demo
let state = key ^ (nonce * 0x9e3779b9 | 0);
for (let i = 0; i < length; i++) {
state = (Math.imul(state, 1664525) + 1013904223) | 0;
stream[i] = ((state >>> 16) ^ (state & 0xff)) & 0xff;
}
return stream;
}
function strToBytes(s) {
return Uint8Array.from(s.split('').map(c => c.charCodeAt(0)));
}
function xorArrays(a, b) {
return a.map((v, i) => v ^ b[i]);
}
function toHex(arr) {
return Array.from(arr).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
}
function bytesToStr(arr) {
return Array.from(arr).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '·').join('');
}
const KEY = 0xDEADBEEF; // fixed demo key
const NONCE = 0x12345678; // same nonce for both — the fatal mistake
const keystream = makeKeystream(KEY, NONCE, P1.length);
const p1Bytes = strToBytes(P1);
const p2Bytes = strToBytes(P2);
const c1Bytes = xorArrays(p1Bytes, keystream);
const c2Bytes = xorArrays(p2Bytes, keystream);
const xorCC = xorArrays(c1Bytes, c2Bytes); // = P1 XOR P2
let revealed = false;
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + (cls || '');
}
function renderRecovered(guessStr) {
const guessBytes = strToBytes(guessStr.padEnd(P1.length, '\0').slice(0, P1.length));
// Recovered P2 guess = xorCC XOR guessP1
const recBytes = xorArrays(xorCC, guessBytes);
let html = '';
for (let i = 0; i < P1.length; i++) {
const ch = recBytes[i];
const printable = ch >= 32 && ch < 127 ? String.fromCharCode(ch) : '·';
const isMatch = i < guessStr.length && printable === P2[i];
html += `<span class="${i < guessStr.length ? (isMatch ? 'char-match' : '') : 'char-unknown'}">${printable}</span>`;
}
document.getElementById('recovered').innerHTML = html;
// Check how many chars match
const correct = guessStr.slice(0, P1.length).split('').filter((c, i) => {
const rb = xorCC[i] ^ c.charCodeAt(0);
return rb === P2.charCodeAt(i);
}).length;
if (correct === P1.length) {
setStatus('Perfect! You recovered all of P2 — the keystream is gone from the equation.', 'ok');
} else if (correct > 0) {
setStatus(fmt('%correct% / %length% characters of P2 recovered so far.', { correct: correct, length: P1.length }), 'info');
} else {
setStatus('XOR of ciphertexts = XOR of plaintexts. Guess P1 to peel back P2.', '');
}
}
function init() {
document.getElementById('p1').textContent = P1;
document.getElementById('c1').textContent = toHex(c1Bytes);
document.getElementById('c2').textContent = toHex(c2Bytes);
document.getElementById('xor12').textContent = toHex(xorCC);
document.getElementById('p2-hidden').textContent = '??? (secret)';
document.getElementById('p2-hidden').className = 'mono secret';
document.getElementById('recovered').innerHTML = '<span class="char-unknown">' + '?'.repeat(P1.length) + '</span>';
document.getElementById('guess').value = '';
revealed = false;
setStatus('XOR of ciphertexts = XOR of plaintexts. Guess P1 to peel back P2.', '');
// Verify the core claim (logged to console)
const check = xorArrays(xorCC, p1Bytes);
const ok = Array.from(check).every((b, i) => b === p2Bytes[i]);
console.assert(ok, 'DEMO BUG: C1 XOR C2 XOR P1 should equal P2');
console.log('Demo verified: C1 XOR C2 XOR P1 =', bytesToStr(check), '=== P2:', P2, '|', ok ? 'PASS' : 'FAIL');
}
document.getElementById('guess').addEventListener('input', function () {
if (!revealed) renderRecovered(this.value);
});
document.getElementById('revealBtn').addEventListener('click', function () {
revealed = true;
document.getElementById('p2-hidden').textContent = P2;
document.getElementById('p2-hidden').className = 'mono';
document.getElementById('guess').value = P1;
renderRecovered(P1);
setStatus('Revealed! With the correct P1 guess, P2 falls out of C1 ⊕ C2 instantly.', 'ok');
});
document.getElementById('resetBtn').addEventListener('click', init);
init();
<p class="hint">Two messages are encrypted under the <b>same key + nonce</b>. XOR the ciphertexts and the keystream cancels — revealing <code>P1 ⊕ P2</code>. Type a guess for message 1 to recover message 2 character-by-character.</p>
<div class="panel">
<div class="row"><span class="label">Message 1 (P1)</span><span class="mono" id="p1"></span></div>
<div class="row"><span class="label">Message 2 (P2)</span><span class="mono secret" id="p2-hidden">??? (secret)</span></div>
</div>
<div class="panel">
<div class="row"><span class="label">Ciphertext 1 (C1)</span><span class="mono hex" id="c1"></span></div>
<div class="row"><span class="label">Ciphertext 2 (C2)</span><span class="mono hex" id="c2"></span></div>
<div class="row highlight"><span class="label">C1 ⊕ C2</span><span class="mono hex" id="xor12"></span></div>
</div>
<div class="panel">
<label class="label" for="guess">Your guess for P1:</label>
<input id="guess" type="text" maxlength="32" placeholder="type here…" autocomplete="off"/>
<div class="row highlight2"><span class="label">Recovered P2</span><span class="mono" id="recovered"></span></div>
</div>
<div class="btns">
<button id="revealBtn" type="button">Reveal P2</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.panel { background: #f3f6f9; border: 1px solid #d0d8e2; border-radius: 8px; padding: .65rem .85rem; margin-bottom: .55rem; }
.row { display: flex; align-items: baseline; gap: .5rem; margin: .25rem 0; flex-wrap: wrap; }
.label { font-size: .78rem; font-weight: 700; color: #556; min-width: 130px; text-transform: uppercase; letter-spacing: .04em; }
.mono { font-family: ui-monospace, monospace; font-size: .82rem; word-break: break-all; }
.hex { color: #1a4d7a; }
.secret { color: #999; font-style: italic; }
.highlight { background: #e0f0ff; border-radius: 5px; padding: 2px 4px; }
.highlight2 { background: #e4f9e7; border-radius: 5px; padding: 2px 4px; margin-top: .3rem; }
label.label { display: block; margin-bottom: .3rem; }
input { width: 100%; padding: .4rem .6rem; font: 600 .88rem ui-monospace,monospace;
border: 1px solid #aab; border-radius: 6px; outline: none; }
input:focus { border-color: #1a4d7a; }
.btns { display: flex; gap: .5rem; margin-top: .4rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1a4d7a;
background: #1a4d7a; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1a4d7a; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1a4d7a; }
.char-match { color: #0a7d33; }
.char-unknown { color: #999; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulates a ChaCha20-style keystream via a simple but representative PRG.
// Key insight demonstrated: C1 XOR C2 = P1 XOR P2 (keystream cancels).
// This is the "two-time pad" attack — the same nonce reuse vulnerability
// that broke Sony PS3's ECDSA signing key in 2010.
const P1 = "ATTACK AT DAWN "; // 16 chars — known (or guessable)
const P2 = "RETREAT AT DUSK!"; // 16 chars — the secret
// A simple but deterministic keystream generator (not real ChaCha20,
// but the algebraic property is identical: same key+nonce => same stream).
function makeKeystream(key, nonce, length) {
const stream = new Uint8Array(length);
// Simulated ARX-style mixing: good enough to look random in the demo
let state = key ^ (nonce * 0x9e3779b9 | 0);
for (let i = 0; i < length; i++) {
state = (Math.imul(state, 1664525) + 1013904223) | 0;
stream[i] = ((state >>> 16) ^ (state & 0xff)) & 0xff;
}
return stream;
}
function strToBytes(s) {
return Uint8Array.from(s.split('').map(c => c.charCodeAt(0)));
}
function xorArrays(a, b) {
return a.map((v, i) => v ^ b[i]);
}
function toHex(arr) {
return Array.from(arr).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
}
function bytesToStr(arr) {
return Array.from(arr).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '·').join('');
}
const KEY = 0xDEADBEEF; // fixed demo key
const NONCE = 0x12345678; // same nonce for both — the fatal mistake
const keystream = makeKeystream(KEY, NONCE, P1.length);
const p1Bytes = strToBytes(P1);
const p2Bytes = strToBytes(P2);
const c1Bytes = xorArrays(p1Bytes, keystream);
const c2Bytes = xorArrays(p2Bytes, keystream);
const xorCC = xorArrays(c1Bytes, c2Bytes); // = P1 XOR P2
let revealed = false;
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + (cls || '');
}
function renderRecovered(guessStr) {
const guessBytes = strToBytes(guessStr.padEnd(P1.length, '\0').slice(0, P1.length));
// Recovered P2 guess = xorCC XOR guessP1
const recBytes = xorArrays(xorCC, guessBytes);
let html = '';
for (let i = 0; i < P1.length; i++) {
const ch = recBytes[i];
const printable = ch >= 32 && ch < 127 ? String.fromCharCode(ch) : '·';
const isMatch = i < guessStr.length && printable === P2[i];
html += `<span class="${i < guessStr.length ? (isMatch ? 'char-match' : '') : 'char-unknown'}">${printable}</span>`;
}
document.getElementById('recovered').innerHTML = html;
// Check how many chars match
const correct = guessStr.slice(0, P1.length).split('').filter((c, i) => {
const rb = xorCC[i] ^ c.charCodeAt(0);
return rb === P2.charCodeAt(i);
}).length;
if (correct === P1.length) {
setStatus('Perfect! You recovered all of P2 — the keystream is gone from the equation.', 'ok');
} else if (correct > 0) {
setStatus(fmt('%correct% / %length% characters of P2 recovered so far.', { correct: correct, length: P1.length }), 'info');
} else {
setStatus('XOR of ciphertexts = XOR of plaintexts. Guess P1 to peel back P2.', '');
}
}
function init() {
document.getElementById('p1').textContent = P1;
document.getElementById('c1').textContent = toHex(c1Bytes);
document.getElementById('c2').textContent = toHex(c2Bytes);
document.getElementById('xor12').textContent = toHex(xorCC);
document.getElementById('p2-hidden').textContent = '??? (secret)';
document.getElementById('p2-hidden').className = 'mono secret';
document.getElementById('recovered').innerHTML = '<span class="char-unknown">' + '?'.repeat(P1.length) + '</span>';
document.getElementById('guess').value = '';
revealed = false;
setStatus('XOR of ciphertexts = XOR of plaintexts. Guess P1 to peel back P2.', '');
// Verify the core claim (logged to console)
const check = xorArrays(xorCC, p1Bytes);
const ok = Array.from(check).every((b, i) => b === p2Bytes[i]);
console.assert(ok, 'DEMO BUG: C1 XOR C2 XOR P1 should equal P2');
console.log('Demo verified: C1 XOR C2 XOR P1 =', bytesToStr(check), '=== P2:', P2, '|', ok ? 'PASS' : 'FAIL');
}
document.getElementById('guess').addEventListener('input', function () {
if (!revealed) renderRecovered(this.value);
});
document.getElementById('revealBtn').addEventListener('click', function () {
revealed = true;
document.getElementById('p2-hidden').textContent = P2;
document.getElementById('p2-hidden').className = 'mono';
document.getElementById('guess').value = P1;
renderRecovered(P1);
setStatus('Revealed! With the correct P1 guess, P2 falls out of C1 ⊕ C2 instantly.', 'ok');
});
document.getElementById('resetBtn').addEventListener('click', init);
init();
What you see is the mathematical heart of the attack. When you XOR the two ciphertexts, the keystream cancels and you are left with P1 ⊕ P2 — the XOR of the two plaintexts. If you know or can guess one message (common words, headers, protocol fields), you can recover the other. The attacker does not need the key at all.
This is why every call to ChaCha20 or XSalsa20 in a real library increments a counter or generates a random 192-bit nonce: the probability of collision must be astronomically low. Get the nonce wrong once and the stream cipher degrades to something weaker than a simple substitution cipher. The related problem of choosing a truly random source for the nonce touches deep questions in cryptographic hardness.
Comments
Loading comments...