symmetric · level 7

Nonce Management

GCM nonce-reuse, deterministic vs random, the birthday bound, XChaCha20 fix.

220 XP

Nonce Management

A nonce is a number used once. With a stream cipher or AEAD, it's the difference between a system that's secure and one whose ciphertexts are catastrophically forgeable. Most production crypto bugs in the last decade are nonce-management bugs — not because the math is weak, but because "use it once" is harder than it sounds.

The GCM nonce-reuse catastrophe

Encrypt two messages with the same (key, nonce) under AES-GCM and you don't just leak P1 ⊕ P2 — that's the stream-cipher consequence. GCM does worse: an attacker can recover H = AES_K(0), the authentication subkey.

The reason is that GCM's authentication tag is a polynomial evaluation in GF(2^128):

T = H · X_1 + H · X_2 + ... + H · X_n + EK(J0)

Where X_i are blocks of (associated_data || ciphertext) and EK(J0) depends on the nonce. With one ciphertext you can't isolate H. With two ciphertexts under the same nonce, you can subtract them — EK(J0) cancels — and what's left is a polynomial in H whose roots include H. Solve, and you have H.

Once you have H, you can forge any new ciphertext-and-tag pair. Confidentiality and integrity are both gone, and on TLS-style channels that means session hijack.

This is the core of Joux's "forbidden attack" — the reason NIST SP 800-38D writes in capital letters: THE PROBABILITY THAT THE AUTHENTICATED ENCRYPTION FUNCTION EVER WILL BE INVOKED WITH THE SAME (KEY, IV) PAIR ON TWO DIFFERENT SETS OF INPUT DATA SHALL BE NO GREATER THAN 2⁻³².

Two nonce strategies, both safe

There are exactly two nonce strategies that preserve security for any reasonable workload:

1. Deterministic / counter-based

Take a 96-bit counter, start at zero, increment by one per message, never reset. With proper care you can encrypt up to 2^96 messages under one key — enough for the lifetime of every key you'll ever generate.

This is what TLS does (sequence number folded into the nonce). It's what most file-format encryption does (block index = nonce). It's the default, and it's perfectly safe if you have somewhere to store the counter durably.

# AES-GCM with explicit counter — the TLS pattern
counter = 0
def encrypt(plaintext: bytes) -> bytes:
    global counter
    counter += 1
    nonce = counter.to_bytes(12, "big")
    return gcm.encrypt(nonce, plaintext, associated_data=b"")

The hard part is durability. If a process crashes and restarts the counter from 0, you're guaranteed nonce reuse on the first message. Every counter-based scheme needs persistent storage and atomic increment.

2. Random nonces — the birthday problem

If you can't keep a counter (distributed systems, fanout, no shared state), generate the nonce randomly. The trap is the birthday bound: after roughly 2^(n/2) random n-bit values, you'll see a collision with non-negligible probability.

For a 96-bit AES-GCM nonce that's 2^48 ≈ 281 trillion messages. Sounds huge, but a busy CDN can hit 2^32 (~4 billion) per day on a single key. The NIST bound (< 2^32 random nonces per key, probability < 2^-32 of collision) is tight.

192-bit nonces fix this entirely. XSalsa20 and XChaCha20 extend the original 96-bit ChaCha20 nonce to 192 bits by hashing the first 16 bytes into a sub-key with HChaCha20(key, n[0:16]), then running standard ChaCha20 with n[16:24] as the regular nonce. The sub-key construction is collision-resistant; with 192 random bits, the birthday bound is 2^96 — astronomical.

Use XChaCha20-Poly1305 (or AES-GCM-SIV, or any AEAD with extended nonces) anywhere you can't guarantee strict counter management.

Side-by-side decision table

Constraint Pick
You have a clean counter (TLS records, file blocks, sequence #) AES-GCM or ChaCha20-Poly1305 with counter nonce
You don't have a counter, plenty of CPU XChaCha20-Poly1305 with random 192-bit nonce
Tiny embedded device, no AES hardware XChaCha20-Poly1305 (also no AES needed)
You absolutely must keep AES-GCM and have no counter AES-GCM-SIV (misuse-resistant, but slower)

What modern libraries do for you

The current generation of "crypto for the rest of us" libraries hides nonces entirely:

// libsodium's secretstream: counter is internal, you don't see it.
const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const c1 = sodium.crypto_secretstream_xchacha20poly1305_push(state, m1, ...);
const c2 = sodium.crypto_secretstream_xchacha20poly1305_push(state, m2, ...);
// Each push uses a fresh internal nonce. No way to misuse it.
# AWS Encryption SDK derives a fresh data key per message.
client = aws_encryption_sdk.EncryptionSDKClient()
ct, _ = client.encrypt(source=b"hello", key_provider=...)
# Internal: per-message data key + nonce. Application sees neither.

The lesson: let the library manage nonces. When you're forced to deal with them yourself (low-level protocols, custom formats), pick one of the two safe strategies and bolt the type system to enforce it.

Real-world incidents

  • TLS 1.2 BEAST/Lucky13 — wasn't strictly a nonce reuse, but a related class: predictable IVs in CBC mode let attackers chosen-plaintext their way through cookies. Killed by TLS 1.3's mandatory AEAD with sequence-number nonces.
  • WPA2 KRACK (2017) — Vanhoef et al. forced the WPA2 4-way handshake to retransmit, which reset the nonce counter. Same nonce twice = stream-cipher leakage.
  • Sweet32 (2016) — not nonce reuse but a related birthday bound: 64-bit block ciphers (3DES) reuse blocks after 2^32 blocks, leaking authentication cookies in long-lived TLS sessions. Killed by deprecating 64-bit block ciphers.

Each of these was prevented by a single rule: never let two messages share (key, nonce). Implementations get this wrong; the rule itself is simple.

Summary

  • Nonce-reuse with GCM = key-recovery for the auth subkey, not just plaintext leak.
  • 96-bit random nonces are safe up to ~2^32 messages per key; not nearly enough for high-traffic systems.
  • 192-bit nonces (XChaCha20) are safe for any practical workload — the birthday bound becomes 2^96.
  • Deterministic counters are simpler and safer when you have one. Make sure they survive process restarts.
  • Modern libraries hide all of this. Use them.

Tools in the wild

4 tools
  • libsodiumfree tier

    Provides XChaCha20-Poly1305 for safe random nonces. The simplest correct AEAD interface.

    library
  • Per-message data-key derivation hides nonce management entirely. Pick this when key reuse worries you.

    library
  • Tinkfree tier

    Google's misuse-resistant crypto library — handles nonces internally, never exposes the raw API.

    library
  • Rust AEAD implementations with explicit nonce types. Type system catches reuse bugs at compile time.

    library