Introduction

Every time you open a WhatsApp message, stream a YouTube video, or connect to a VPN, billions of bytes of data are being encrypted in real time. The primitive doing most of that work is a stream cipher: a fast, elegant algorithm that takes a short secret key and stretches it into a long keystream — a river of pseudorandom bytes. Each plaintext byte is then XOR-ed with the next byte of the keystream to produce ciphertext.

The idea is beautiful in its simplicity. XOR is symmetric: C = P ⊕ K and P = C ⊕ K. The sender and receiver share the same key, generate the same keystream independently, and the ciphertext decrypts itself with one more XOR. No padding, no block boundaries — just a bitwise operation that a modern CPU can perform on 512 bits at once.

The most celebrated modern stream cipher is ChaCha20, designed by Daniel J. Bernstein in 2008. It replaced the aging and broken RC4 in TLS, SSH, and virtually every major protocol that needs speed without compromise. Salsa20, its predecessor, powers the NaCl cryptography library. These ciphers encrypt at multiple gigabytes per second — faster than your RAM can supply data.

But there is one rule that must never be broken: the same key and nonce pair must never be used twice. Violate it once and the encryption collapses entirely — not just for the repeated messages, but sometimes for everything protected by that key. The attack has a nickname: the two-time pad.

Try It: Break the Nonce

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();
Result
Console

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.

The Real Complexity

What makes ChaCha20 secure — and what breaks it?

  • Security model: a stream cipher is a pseudorandom generator (PRG). Its output must be computationally indistinguishable from a truly random bitstring, assuming the key is unknown. If an adversary can tell the keystream apart from random noise, they can break the cipher.
  • ChaCha20's construction: the cipher builds on a 512-bit state (key, nonce, counter, constants) and applies 20 rounds of the quarter-round function — simple ARX operations: Add, Rotate, XOR. No S-boxes, no look-up tables vulnerable to timing attacks, just integer arithmetic the CPU loves.
  • No unconditional proof: like virtually all practical symmetric ciphers, ChaCha20's security is conjectured, not proven. No one has reduced its hardness to a standard complexity assumption like factoring or discrete logarithm. The best known attacks (differential, linear) are far below the 256-bit key strength.
  • The nonce-reuse catastrophe: with the same nonce, the keystream KK is identical for both encryptions. XOR-ing two ciphertexts C1 ⊕ C2 = (P1 ⊕ K) ⊕ (P2 ⊕ K) = P1 ⊕ P2 eliminates the key entirely. Even worse: if any part of P1 is known (an HTTP header, a JSON field), the corresponding part of P2 is immediately recovered. Worse still: with many messages encrypted under the same nonce, crib-dragging can recover entire plaintexts.
  • Nonce sizes: ChaCha20 uses a 96-bit nonce. With random nonces and 2322^{32} messages, birthday-bound collisions become likely. The extended variant XChaCha20 uses a 192-bit nonce, making random selection safe even at internet scale.
  • Status: stream ciphers are a well-understood, solved problem when used correctly — fast, secure, and formally analyzed. The failure mode is not algorithmic but operational: misuse of the nonce.

Where It Matters

Stream ciphers are ubiquitous in modern security infrastructure:

  • TLS 1.3 and HTTPS: ChaCha20-Poly1305 is a mandatory cipher suite in TLS 1.3, the protocol securing every HTTPS connection. It is preferred on mobile devices and CPUs without AES hardware acceleration.
  • WireGuard VPN: the modern VPN protocol uses ChaCha20-Poly1305 as its sole symmetric cipher. Its simplicity and speed made it practical to implement in only ~4,000 lines of code, auditable in a way OpenVPN never was.
  • SSH: OpenSSH switched its default cipher to ChaCha20-Poly1305 in 2013, replacing the aging arcfour (RC4). The change simultaneously improved security and performance.
  • NaCl / libsodium: the NaCl cryptography library, designed by Bernstein, uses XSalsa20 (Salsa20 with a 192-bit nonce) as its secret-key encryption primitive, combined with Poly1305 for authentication.
  • The RC4 cautionary tale: RC4, once the most deployed stream cipher in the world (WEP, WPA, SSL, TLS), was broken by multiple attacks across two decades. The Fluhrer–Mantin–Shamir attack (2001) broke WEP; BEAST and RC4NOMORE broke it in TLS. All major browsers removed it by 2015. RC4 is the textbook example of a cipher that was fast but insecure — and it lingered far too long.
  • PlayStation 3 and ECDSA: the famous 2010 PS3 hack exploited a nonce reuse in Sony's ECDSA signing implementation (a different use of the same XOR-keystream principle). The same random nonce was used twice, revealing the private key through simple algebra.

Stream ciphers are also related to deeper questions in cryptography that touch lattice-based cryptography and learning with errors — the foundations of post-quantum security.

Conclusion

Stream ciphers are a triumph of practical cryptography: a few hundred lines of ARX operations, verified by years of public cryptanalysis, encrypting the internet's data at memory bandwidth. ChaCha20 is elegant, fast, and — when used correctly — secure against every known attack.

The catch is a single rule: never reuse a nonce under the same key. Break it once and the keystream vanishes from the equation. The ciphertexts XOR to reveal P1 ⊕ P2, and from there the messages fall like dominoes. Sony learned this with their PlayStation 3 signing key. WEP learned it with RC4.

That brittleness is not a flaw in the mathematics — it is a reminder that cryptographic security is not just about the algorithm but about how it is used. The best cipher in the world is only as strong as its nonce generation. Choose randomly, never repeat, and ChaCha20 will protect your data from everything we know how to throw at it.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/stream-ciphers/Content licensed under CC BY-NC 4.0.