Skip to main content

QUIC cryptography — RFC 9001

core.net.quic.crypto wraps the TLS 1.3 key schedule into the per-packet-protection operations QUIC requires. Every public function either takes or returns typed secrets that satisfy refinement contracts (length, AEAD tag size, directional separation).

Initial secret derivation (§5.2)​

The client's destination CID seeds the Initial key schedule:

initial_salt = 0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a

initial_secret = HKDF-Extract(initial_salt, client_dst_cid)
client_initial_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32)
server_initial_secret = HKDF-Expand-Label(initial_secret, "server in", "", 32)

client_key / server_key = HKDF-Expand-Label(role_secret, "quic key", "", 16)
client_iv / server_iv = HKDF-Expand-Label(role_secret, "quic iv", "", 12)
client_hp / server_hp = HKDF-Expand-Label(role_secret, "quic hp", "", 16)

RFC 9001 §A.1 known-answer for client DCID 0x8394c8f03e515708:

OutputExpected
client_initial_secretc00cf151ca5be075ed0ebfb5c80323c4 2d6b7db67881289af4008f1f6c357aea
client_initial_key1f369613dd76d5467730efcbe3b1a22d
client_initial_ivfa044b2f42a3fd3b46fb255c
client_initial_hp9f50449e04a0e810283a1e9933adedd2

API:

mount core.net.quic.crypto.initial.{
derive_initial_secrets, derive_initial_keys, InitialKeys,
};

let secrets = derive_initial_secrets(&dcid_bytes)?;
let client_keys: InitialKeys = derive_initial_keys(secrets.client.as_slice())?;
// client_keys.key : List<Byte> (16 bytes)
// client_keys.iv : [Byte; 12]
// client_keys.hp : List<Byte> (16 bytes)

See rfc9001_initial_secret_kat and initial_salt_label_kat.

Per-packet AEAD (§5.3)​

Each packet protects its payload with AEAD_AES_128_GCM (or GCM-256 / ChaCha20-Poly1305 post-handshake):

nonce = iv XOR pad_left_64(packet_number, 12 bytes)
aad = plaintext header bytes (up through PN field)
ciphertext = AEAD_seal(key, nonce, aad, payload)
tag = last 16 bytes of ciphertext

The sequence counter is strictly monotonically increasing — every successful seal / open increments the space's next-PN counter by exactly one (V8 theorem, v8_aead_seq_theorem). Crossing PN boundaries across PN spaces does not tie them together: Initial, Handshake, and Application are independent (V4 theorem, v4_pn_monotonic_theorem).

Header protection (§5.4)​

After sealing the payload, QUIC protects the first byte and the packet-number bytes with a mask derived from the ciphertext sample:

sample = ciphertext[offset_of_PN_start + 4 ..= offset_of_PN_start + 19] (16 bytes)
mask = first 5 bytes of:
* AES-128-ECB(hp_key, sample) — AES suite
* ChaCha20(hp_key, counter = u32_le(sample[0..4]),
nonce = sample[4..16],
zeros) — ChaCha20 suite

Applied bits:

  • First byte: low 4 bits (long header) or low 5 bits (short header) XOR mask[0].
  • Packet number bytes: every byte in the PN field XORs with mask[1 + i].

The HP key flavour is encoded as HpKey.AesEcb128([Byte;16]), HpKey.AesEcb256([Byte;32]), or HpKey.ChaCha20Block([Byte;32]) — constructed at key-derivation time based on the AEAD family.

API:

mount core.net.quic.crypto.header_protection.{
HpKey, compute_hp_mask, apply_hp_mask,
};

let mask: [Byte; 5] = compute_hp_mask(&hp_key, &sample);
apply_hp_mask(&mut wire, pn_offset, pn_len, &mask, /* is_long */ true);

KATs:

Retry integrity tag (§5.8)​

The Retry packet carries no encrypted payload; its trailing 16 bytes authenticate the entire Retry header plus the original DCID:

pseudo_packet = len(odcid) || odcid || retry_packet_without_tag
integrity_tag = AEAD_AES_128_GCM_tag(
key = RETRY_INTEGRITY_KEY_V1,
nonce = RETRY_INTEGRITY_NONCE_V1,
aad = pseudo_packet,
plain = [])

Fixed constants:

RETRY_INTEGRITY_KEY_V1 = 0xbe0c690b9f66575a1d766b54e368c84e
RETRY_INTEGRITY_NONCE_V1 = 0x461599d35d632bf2239825bb

API:

mount core.net.quic.crypto.{
compute_retry_integrity_tag, verify_retry_integrity_tag,
};

let tag: [Byte; 16] = compute_retry_integrity_tag(&odcid, &retry_header)?;
let ok = verify_retry_integrity_tag(&odcid, &retry_packet_bytes)?;

See rfc9001_retry_integrity_kat.

Key update (§6)​

RFC 9001 §6 rotates the 1-RTT traffic keys via the TLS 1.3 application-traffic secret chain:

current_secret_N+1 = HKDF-Expand-Label(current_secret_N, "quic ku", "", Hash.len)
key_N+1 = HKDF-Expand-Label(current_secret_N+1, "quic key", "", key_len)
iv_N+1 = HKDF-Expand-Label(current_secret_N+1, "quic iv", "", 12)

HP keys are not rotated (per spec). The key_phase bit in short-header first byte signals which generation a packet uses. Limits:

  • Confidentiality: max 2²³ records for AES-GCM, 2²³ records for ChaCha20-Poly1305 (RFC 8446 §5.5).
  • Integrity: 2⁵² invalid records observed triggers a AEAD_LIMIT_REACHED close.

V2 theorem (v2_key_update_theorem) proves the counter is monotonic, the peer gap is at most 1, and the per-direction record sequence resets on rotation.

References​

  • vcs/specs/L2-standard/net/kat/rfc9001_client_initial.vr
  • vcs/specs/L2-standard/net/kat/rfc9001_server_initial.vr
  • vcs/specs/L2-standard/net/quic/initial_salt_label_kat.vr
  • vcs/specs/L2-standard/net/quic/rfc9001_initial_secret_kat.vr
  • vcs/specs/L2-standard/net/quic/rfc9001_retry_integrity_kat.vr
  • vcs/specs/L2-standard/net/quic/rfc9001_chacha20_hp_kat.vr
  • vcs/specs/L2-standard/net/quic/hp_mask_aes_roundtrip.vr
  • vcs/specs/L2-standard/net/quic/retry_constants_surface.vr
  • vcs/specs/L2-standard/net/quic/key_update_actions.vr
  • vcs/specs/L2-standard/net/quic/key_update_limits.vr
  • vcs/specs/L2-standard/net/quic/key_update_typecheck.vr
  • vcs/specs/L2-standard/net/tls13/v2_key_update_theorem.vr
  • vcs/specs/L2-standard/net/tls13/v8_aead_seq_theorem.vr
  • vcs/specs/L2-standard/net/quic/v4_pn_monotonic_theorem.vr