ChaCha20
ChaCha20
Salsa20 reshuffled, the stream cipher inside TLS 1.3 and WireGuard.
- Keyspace
- 2^256
- Decode
- same options back
- Works on
- UTF-8 or hex, hex out
- Family
- 6 in arx
Options
Access
- Create
create("chacha20") - CLI
ciphers encode chacha20 'ATTACK AT DAWN' --key 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f --nonce 000000000000004a00000000 - Tryplayground with the sample above
- Kinrabbit, salsa20, xsalsa20, xchacha20 +1
Salsa, then ChaCha. Both names are dances, and both ciphers are Bernstein's. ChaCha came in 2008 as "a variant of Salsa20". Same ARX idea, same twenty rounds, but every word gets touched more often per round. Better diffusion for the same work.
And this is the one that won. RFC 7539 put it next to Poly1305 in 2015, RFC 8439 replaced that in 2018, and now TLS 1.3, WireGuard and SSH all run it. Not bad for a dance.
How it runs
Sixteen 32-bit words again. The first row is the constant expand 32-byte k, then two rows of key, then the counter and the nonce. Salsa scatters them across the grid. ChaCha keeps them in rows.
The quarter round changes too. It's add, XOR, rotate by 16, 12, 8 and 7, and each word gets updated twice. Ten column rounds alternate with ten diagonal rounds. Add the starting state back and you have 64 bytes of keystream.
Keys, nonce and counter
The key is 64 hex digits. nonce is 24 hex digits, the 96 bits of RFC 8439, and required. counter is the block counter of the first 64 bytes, from 0 to 4294967295, default 0.
The RFC's own example starts at counter 1, because its AEAD spends block 0 on the Poly1305 key. libsodium starts plain ChaCha20 at 0. Wrong counter and every byte comes out different, so try both:
const chacha = create("chacha20");
const key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
const nonce = "000000000000004a00000000";
chacha.encode("ATTACK AT DAWN", { key, nonce }).text; // "ee514a01f8eb1508d512dec13d5a"
chacha.encode("ATTACK AT DAWN", { key, nonce, counter: 1 }).text; // "631b05b20350f9a07bfe632eef2d"
That's the key and nonce from RFC 8439 section 2.4.2. Node's createCipheriv("chacha20") wants the counter glued in front of the nonce, four bytes little endian, and gives the same bytes.
The counter is 32 bits, so one nonce covers 256 GiB. A text that runs past block 4294967295 is an InvalidOptionError, not a silent wrap.
Original ChaCha had a 64-bit counter and a 64-bit nonce. The RFC moved 32 bits from one to the other. Up to block 2^32 - 1 both give the same keystream, if the first four bytes of the RFC nonce are zero.
Bytes without hex? chacha20(data, key, nonce, counter) from @agntn/ciphers/chacha takes and returns Uint8Array.
Checked against
RFC 8439 everywhere it has a number. The block function from section 2.3.2, the sunscreen text from 2.4.2, and test vectors #1 and #3 from appendix A.2, the last one Jabberwocky at counter 42.
Same old nonce problem
chacha.encode("ATTACK AT DAWN", { key, nonce }).text; // "ee514a01f8eb1508d512dec13d5a"
chacha.encode("ATTACK AT DUSK", { key, nonce }).text; // "ee514a01f8eb1508d512ded5395f"
Look familiar? It's Salsa20 all over again. A better mixing function doesn't help when the keystream repeats. Twelve bytes of nonce is still too few to pick at random, so count them, or take XChaCha20.
No integrity either. For that there's ChaCha20-Poly1305, and in practice nobody should send plain ChaCha20 anywhere.
A key that isn't 64 hex digits is an InvalidOptionError, and so is a nonce that isn't 24 or a counter off the range. A missing key or nonce is a MissingOptionError. On decode, an odd number of hex digits is a CipherError.
Plain TypeScript, not constant time. The real ChaCha20 is in your TLS library already.