Deep dive
How it works
In: 32-byte key, 12- or 24-byte nonce, plaintext, optional associated data · Out: ciphertext plus 16-byte tag (on open: the plaintext or an error) · State: none; the caller must never repeat a nonce under one key · Cost: O(n), order of 1-3 cycles/byte
- Encrypt: a stream cipher (AES in counter mode, or ChaCha20) turns (key, nonce, counter) into a keystream; ciphertext = plaintext XOR keystream.
- Authenticate: a one-time polynomial MAC (GHASH for GCM, Poly1305 for ChaCha) runs over AAD || ciphertext || lengths, keyed from the same key/nonce. The result is the tag.
- Decrypt: recompute the tag, compare in constant time, and only then release plaintext.
key, nonce ──► keystream ──XOR── plaintext ──► ciphertext ─┐
└─► MAC key ── MAC(AAD, ciphertext) ─────┴─► tag (16 B)
wire format: nonce (12|24 B) || ciphertext || tag
Why nonce reuse is fatal: two messages under the same (key, nonce) share a keystream, so XOR of ciphertexts = XOR of plaintexts. For GCM it also leaks the GHASH key H, which lets an attacker forge tags for any message under that key.
Code
Rust, RustCrypto crates (chacha20poly1305 = "0.10", aes-gcm = "0.10").
use chacha20poly1305::{
aead::{Aead, AeadCore, KeyInit, OsRng, Payload},
Key, XChaCha20Poly1305, XNonce,
};
const NONCE_LEN: usize = 24;
const TAG_LEN: usize = 16;
/// Returns nonce || ciphertext || tag. `aad` is authenticated but not encrypted
/// (bind it to context: record id, user id, version) so ciphertexts can't be swapped.
pub fn seal(key: &[u8; 32], plaintext: &[u8], aad: &[u8]) -> Vec<u8> {
let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); // 192-bit random: collision-safe
let ct = cipher
.encrypt(&nonce, Payload { msg: plaintext, aad })
.expect("only fails on absurd lengths");
let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
out
}
pub fn open(key: &[u8; 32], sealed: &[u8], aad: &[u8]) -> Option<Vec<u8>> {
if sealed.len() < NONCE_LEN + TAG_LEN {
return None;
}
let (nonce, ct) = sealed.split_at(NONCE_LEN);
let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: ct, aad }).ok()
}
// High-volume channel with AES-256-GCM: counter nonces, one key per direction.
use aes_gcm::Aes256Gcm;
pub struct Sealer {
cipher: Aes256Gcm,
counter: u64,
}
impl Sealer {
pub fn new(key: &[u8; 32]) -> Self {
Self { cipher: Aes256Gcm::new(aes_gcm::Key::<Aes256Gcm>::from_slice(key)), counter: 0 }
}
pub fn seal(&mut self, plaintext: &[u8]) -> (u64, Vec<u8>) {
let n = self.counter;
self.counter = n.checked_add(1).expect("rekey before the counter wraps");
let mut nonce = [0u8; 12];
nonce[4..].copy_from_slice(&n.to_be_bytes()); // never repeats under this key
let ct = self.cipher.encrypt(aes_gcm::Nonce::from_slice(&nonce), plaintext).unwrap();
(n, ct) // receiver rejects n <= last seen to stop replays
}
}
Complexity and constants
- AES-GCM with AES-NI + PCLMULQDQ (or VAES on recent x86): order of several GB/s per core. Without hardware AES, software AES is slow and often not constant-time.
- ChaCha20-Poly1305: order of 1-3 GB/s per core with AVX2; the faster choice on low-end ARM without crypto extensions.
- Overhead per message: 12 or 24 B nonce + 16 B tag.
- Limits: AES-GCM max plaintext per message about 64 GiB; with random 96-bit nonces, at most 2^32 messages per key (NIST SP 800-38D). ChaCha20-Poly1305 (IETF) max about 256 GiB per message.
Efficiency
- Cost: order of 1 cycle/byte (AES-GCM with AES-NI + PCLMULQDQ) to 1-3 cycles/byte (ChaCha20-Poly1305 with SIMD); 28-40 B nonce+tag overhead per message. Bound: compute on large messages, per-call overhead on small ones.
- Levers:
- Match the CPU — AES-GCM with AES-NI/ARMv8 crypto, ChaCha20-Poly1305 without (RustCrypto crates use AES-NI/CLMUL and AVX2 when present, per their READMEs) — every deployment.
- Reuse the cipher per key and seal in place — saves key schedule and copies (order of) — many small messages.
- Pack records into one message or 64 KiB chunks — overhead amortized — small records.
- Counter nonces — no CSPRNG call per message — single writer per key.
- Naive → standard → state of the art:
- Naive · unauthenticated CBC/CTR plus separate MAC, new cipher object per message
- Standard · one AEAD call, cached cipher, hardware-matched algorithm, order of GB/s per core
- State of the art · AEGIS on AES hardware (faster than GCM, see Variants); or let TLS 1.3 do it
Implementation notes
- Key: 32 random bytes from the OS CSPRNG, or derived with HKDF from a master key (see hashes). Never a password directly; use Argon2id first.
- Nonce strategy: random with XChaCha; counter with AES-GCM/ChaCha20 when one writer owns the key. With multiple writers and 12-byte nonces, derive a per-writer subkey.
- Large files: split into chunks (64 KiB is a common size), each sealed with nonce = prefix || chunk index and a "last chunk" flag in the AAD, so reordering and truncation fail. libsodium
secretstreamdoes this for you. - Envelope encryption: data key encrypts data; a KMS key encrypts the data key. Rotate the KMS key without re-encrypting data.
- Version byte in front of the ciphertext lets you change algorithms later.
Pitfalls
- Nonce reuse: random 12-byte nonces in a long-lived key, counters reset after restart, same key in two processes.
- Using plaintext before the tag is checked (streaming decrypt that writes output early).
- Comparing tags with
==in hand-rolled code; libraries do constant-time compare, so use them. - Assuming key commitment: GCM and Poly1305 ciphertexts can be crafted to decrypt validly under two keys ("partitioning oracle" attacks on password-based or multi-key schemes). Add a key commitment (e.g., HMAC of the key in the header) if a ciphertext may be tried under several keys.
- Using unauthenticated modes (CBC, CTR) or ECB at all.
- Truncating tags to save bytes.
Variants and alternatives
In production
- Cloud: AWS KMS (256-bit AES-GCM, docs); Azure Storage service-side encryption (AES-GCM, docs); envelope encryption in GCP KMS.
- Networking: IPsec ESP (RFC 4106), WireGuard (protocol), TLS 1.3 and QUIC (RFC 9001).
- Government: CNSA profile (RFC 9151).
- Tools: OpenSSH (
chacha20-poly1305@openssh.com,aes256-gcm), age (ChaCha20-Poly1305).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| aes-gcm, chacha20poly1305 | Rust | RustCrypto/AEADs | cargo add aes-gcm chacha20poly1305 | — | — | — | — | pure Rust, constant time; each crate's README states one NCC Group audit; XChaCha20 included |
| aws-lc-rs | Rust | aws/aws-lc-rs | cargo add aws-lc-rs | — | — | — | — | ring-style API over AWS-LC; FIPS option via aws-lc-fips-sys |
| ring | Rust | briansmith/ring | cargo add ring | — | — | — | — | AES-GCM, ChaCha20-Poly1305; much C/asm from BoringSSL |
| libsodium | C | jedisct1/libsodium | brew install libsodium | — | — | — | — | XChaCha20-Poly1305, AES-256-GCM, secretstream; bindings for most languages |
| Tink | Java | tink-crypto/tink-java | Maven com.google.crypto.tink:tink | — | — | — | — | high-level API that manages nonces and key formats; also tink-py (pip install tink) |
Sources
- Dworkin, NIST SP 800-38D: Recommendation for Block Cipher Modes of Operation: GCM and GMAC, 2007. spec
- Nir, Langley, RFC 8439: ChaCha20 and Poly1305 for IETF Protocols, 2018. RFC
- Gueron, Langley, Lindell, RFC 8452: AES-GCM-SIV, 2019. RFC
- Böck et al., Nonce-Disrespecting Adversaries: Practical Forgery Attacks on GCM in TLS, USENIX WOOT 2016. paper
- Len, Grubbs, Ristenpart, Partitioning Oracle Attacks, USENIX Security 2021. paper
- Harkins, RFC 5297: Synthetic Initialization Vector (SIV) Authenticated Encryption Using AES, 2008. RFC