ChaCha20-Poly1305: The Mobile-Friendly AEAD
TLS 1.3's second mandatory cipher suite pairs ChaCha20 encryption with the Poly1305 message authenticator, delivering AES-GCM-equivalent security without needing any special hardware. Here's how the pairing works.
Interactive ChaCha20-Poly1305 Visualizer
🔐 ChaCha20-Poly1305 AEAD
ChaCha20-Poly1305: The Mobile-Friendly AEAD
Introduction
Just as AES-GCM pairs AES encryption with GHASH authentication, ChaCha20-Poly1305 pairs ChaCha20 encryption with a different, purpose-built authenticator called Poly1305, also designed by Daniel J. Bernstein. Standardized in RFC 8439, this combination is one of exactly two AEAD cipher suites mandated by TLS 1.3 (the other being AES-GCM), and it’s the backbone of the WireGuard VPN protocol. Its defining advantage: excellent, consistent performance in pure software, with no dependency on the specialized AES-NI or PCLMULQDQ hardware instructions that AES-GCM leans on.
Table of Contents
- Why Pair ChaCha20 with Poly1305?
- Deriving the One-Time Poly1305 Key
- How Poly1305 Computes a Tag
- Assembling the AEAD Construction
- A Verified Example
- Python Implementation
- Limitations
- ChaCha20-Poly1305 vs. AES-GCM
- FAQ
- References
Why Pair ChaCha20 with Poly1305?
ChaCha20 alone only provides confidentiality. Poly1305 is a fast, information-theoretically-grounded message authentication code (MAC). It’s not a general-purpose hash function, but a purpose-built one-time authenticator, designed specifically to pair efficiently with a stream cipher like ChaCha20. Together they form a complete AEAD construction: ChaCha20 hides the data, Poly1305 proves nobody tampered with it.
Deriving the One-Time Poly1305 Key
Poly1305 requires a fresh, unique 32-byte key for every single message. Reusing a Poly1305 key across two different messages breaks its security guarantees entirely. Rather than requiring protocols to manage a second secret key, the ChaCha20-Poly1305 construction elegantly derives it from the same key and nonce already in use. It runs the ChaCha20 block function once, with the block counter fixed at 0, and uses the first 32 bytes of that keystream block as the one-time Poly1305 key. The actual message encryption then proceeds with ChaCha20 starting at counter 1, guaranteeing the Poly1305 key and the encryption keystream never overlap.
How Poly1305 Computes a Tag
The one-time key splits into two 16-byte halves, r and s. r is “clamped” (specific bits forced to zero) to keep the arithmetic well-behaved. It’s then treated as a number for polynomial evaluation modulo the prime 2¹³⁰ − 5, the prime that gives Poly1305 its name. The message is split into 16-byte blocks. Each block, with a single bit appended, is added into a running accumulator, which is then multiplied by r (mod that prime). This repeats for every block. The final accumulator, plus s, mod 2¹²⁸, is the 16-byte authentication tag.
Interactive Visualizer
The visualizer above runs the complete, genuine construction: real ChaCha20 keystream generation for both key derivation and encryption, real Poly1305 polynomial evaluation for the tag. It’s verified against Node.js’s own built-in ChaCha20-Poly1305 implementation before being published here. Try the tamper button to see authentication correctly reject an altered ciphertext.
Assembling the AEAD Construction
The final Poly1305 MAC is computed over a specifically formatted message: the associated data (padded to a 16-byte boundary), the ciphertext (padded to a 16-byte boundary), then 8-byte little-endian lengths of the associated data and ciphertext. This exact padding and length-encoding scheme prevents subtle ambiguity attacks where an attacker might otherwise construct two different (AAD, ciphertext) pairs that hash to the same tag.
A Verified Example
Using a small demonstration case with an all-but-one-byte-zero key:
- Key: 32 bytes, all zero except the last byte set to
01 - Nonce: 12 bytes, all zero except the last byte set to
02 - Associated data: “hello aad”
- Plaintext: “The quick brown fox jumps over the lazy dog”
produces a specific ciphertext and 16-byte tag that the visualizer above reproduces exactly, matching Node.js’s own AEAD implementation byte for byte.
Python Implementation
This builds directly on the ChaCha20 block function from the Salsa20/ChaCha20 guide’s Python Implementation section, adding Poly1305’s polynomial-evaluation MAC and the key-derivation and padding rules described above:
MASK32 = 0xFFFFFFFF
def rotl32(x, n):
return ((x << n) | (x >> (32 - n))) & MASK32
def quarter_round(state, a, b, c, d):
state[a] = (state[a] + state[b]) & MASK32; state[d] ^= state[a]; state[d] = rotl32(state[d], 16)
state[c] = (state[c] + state[d]) & MASK32; state[b] ^= state[c]; state[b] = rotl32(state[b], 12)
state[a] = (state[a] + state[b]) & MASK32; state[d] ^= state[a]; state[d] = rotl32(state[d], 8)
state[c] = (state[c] + state[d]) & MASK32; state[b] ^= state[c]; state[b] = rotl32(state[b], 7)
CONSTANTS = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
def chacha20_block(key, counter, nonce):
key_words = [int.from_bytes(key[i:i+4], 'little') for i in range(0, 32, 4)]
nonce_words = [int.from_bytes(nonce[i:i+4], 'little') for i in range(0, 12, 4)]
state = CONSTANTS + key_words + [counter] + nonce_words
working = state.copy()
for _ in range(10):
quarter_round(working, 0, 4, 8, 12); quarter_round(working, 1, 5, 9, 13)
quarter_round(working, 2, 6, 10, 14); quarter_round(working, 3, 7, 11, 15)
quarter_round(working, 0, 5, 10, 15); quarter_round(working, 1, 6, 11, 12)
quarter_round(working, 2, 7, 8, 13); quarter_round(working, 3, 4, 9, 14)
output = [(working[i] + state[i]) & MASK32 for i in range(16)]
return b''.join(w.to_bytes(4, 'little') for w in output)
def chacha20_encrypt(key, counter, nonce, plaintext):
result = bytearray()
for i in range(0, len(plaintext), 64):
keystream = chacha20_block(key, counter + i // 64, nonce)
result.extend(p ^ k for p, k in zip(plaintext[i:i+64], keystream))
return bytes(result)
def poly1305_mac(msg, key):
r = int.from_bytes(key[0:16], 'little') & 0x0ffffffc0ffffffc0ffffffc0fffffff # clamp
s = int.from_bytes(key[16:32], 'little')
acc = 0
p = (1 << 130) - 5
for i in range(0, len(msg), 16):
block = msg[i:i + 16]
n = int.from_bytes(block, 'little') + (1 << (8 * len(block)))
acc = (acc + n) * r % p
acc = (acc + s) & ((1 << 128) - 1)
return acc.to_bytes(16, 'little')
def pad16(data):
return data + b'\x00' * (-len(data) % 16)
def chacha20_aead_encrypt(key, nonce, plaintext, aad=b''):
poly_key = chacha20_block(key, 0, nonce)[:32] # counter 0: derive the one-time MAC key
ciphertext = chacha20_encrypt(key, 1, nonce, plaintext) # counter 1: encrypt the message
mac_data = pad16(aad) + pad16(ciphertext)
mac_data += len(aad).to_bytes(8, 'little') + len(ciphertext).to_bytes(8, 'little')
tag = poly1305_mac(mac_data, poly_key)
return ciphertext, tag
if __name__ == "__main__":
key = bytes(31) + bytes([0x01]) # 32 bytes, all zero except the last byte
nonce = bytes(11) + bytes([0x02]) # 12 bytes, all zero except the last byte
aad = b"hello aad"
plaintext = b"The quick brown fox jumps over the lazy dog"
ciphertext, tag = chacha20_aead_encrypt(key, nonce, plaintext, aad)
print(f"Ciphertext: {ciphertext.hex()}")
print(f"Tag: {tag.hex()}")
Running this with the exact key, nonce, AAD, and plaintext from the Verified Example above reproduces the same ciphertext and tag the visualizer computes. I cross-checked this output against pycryptodome’s own ChaCha20_Poly1305 implementation before writing this up, byte for byte on both the ciphertext and the tag.
Limitations
This is a genuine, complete implementation of the real construction, not a simplified stand-in, but it’s still a teaching artifact:
- No decryption or tag verification. The code only implements the encrypt-and-tag direction; a full implementation would also need to recompute the tag on decryption and reject the message on any mismatch, ideally in constant time.
- No nonce-management logic. As the FAQ below stresses, a Poly1305 key must never be reused across two messages, and the whole construction depends on the nonce never repeating under the same key; this code takes whatever nonce it’s given and doesn’t track uniqueness.
- The Poly1305 accumulator uses arbitrary-precision integers. Python’s big integers make the modular arithmetic trivial to write correctly, but real implementations (especially in C) carefully use fixed-width limbs specifically to run in constant time; this code’s simplicity comes at the cost of not being a template for a hardened implementation.
- Never use this in production. Real applications should use a vetted library’s ChaCha20-Poly1305 (Python’s own
cryptographypackage, for instance), for exactly the reasons the Security Considerations sections throughout this AEAD series keep repeating.
ChaCha20-Poly1305 vs. AES-GCM
Both provide equivalent security guarantees as AEAD constructions. The practical difference is performance profile:
- AES-GCM excels on hardware with AES-NI and PCLMULQDQ instructions (nearly all modern desktop and server CPUs), where it can outperform ChaCha20-Poly1305 substantially.
- ChaCha20-Poly1305 excels in pure software, particularly on mobile and embedded processors that historically lacked (or had inconsistent) AES hardware acceleration. It’s also naturally resistant to cache-timing side-channels, since it uses no table lookups at all.
TLS 1.3 mandates support for both specifically so each side of a connection can negotiate whichever performs best on its own hardware.
FAQ
Why can’t Poly1305 keys be reused?
Poly1305 is a “one-time” authenticator in the cryptographic sense: its security proof depends on each key being used for exactly one message. Reusing a Poly1305 key across two messages can let an attacker solve for the key algebraically and forge tags for arbitrary future messages.
Is ChaCha20-Poly1305 secure?
Yes. It’s one of exactly two AEAD constructions mandated by TLS 1.3, alongside AES-GCM, and has no known practical weaknesses in its standard configuration.
Why does the construction start ChaCha20 at counter 1 instead of 0?
Counter 0 generates the one-time Poly1305 key itself. Starting the actual message encryption at counter 1 guarantees the keystream used to derive the authentication key never overlaps with the keystream used to encrypt data. Reusing that block would leak the Poly1305 key.
What does the “1305” in Poly1305 refer to?
The prime modulus 2¹³⁰ − 5, which the algorithm’s polynomial evaluation is computed modulo. “1305” loosely evokes “130-5.”
Does WhatsApp or Signal use ChaCha20-Poly1305?
Signal Protocol implementations commonly use AES-GCM or ChaCha20-Poly1305 for the actual message encryption layer (after the Double Ratchet establishes per-message keys). See the Signal Protocol guide for the full picture of how those pieces fit together.
References
-
RFC 8439. “ChaCha20 and Poly1305 for IETF Protocols.” IETF, 2018. Available at: https://datatracker.ietf.org/doc/html/rfc8439
-
Bernstein, D. J. “The Poly1305-AES message-authentication code.” FSE 2005. This is the original Poly1305 design paper.
-
Langley, A., Chang, W., Mavrogiannopoulos, N., Strombergson, J., and Josefsson, S. “ChaCha20-Poly1305 Cipher Suites for TLS.” RFC 7905, 2016.
-
Donenfeld, J. A. “WireGuard: Next Generation Kernel Network Tunnel.” NDSS 2017.