Stream Ciphers
ChaCha20, RC4, and the keystream-XOR mental model.
Stream Ciphers
A block cipher (like AES) operates on a fixed-size chunk and needs a mode of operation to handle anything longer. A stream cipher is the other family: it produces a long pseudorandom keystream and you XOR it with your plaintext, byte by byte, for as long as your message lasts.
The mental model is one line: stream cipher = key+nonce-keyed PRNG. Encryption and decryption are the same XOR, since (P ⊕ K) ⊕ K = P.
ChaCha20 — the modern default
ChaCha20 is Daniel Bernstein's design, a refinement of Salsa20. It's the stream cipher behind:
- TLS 1.3 —
TLS_CHACHA20_POLY1305_SHA256is one of the three mandatory cipher suites. - WireGuard — uses ChaCha20-Poly1305 exclusively, no negotiation.
- OpenSSH — preferred AEAD when both ends support it.
- Signal — the data layer of Signal's protocol uses ChaCha20.
Why it won:
- Constant-time on every CPU. No data-dependent branches, no S-box lookups. AES requires hardware (AES-NI, ARM crypto extensions) to be fast and constant-time; on a CPU without those, AES is either slow or vulnerable to cache-timing attacks. ChaCha20 doesn't care.
- Mobile-friendly. Older ARM chips lack AES instructions. ChaCha20 runs at full speed on raw integer ALU.
- Simple structure. ARX (Add, Rotate, XOR) — three operations, no Galois fields, no AES tables. A few hundred lines of audit-able code.
The parameters:
key: 256 bits (32 bytes)
nonce: 96 bits (12 bytes) ← unique per message
counter: 32 bits (4 bytes) ← incremented per 64-byte block
keystream block: 64 bytes per (key, nonce, counter) tuple
RC4 — the deprecated ancestor
RC4 was the dominant stream cipher of the 90s and 2000s. Tiny, fast, easy to implement. It's also fundamentally broken:
- Biased keystream. The first few bytes have detectable statistical biases. WEP shipped these directly to the wire — letting an attacker recover the key from enough captured traffic.
- Related-key weaknesses. RC4's key schedule leaks information about the key.
- No modern AEAD pairing. RC4 was always used with separate MACs, often badly composed.
RFC 7465 banned RC4 from TLS in 2015. If you see RC4 anywhere today, it's a bug.
The keystream-XOR mental model
plaintext: D E A R J O H N
keystream: k0 k1 k2 k3 k4 k5 k6 k7
XOR
ciphertext: c0 c1 c2 c3 c4 c5 c6 c7
Decryption:
ciphertext: c0 c1 c2 c3 c4 c5 c6 c7
keystream: k0 k1 k2 k3 k4 k5 k6 k7
XOR
plaintext: D E A R J O H N
Same operation, both directions. The keystream depends only on (key, nonce, counter) — it knows nothing about your plaintext. That's the source of both the strength (no plaintext-leak, no IV chaining like CBC) and the danger (re-using a (key, nonce) pair is catastrophic).
Why nonce uniqueness is non-negotiable
If you encrypt two messages with the same (key, nonce):
ciphertext_1 = plaintext_1 ⊕ keystream
ciphertext_2 = plaintext_2 ⊕ keystream
ciphertext_1 ⊕ ciphertext_2
= (plaintext_1 ⊕ keystream) ⊕ (plaintext_2 ⊕ keystream)
= plaintext_1 ⊕ plaintext_2
The keystream cancels. You're left with the XOR of the two plaintexts. Any structure in either plaintext (English text, JSON keys, fixed-width fields) becomes a crib that an attacker uses to recover both.
This is the same failure mode as the WW2 Venona cables (Soviet pad-reuse) and the WEP attacks (catastrophic IV-reuse on busy networks). It's so easy to misuse that modern libraries refuse to expose raw stream ciphers — they bundle nonce generation and a MAC together as AEAD (authenticated encryption with associated data) and never let the application touch the keystream directly.
XChaCha20 — the bigger nonce
A 96-bit ChaCha20 nonce is 2^96 possible values. Pick them randomly and the birthday bound says you'll see a collision around 2^48 messages — too few for a long-lived key.
XChaCha20 extends the nonce to 192 bits using an HSalsa20-style construction (HChaCha20(key, first_16_bytes_of_nonce) produces a sub-key, and the remaining 24 bytes become the standard ChaCha20 nonce). 192 random bits will never collide for any practical workload. Use XChaCha20-Poly1305 anywhere you can't guarantee strict counter-based nonces (e.g., distributed systems, file-at-rest encryption).
What this means for you
- Use ChaCha20-Poly1305 (or XChaCha20-Poly1305 if you don't have a clean counter). Always paired with Poly1305 for integrity. Never raw ChaCha20.
- Treat nonces as a uniqueness invariant. Counter-based for in-order systems; random + 192-bit for anything else.
- Don't reach for AES-CTR if your platform lacks AES-NI. ChaCha20 will be faster and safer.
- Don't write your own. libsodium, noble-ciphers, PyNaCl, and Web Crypto all expose AEAD interfaces that handle nonces, MACs, and counter-rollover correctly.
What it looks like under the hood
The ChaCha20 quarter round, repeated 80 times to produce one 64-byte keystream block:
a += b; d ^= a; d <<<= 16
c += d; b ^= c; b <<<= 12
a += b; d ^= a; d <<<= 8
c += d; b ^= c; b <<<= 7
Four 32-bit registers, four operations, four rotations. A few hundred lines of constant-time code becomes the cipher protecting most TLS-mobile, WireGuard, and OpenSSH connections you'll touch. That's modern crypto: small, simple, audit-able, and used everywhere.
Tools in the wild
4 tools- librarylibsodiumfree tier
The reference 'crypto for the rest of us' library. ChaCha20-Poly1305 with sane defaults.
- library@noble/ciphers (npm)free tier
Pure-JS audited implementation of ChaCha20-Poly1305 and AES-GCM. Tiny, no native deps.
- serviceWireGuardfree tier
Modern VPN. Uses ChaCha20-Poly1305 exclusively for the data plane — no negotiation.
- libraryPyNaClfree tier
Python bindings to libsodium. SecretBox does authenticated stream encryption in two lines.