Decision records

ADR-0040: QUIC connection IDs — routing and privacy

Status: accepted (design, D7) — 2026-10-07

Context: ADR-0039. The demux must find the owner of every datagram from its destination connection ID (DCID), without connection state, while observers must not be able to link the CIDs of one connection or count instances. Principles from QUIC-LB (draft-ietf-quic-load-balancers): routable CIDs, encrypted server identifier, config rotation bits, no plaintext server number.

Format (server-issued CIDs, 12 bytes)

byte 0      : cr(2 bits) | random(6 bits)       cr = key generation (0..2; 3 = unroutable)
bytes 1..8  : nonce (64 random bits, fresh per CID)
bytes 9..10 : server id XOR PRF(key[cr], byte0 || nonce)[0..2]
byte 11     : random
  • Server id — 16 bits, assigned by the controller per instance start (monotonic, never reused while a key generation lives), not the slot index: a replaced instance in the same slot gets a new id, so packets for the dead one are never delivered to the new process (they get a stateless reset, ADR-0039).
  • PRF — HMAC-SHA-256 (aws-lc-rs, already in the binary), first two bytes. Each CID has a fresh nonce, so two CIDs of one connection, or of two connections on the same instance, are unrelated to anyone without the key. Integrity is not needed: a forged CID reaches some instance and is dropped there.
  • Keys — 32 random bytes per generation, created by the controller, kept 0600 in its state directory (survive controller and demux restarts) and passed with the sockets. Rotation: new CIDs use the newest generation; the demux decodes the previous ones until the connections that use them are gone (2 generations live at most).
  • Length — fixed at 12: the demux parses short headers without state. quinn checks CID uniqueness per endpoint; across instances uniqueness follows from the server id.
  • Unique, not reused — every generate_cid call draws a fresh nonce; quinn issues NEW_CONNECTION_ID / handles RETIRE_CONNECTION_ID; all of a connection’s CIDs carry the same server id, so every one of them routes to the owner.

Routing rules (demux)

DatagramRoute
short header, cr known, decodes to a live server idthat instance (also after the client’s address changed)
long header (Handshake, Retry-follow-up Initial) with a server-issued DCIDdecoded server id
client Initial / 0-RTT, client-chosen DCIDplacement: pinned table entry, else rendezvous hash over instances accepting new connections; entry pinned 10 s
decodes to an id that is not live, cr = 3 or unknownany serving instance → stateless reset (shared reset key, rate-limited by quinn)
not a QUIC packet (too short, fixed bit clear)dropped

Retry — the Retry’s source CID comes from our generator, so the client’s second Initial (DCID = that CID) routes to the instance that issued the token. 0-RTT carries the client’s original DCID → same placement as its Initial.

Consequences

  • Routing is a pure function of the datagram plus a 10 s table: nothing to synchronize between instances, nothing lost on a demux restart except handshakes in progress.
  • 16-bit server ids bound the number of instance starts per key generation (65 535); the controller rotates the key generation long before (e.g. every 10 000 starts).
  • Observers learn the CID length (12) — the same for every connection — nothing else.