Stream Ciphers and ChaCha20
A stream cipher generates a keystream from a key and a nonce, then XORs it with the plaintext byte for byte - fast, parallel, and friendly to environments without AES hardware (phones, embedded devices, some servers). The modern standard pairing is ChaCha20 for encryption plus Poly1305 for authentication: the same authenticated-encryption story as AES-GCM, with a purely 32-bit arithmetic core that runs well even where AES is in software.
ChaCha20-Poly1305
import os
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
key = ChaCha20Poly1305.generate_key(bit_length=256) # 32 bytes
cipher = ChaCha20Poly1305(key)
nonce = os.urandom(12) # 12 bytes, unique per key
ct = cipher.encrypt(nonce, b"wireless payload", b"msg-id:77")
print(len(ct)) # 16 (payload) + 16 (tag)
pt = cipher.decrypt(nonce, ct, b"msg-id:77")
print(pt) # b"wireless payload"
The call shape is identical to AES-GCM - key, 12-byte nonce, optional AAD - because both are AEAD ciphers. Swapping AES for ChaCha20 is a one-line change in most codebases.
Where ChaCha20 Wins
- Mobile and embedded: no AES-NI needed; ChaCha20 stays fast on generic 32-bit CPUs, which is why it is the default in many mobile TLS profiles.
- Software-only servers: on hardware without AES instructions, ChaCha20-Poly1305 can be faster than AES-GCM in software.
- TLS 1.3: the cipher suite
TLS_CHACHA20_POLY1305_SHA256is standard; browsers and servers negotiate it automatically.
Nonce Handling
Same rule as GCM: the nonce must be unique for a given key, ever. With a 12-byte random nonce the collision risk becomes noticeable only after tens of billions of messages per key, so random nonces are fine for message-level encryption; for counter-based streams (bulk data) derive nonces from a starting value plus a message counter and store that counter with the key. Reusing a ChaCha20 nonce with the same key leaks the XOR of the two plaintexts - a severe failure.
Raw ChaCha20 (without Poly1305)
The library also exposes plain ChaCha20 as a stream cipher. Use it only if you add your own MAC (encrypt-then-MAC) - an unauthenticated keystream is malleable, and an attacker who can flip ciphertext bits can flip predictable plaintext bits:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
cipher = Cipher(algorithms.ChaCha20(key, os.urandom(16)), mode=None)
enc = cipher.encryptor()
ct = enc.update(b"plaintext") + enc.finalize() # no tag - add an HMAC
Summary
Prefer ChaCha20-Poly1305 over raw stream ciphers: it is an AEAD with the same interface as AES-GCM and it shines where AES hardware is absent. Keep nonces unique per key, treat the tag as mandatory, and use plain ChaCha20 only behind your own MAC.