ChaCha20-Poly1305
ChaCha20-Poly1305
ChaCha20 with a Poly1305 tag, so a changed byte gets caught.
- Keyspace
- 2^256
- Decode
- same options back
- Works on
- UTF-8 or hex, hex out
- Family
- 6 in arx
Options
Access
- Create
create("chacha20-poly1305") - CLI
ciphers encode chacha20-poly1305 'ATTACK AT DAWN' --key 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f --nonce 070000004041424344454647 - Tryplayground with the sample above
- Kinrabbit, salsa20, xsalsa20, chacha20 +1
Plain ChaCha20 decrypts whatever you give it. Flip a bit, get a flipped bit, nobody complains. This one complains. It's ChaCha20 with Poly1305 on top, both by Bernstein, put together in RFC 8439. TLS 1.3 has it as a cipher suite, and WireGuard runs every packet through it.
How it runs
Block 0 of the ChaCha20 keystream isn't used for the text. Its first 32 bytes are a one-time Poly1305 key, fresh for every nonce. The text gets encrypted from block 1.
Then Poly1305 reads the associated data, padded to 16 bytes, then the ciphertext, padded the same way, then both lengths. It's a polynomial evaluated modulo 2^130 - 5, which is where the name comes from. Out comes a 16-byte tag, glued to the end of the ciphertext.
decode recomputes the tag first. Wrong tag, no text, just an error.
The options
The key is 64 hex digits. nonce is 24 hex digits and required. aad is optional hex that the tag covers but nobody encrypts, like a packet header a router has to read. No counter here. The RFC fixes it at 1.
const aead = create("chacha20-poly1305");
const key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
const nonce = "070000004041424344454647";
const aad = "46524f4d3a2048512e"; // "FROM: HQ." in hex
aead.encode("ATTACK AT DAWN", { key, nonce, aad }).text;
// "08b82563520a4c81cdc90976213c" + "cdabbd7363a02df0262f5ad3936c7d87"
Fourteen bytes of ciphertext, sixteen of tag. Node's createCipheriv("chacha20-poly1305") with the same setAAD gives the same bytes. Leave aad out and the first fourteen stay put, only the tag changes. The text never touches aad.
Try to cheat
DAWN to DUSK is 00140405 XORed into the last four bytes. Plain ChaCha20 would take that. And this one?
aead.decode("08b82563520a4c81cdc909622539cdabbd7363a02df0262f5ad3936c7d87", { key, nonce, aad });
// CipherError: [chacha20-poly1305] Tag does not match: wrong key, nonce or aad, or the ciphertext was changed
Nice try. The bytes under the tag do decrypt to ATTACK AT DUSK, but decode never shows them. The tag was computed over DAWN, and a new one needs the key. A wrong nonce or a different aad ends the same way.
Checked against
RFC 8439 section 2.8.2, the sunscreen text sealed byte for byte, and appendix A.5, a real message that opens to text with curly quotes. Poly1305 alone gets section 2.5.2 and test vectors #5 to #11 from appendix A.3. Those are the nasty ones, built to break carries and reductions.
What the tag can't fix
A reused nonce. Same key, same nonce, same keystream, so XOR of two ciphertexts is still XOR of the texts. Worse, the one-time Poly1305 key repeats too. Two tags under one key let an attacker forge new ones. The RFC says it plainly: the nonce "MUST not be repeated for the same key".
A key that isn't 64 hex digits is an InvalidOptionError, and so is a nonce that isn't 24 or an aad that isn't whole bytes of hex. A missing key or nonce is a MissingOptionError. On decode, a ciphertext shorter than the tag or a tag that doesn't match is a CipherError.
Plain TypeScript, BigInt in Poly1305, not constant time. Great for seeing what a tag buys you. Terrible for anything real.