# Post-quantum handshakes Status: draft. Decisions below were made on 2026-10-13 and 2026-10-14; open items are listed at the end. Post-quantum handshakes ship hybrid only, on version 3 certificates (decided 2026-10-14); see [Implementation](#implementation-certificates-and-the-hybrid-handshake). ## Goals 1. **Confidentiality against harvest-now-decrypt-later.** Traffic recorded today must stay secret after a cryptographically relevant quantum computer exists. This needs a post-quantum key exchange now. 2. **Post-quantum authentication.** Needs certificates whose CA signature and host keys are post-quantum, so certificates have to be reissued or extended. Post-quantum key exchange ships only together with it: there is no post-quantum handshake on version 2 certificates. 3. **Never rely on IP fragmentation.** ML-KEM and ML-DSA do not fit in one packet, so the handshake must carry messages larger than the path MTU itself. Non-goals: identity hiding beyond what IX offers today, and signatures at handshake time. The only signature in the system stays the CA signature inside the certificate. ## Primitives and sizes | Primitive | Size | |---|---| | ML-KEM-1024 encapsulation key | 1568 B | | ML-KEM-1024 ciphertext | 1568 B | | ML-DSA-87 public key / signature | 2592 B / 4627 B | | Today's IX msg1 / msg2 (v2 cert, 151 B) | 252 B / 287 B | `crypto/mlkem` is available in Go 1.26; `crypto/mldsa` arrives in Go 1.27, which `go.mod` requires. ## An ephemeral-KEM handshake, built and dropped The first design was `ix_psk2_mlkem1024`: classical IX on version 2 certificates with an ephemeral ML-KEM-1024 exchange mixed in through the `psk2` slot, so recorded traffic was protected without reissuing certificates. It was built, field tested and then dropped on 2026-10-14, before any release. Post-quantum handshakes ship hybrid only, for three reasons: - It left authentication classical, which a deployment wanting quantum safety needs anyway, and a second post-quantum handshake would be twice the code to review and keep. - It completes in two messages, so the responder could not tell which of two crossing attempts the initiator kept (see [Crossing attempts](#three-messages-in-the-manager)); on real hosts 5 of 100 first pings failed at 50 ms round trip for that reason. - Its `psk` token came last in msg2, so the responder's static key travelled under classical keys only. Subtype 2 and payload field 9 (its encapsulation key) stay reserved. The code is on the `pq-handshake` branch for reference. ## Hybrid IX with certificate v3 Decided on 2026-10-14 and implemented on the `pq-identity` branch (see [Implementation](#implementation-certificates-and-the-hybrid-handshake)). ### Certificate v3 A version 3 certificate is a version 2 certificate extended with post-quantum keys. It is its own format, with its own PEM banner (`NEBULA CERTIFICATE V3`) and a strict parser, but its first fields are exactly the fields of a v2 certificate: ``` CertificateV3 ::= SEQUENCE { details [0] Details, -- v2 details, unchanged curve [1] Curve DEFAULT curve25519, publicKey [2] OCTET STRING, -- classical: X25519 / P256 for hosts, Ed25519 / ECDSA P256 for CAs signature [3] OCTET STRING, -- classical CA signature over details || curve || publicKey, as in v2 pqSuite [4] OCTET STRING, -- one byte, the PQSuite; always present pqPublicKey [5] OCTET STRING, -- the suite's KEM public key for hosts, its signature public key for CAs pqSignature [6] OCTET STRING -- the suite's signature by the issuing CA, context "nebula certificate v3", -- over the DER of fields 0 to 5, curve always present } ``` - **The suite.** `pqSuite` names the post-quantum algorithms the way `curve` names the classical ones: the KEM of host keys, and the signature algorithm of CA keys and of the certificates a CA signs. A CA and every certificate it signs share a suite, as they share a curve; the CA pool rejects a mismatch, and the post-quantum signature covers the suite, so a certificate can not be relabelled. The field is always present, in handshakes too, with no default, and 0 is never valid. There is one suite today, `MLKEM1024_MLDSA87` (1): ML-KEM-1024 hosts, ML-DSA-87 CAs. A new suite is an entry in the cert package's suite table plus, for a new KEM, a hybrid subtype; no new certificate version. Changing a CA's suite means a new CA, or a new v3 extension of its v2 form that replaces the old one, since one v2 CA has one v3 extension. - **The v2 form.** Fields 0 to 3 are a byte exact v2 certificate, signed by the v2 form of the CA. A v3 holder presents it in classical handshakes, so peers that only speak v2 accept it as they always have, and old parsers never see v3 bytes. A node therefore needs one certificate, not a v2 and v3 pair. - **Signatures.** Both must verify. The ML-DSA-87 signature covers the classical signature too, so neither signature can be swapped out (which also makes P256 low/high-s alternates moot for v3). If ML-DSA fell classically and someone swapped `pqPublicKey`, the hybrid handshake still needs the classical static key, which only the classical signature covers. The ML-DSA-87 signature is deterministic (FIPS 204 section 3.4), so extending the same v2 certificate with the same key twice gives the same v3 certificate and fingerprint: `pq-extend` is idempotent. The hedged variant guards signers an attacker can fault physically, which a CA key in a file is not, and the Ed25519 signature beside it is deterministic too. - **Issuer and fingerprints.** The details are v2-shaped, so the issuer is the fingerprint of the CA's v2 form. A v3 certificate's own fingerprint is the SHA-256 of its whole encoding and differs from its v2 form's. Both identify it. To block a v3 certificate, block its v2 fingerprint: the pool checks it for the v3 certificate and for its v2 form alike, so one entry covers both forms, while blocking only the v3 fingerprint leaves the v2 form usable in classical handshakes. `nebula-cert print` leads with both fingerprints and says which one to block. Firewall `ca_sha` rules match a v3 peer by either fingerprint of its CA. - **CA pool.** A v3 CA verifies only v3 certificates. To accept v2 certificates as well the pool must also list the CA's v2 form. Dropping the v2 form is how a node requires post-quantum authentication; there is no separate setting. Two different v3 extensions of one v2 CA are rejected as ambiguous. - **Keys are composite**, classical followed by post-quantum, wherever a v3 certificate deals in them: `PublicKey()` returns `classical || pq`, and private keys are `classical || seed` (64 byte ML-KEM seed for hosts, 32 byte ML-DSA seed for CAs), in one PEM block in `pki.key` with banners such as `NEBULA X25519 MLKEM1024 PRIVATE KEY` and `NEBULA ED25519 MLDSA87 PRIVATE KEY`. The banner names the post-quantum algorithm, not the suite, because a seed's size does not tell the algorithm's strength. - **Both curves.** Curve25519 and P256; the post-quantum half does not depend on the curve. - **In-place upgrade.** `nebula-cert pq-extend` turns an existing v2 CA into a v3 CA whose v2 form is the old CA certificate, byte for byte, and extends existing host certificates the same way. Fingerprints, blocklists and trust in the v2 forms carry over unchanged. Hosts get their ML-KEM key with `nebula-cert keygen -in-key`, which keeps the classical key. | Certificate | Full | In a handshake | v2 form | |---|---|---|---| | CA, Curve25519 | 7365 B | n/a | 134 B | | Host, Curve25519 | 6378 B | 4772 B | 171 B | `crypto/mldsa` is new in Go 1.27, so `go.mod` moves to Go 1.27. ### Handshake A new subtype, `ix_hybrid_mlkem1024` (3), runs `HandshakeHybridIX` from [github.com/DefinedNet/noise](https://github.com/DefinedNet/noise) (its `PQ_RESEARCH.md` gives the design rationale and security argument), with ML-KEM-1024 supplied by nebula: ``` -> e, s <- ekem, skem, e, ee, se, s, es -> skem ``` - `e` and `s` carry composite keys, X25519 (or P256) followed by ML-KEM-1024. DH tokens use the classical halves, `ekem` and `skem` encapsulate to the ML-KEM halves, and every secret goes into the one chaining key. - **The responder's `e` is classical only.** It follows the responder's `ekem`, which already completed the ephemeral KEM exchange, so it carries only the X25519 (or P256) key that `ee` and `se` need. A second ephemeral KEM exchange would add nothing: one `ekem` is lost only if the initiator's ephemeral private key or the responder's encapsulation randomness is, and a reverse one would be lost in the same cases. PQNoise's pqIX has no responder `e` at all. The static ML-KEM key is the post-quantum half of the certificate's public key, so it travels in `s` and the handshake form of the certificate leaves out both keys. - **KEM tokens first.** msg2 starts with `ekem, skem`, so the responder's static key and certificate are encrypted under keys that already include both KEM secrets: the responder's identity is protected against harvest-now-decrypt-later, as TLS 1.3 with `X25519MLKEM768` protects the server certificate. (OpenSSH offers no comparison: its hybrid key exchange sends the host key in the clear.) The initiator's certificate travels in msg1 in the clear, as in IX today. - **Authentication.** The responder authenticates the initiator when msg3 decrypts (it depends on msg2's `skem` to the initiator's static key); the initiator authenticates the responder when the first packet under the new keys decrypts (it depends on msg3's `skem`). Neither side needs a signature. Knowing only a peer's classical private key gets an impersonator nowhere: as an initiator it cannot read msg2, as a responder it cannot read msg3. - **The two static ML-KEM keys see different ciphertexts.** msg2's `skem` ciphertext is encrypted under a key derived from the `ekem` secret, which whoever sent msg2 chose, so anyone who can address a pending initiator's index can have the initiator decapsulate a ciphertext of their choosing with its static key. That is the IND-CCA setting ML-KEM is designed for, and `crypto/mlkem` is constant time. The responder decrypts msg3's `skem` ciphertext under the keys of a completed msg2 before decapsulating, so it only ever decapsulates ciphertexts the genuine initiator sealed. - v3 certificates go with this subtype and nothing else: a v3 certificate on any other subtype, or a v2 one on this one, is a protocol error. - **One subtype per KEM.** A hybrid subtype names its KEM, and a v3 holder initiates the subtype of its suite's KEM. Peers need the same KEM, not the same suite: suites that differ only in their signature algorithm share a subtype, and each certificate carries its own suite. A responder whose suite uses another KEM drops msg1, so the pair behaves as if the responder did not speak PQ: classical fallback, or no tunnel under `require`. A suite's KEM must have a subtype for the pki to load a certificate in it. | Message | Size (Curve25519) | Data fragments (k) | |---|---|---| | msg1 | 8009 B | 7 | | msg2 | 9628 B | 9 | | msg3 | 1616 B | 2 | **Dependency.** DefinedNet/noise is a private repository today. `go.mod` points at a local clone with a `replace` directive; the module has to be published (or the hybrid patterns upstreamed to flynn/noise) before this can merge. ### Three messages in the manager - **Responder.** After msg2 the responder keeps the handshake pending under its local index, the multi-message responder the manager lacked before. A repeated msg1 maps to it through the answered-msg1 cache and gets msg2 resent with fresh symbols. msg3's fragments name the responder's index, so they reach the pending entry and never the pre-authentication table; its reassembly is capped at 4 KiB. Until msg3 verifies, data for that index is dropped quietly: a RecvError would tear down the initiator's new tunnel. On completing, the responder sends an empty `Test`/`TestReply` under the new keys so the initiator hears back without traffic. A pending responder expires after the handshake timeout. - **Initiator.** Writing msg3 yields the transport keys, so the tunnel goes live at once, readable only by the real responder; msg3 goes out before any packets cached during the handshake. Until anything from the responder decrypts on the new tunnel, msg3 is resent with fresh symbols on a linear backoff, and if nothing has decrypted by the handshake timeout the tunnel is closed. Losing msg3 costs the data sent right behind it, which the overlay retransmits. - **Lost confirmation.** A msg3 fragment for a v3 tunnel the responder already completed means the initiator is still waiting, so the responder confirms again, at most once per retry interval and only to the tunnel's own remote, never to the fragment's source. - **Crossing attempts.** The initiator alternates, and its first classical attempt follows the hybrid msg1 anywhere from right away to three retry intervals later (the retry timer lands two slots ahead on the timer wheel, which moves on whole ticks), so at real round-trip times it often goes out before msg2 is back. Answered, it would complete at once on the responder while the hybrid handshake waits for msg3. So for four retry intervals after answering a peer's new hybrid msg1, the responder leaves that peer's classical msg1s unanswered: the crossing one arrives within the window whatever the round trip, because both take the same path. The window is short so an initiator whose hybrid attempt fails on its own side (one that does not yet trust our v3 CA, during a rollout) still gets a classical answer to a later attempt; a block lasting as long as the hybrid state would refuse it until its handshake timed out, and its next handshake would start with a hybrid msg1 again. A classical msg1 that still crosses, past the window on a very slow path, is answered, but msg3 settles it: the initiator only sends msg3 after committing to hybrid, so the responder takes the hybrid tunnel even though the classical one is newer, rather than rejecting it as too old. - **A failed attempt.** A post-quantum attempt can fail fatally where the classical one would not, for example a v3 certificate from a CA the initiator trusts only in its v2 form. The failed attempt is dropped and the handshake continues classical. With `require` the handshake ends. ### Rollout with v3 - `handshakes.pq` only does anything on a node with a v3 certificate. Without one, `initiate`, `accept` and `sticky` log a warning and are ignored, and `require` is refused at startup and on reload, since such a node could reach no one. - A responder without a v3 certificate, or whose suite uses another KEM, drops hybrid msg1s before reassembly, and the initiator falls back to classical. - `handshakes.pq.require` means hybrid only, in both directions, so every peer needs a v3 certificate. - Post-quantum authentication is required through the CA pool, by not listing the CA's v2 form. A sticky or `require` initiator resists active downgrade of the key exchange; only the CA pool resists downgrade of authentication, since a quantum attacker could forge a v2 form. - `sticky` stays off by default for v3 holders too (decided 2026-10-14): a peer re-issued back to a v2-only certificate would otherwise be unreachable until this process restarts. Deployments that want downgrade resistance turn on `sticky` or `require`, or drop the CA's v2 form from the pool. ## Fragment transport ### Requirements - No IP fragmentation: every datagram's UDP payload is at most 1232 bytes **after** relay wrapping (16 B header + 16 B tag), the IPv6 minimum MTU budget also used by DNS Flag Day 2020 and Solana's Turbine. - Every fragment of a handshake uses the same 5-tuple. - Bounded pre-authentication state, bounded amplification, selective loss recovery, tolerance of reordering and duplication, and a strictly validated, fuzzable parser. ### Wire format Every packet of a fragmented handshake subtype is: ``` Nebula header (16) Version | Type=Handshake | Subtype | Reserved=0 | RemoteIndex | Counter Fragment header (8) FragID u32 | MsgLen u16 | Index u8 | Flags u8 Cookie (16) only when Flags has 0x01 Symbol SymbolSize bytes ``` - `Counter` is the Noise message number (1, 2, 3). `RemoteIndex` is 0 for msg1, as today. - `FragID` is the sender's local index (random, non-zero). - `MsgLen` is the length of the whole message. Both ends derive the layout from it: `K = ceil(MsgLen / 1160)`, `SymbolSize = ceil(MsgLen / K)`. Every symbol has the same size. Room for a cookie is always reserved, so a packet is at most 16 + 8 + 16 + 1160 = 1200 bytes with or without one. - `Index < K` is a data symbol (a slice of the message, the last one zero padded); `Index >= K` is a repair symbol. - `Flags` bit 0x01 means a cookie follows the fragment header. Other bits must be zero. A cookie reply, sent by a responder under load, is a bare 32 byte packet: ``` Nebula header (16) Version | Type=Handshake | Subtype | Reserved=0 | RemoteIndex=FragID | Counter=0 Cookie (16) ``` It names the initiator's FragID, so the initiator finds its attempt the same way it finds msg2. ### Forward error correction Messages are protected with a systematic Reed-Solomon code over GF(2^8), implemented in `handshake/fec`: - The repair rows form a Cauchy matrix, `coefficient(row, col) = 1 / (row XOR col)` with `row >= K > col`, so every square submatrix is invertible and **any K distinct symbols reconstruct the message**. Naive identity-over-Vandermonde constructions are not MDS (the PAR1 flaw and the 1997 Plank tutorial erratum). - A repair row depends only on its index, so repair symbols are generated on demand and their indexes never need to be agreed in advance. Up to 256 symbols exist per message. - Known-answer vectors in `fec_test.go` pin the encoding and were cross-checked against an independent implementation; changing them is a wire format change. - Cost for a 9.8 KB message (k=9): about 13 µs for three repair symbols, about 19 µs to decode with three data symbols missing. **The sender chooses m**, the number of repair symbols per attempt. The receiver only needs any K symbols, so this is policy and can change without a protocol change. The starting rule is `m = max(2, ceil(K/3))`. **Retries send fresh symbols.** Attempt 0 sends indexes `0 .. K+m-1`; each retry sends the next `K+m` unused indexes. The receiver merges all distinct symbols across attempts, so no received fragment is wasted. Indexes are a deterministic function of the message, so retransmits stay idempotent: the responder caches msg2 itself and regenerates any symbol. **Repair symbols trail the data.** Data symbols go out back to back; repair symbols follow after a short delay, so a queue tail-drop at the end of the data run does not also take the repair. In simulation this lifts small messages substantially (k=2 at 10% loss, burst 8: 91.4% to 98.2% first-attempt success). Pacing every fragment was also evaluated; it gains about 5 more points for k=7..9 on bursty paths, which is not worth the extra machinery, and the retry with fresh symbols covers the remainder. ### Receiver rules Implemented in `handshake/fragment` (`Sender`, `Assembly`, and the capped `Table`). - Fragments are stored individually as they arrive and charged against a memory cap. Nothing is allocated from a claimed size alone (FragmentSmack, game-engine "fragment bombs"). - `MsgLen` must be identical for every fragment of a `FragID`; a mismatch drops the whole entry. msg1 is capped at `MsgLen <= 16384` (K <= 15). - The decoder keeps at most K distinct symbols, so an entry never exceeds about MsgLen bytes however many repair symbols arrive. - When K symbols are present the message is decoded and handed to the handshake `Machine` as a synthetic `[header | message]`. The Machine never sees fragments. - A completed message the responder answered is remembered without its symbols, outside the memory and per-source caps, for the idle timeout: which symbol indexes it has seen, and its hash. Fragments still trailing the attempt that completed it are counted and dropped. Once as many new symbols as it has data symbols arrive, which only a retry offers, it is reported as a repeat and the cached response is resent. Without this, the repair fragments trailing a msg1 whose data fragments already completed it open an entry that for K above the repair count can never complete, and back to back handshakes from one source run into its quota (seen on real hosts: the fifth hybrid handshake within 3 s fell back to classical). A message that decoded but was not answered is forgotten, and the next attempt rebuilds it. - If Noise rejects a decoded message, the entry is discarded and the next attempt supplies fresh symbols. (Possible refinement: keep a few spare symbols so an injected bad symbol can be identified by re-encoding instead of waiting for a retry.) - Pre-authentication msg1 entries live in a table keyed by (source address or relay index, FragID) with a total memory cap, per-source quotas (/32 for IPv4, /64 for IPv6), a sliding TTL of about 2 s refreshed by progress, and random eviction under pressure. msg2 and msg3 reassembly attaches to the existing pending handshake found by RemoteIndex. The initiator keeps two msg2 slots keyed by the responder's FragID, each capped at 16 KiB. When a stale lighthouse address for the peer belongs to another host now, both hosts answer the same msg1, and their interleaved bursts would wipe each other out of a single slot that emptied on every FragID change; with a slot each, whichever completes first is processed, and a wrong host's answer takes the usual block-and-retry path. The responder's msg3 slot needs no such care: it checks FragID against the initiator's index it learned from msg2. ### Retransmission and amplification - Only timers cause transmission, with one reactive exception: the responder resends its cached msg2 when a copy of msg1 completes again. An initiator sends msg1 to every address it knows and through its relays, the responder reassembles each path separately, and each path's first completion is answered on that path at once. A path that only works one way, say a responder answering from the wrong interface, is then covered by the others in the same attempt, and the initiator keeps the union of the msg2 symbols that reach it. After that a path is answered again at most once per retry interval, and only once K symbols not seen before on it arrive: duplicates do nothing, and there are 256 symbols per message, so a resend costs the initiator K packets on that path and the answer is held to the 3x budget below. That is TFTP's Sorcerer's Apprentice bound, per path rather than per handshake. A path is only an address and port, so a recently answered msg1 replayed from spoofed sources draws one cached msg2 to each, at about 1.2-1.4x the bytes sent and never over 3x, for as long as the answered msg1 is remembered (the handshake timeout). Accepted (2026-10-15): classical IX already resends its stored msg2 to any source that replays msg1, with no clock at all, and a per-handshake clock is what broke a path that only works one way. - Duplicate msg1 detection compares the decoded message to the cached one **before** running Noise or certificate verification. - Before an address is validated, a responder sends at most 3x the bytes it received from it (QUIC's rule). Normal handshakes sit near 1.1-1.2x. - The initiator retransmits msg3 until any authenticated packet arrives from the responder; the responder sends a small confirmation on completion and drops tunnel traffic until msg3 verifies. ### Denial of service The responder can no longer complete statelessly on msg1, since msg1 arrives in pieces. Defenses are the capped table above plus a **cookie under load**, in the style of WireGuard's mac2, QUIC Retry and the SCTP state cookie: - Once the msg1 table is at least half full (by bytes or by entries), every msg1 fragment must carry a valid cookie. One without is answered with a cookie reply and leaves no state. Fragments that already belong to an entry need one too, so an attacker cannot hold old entries open. - A cookie is `HMAC-SHA256(secret, source address and port, relay channel, FragID)` truncated to 16 bytes. The secret rotates every 2 minutes and the previous one is still accepted, so a cookie is good for 2 to 4 minutes. - Replies are never larger than the packet that prompted them (no amplification) and are rate limited as a whole (a burst of 256, then 1000 per second), so spoofed floods cannot turn the responder into a reflector. - The initiator attaches the cookie to its PQ attempt, so every later packet carries it, and immediately resends fresh symbols to the address the reply came from, at most once per retry interval. Under load a PQ handshake costs one extra round trip, which in practice still beats the classical attempt on the next retry tick. - An attacker on the path can forge a cookie reply; the bogus cookie is simply rejected and answered with a real one. A CA-keyed pre-filter MAC was considered and rejected: networks commonly trust many CAs. #### Pending hybrid responders A hybrid responder that answered msg1 keeps a pending entry, keyed by its local index, until msg3 arrives or the handshake timeout (5.5 s by default) passes. msg1 is cleartext and nothing covers `e` or `Time`, so from one captured v3 msg1 an attacker can mint distinct valid msg1s with a fresh `e` and a bumped `Time`. Each misses the answered-msg1 cache, costs the responder the whole msg1 (two signature verifications, two encapsulations, four DH operations, the certificate checks) and a pending entry, and the fragment table stays near empty because a complete burst arrives together, so cookies are never demanded and spoofed sources work. Pending entries are not bounded per peer or in total. Accepted as is (2026-10-14): the per-msg1 work is the same class as classical IX's pre-authentication cost, the expiry bounds the state in time and the fragment table bounds bytes in flight. If it needs tightening, cap pending responders per certificate fingerprint and in total, and count them toward the cookie threshold. #### Injected symbols The decoder keeps the first K distinct symbols it sees, so one forged symbol in those K spoils that attempt's decode; Noise rejects the result and the next attempt brings fresh symbols. Forging one needs the attempt's FragID or index, which are random 32 bit values, and for msg1 the initiator's exact address and port too, so in practice only an attacker who sees the traffic can do it. An attacker who can also drop packets gains nothing new. One who can only inject, and races a forged symbol into every attempt, can stop a PQ handshake where it could not stop a classical one, since classical msg2s are tried one packet at a time. Keeping spare symbols to find a consistent subset only raises the number of forgeries needed per attempt, and authenticating symbols is impossible for msg1, which travels before any key exists. So the decoder deliberately does neither. A non-sticky initiator still gets a classical tunnel from its next attempt; a sticky one, by design, does not downgrade. ## Rollout - Separate settings for initiating and accepting PQ handshakes, both off by default: `handshakes.pq.initiate`, `handshakes.pq.accept`. `handshakes.pq.repair_delay` (default 10ms) sets how long repair fragments trail the data fragments. - `handshakes.pq.require` makes every handshake PQ: it implies `initiate` and `accept`, never offers a classical attempt, and drops classical msg1s. It needs a v3 certificate. - Old nodes silently drop unknown subtypes, so discovery is speculative: an initiator with `initiate` enabled alternates PQ and classical attempts on successive timer retries, keeping both pending, and the first to complete wins. A path nothing has been sent over yet gets the PQ attempt first: an early resend triggered by a lighthouse reply is PQ, and a relay that comes up pushes the PQ attempt through at once, counting as the PQ turn. Without that, relays set up during the first attempt handed every relayed handshake to the classical retry (seen on real hosts: 11 of 11). Once a msg2 fragment or cookie reply arrives for the PQ attempt the peer evidently speaks PQ, and classical stops getting turns. - All `handshakes.pq` settings reload without a restart. Handshakes in flight carry on; the new settings apply to the next attempt and the next packet. - `handshakes.pq.sticky` (default off): once a peer has completed a PQ handshake, never fall back to classical for it again during this process lifetime. This resists active downgrade after first contact. - No lighthouse capability advertisement. ## Simulation results Gilbert-Elliott channel (bad state drops everything), losses expressed as average rate and mean burst length in packets. First-attempt success for the whole handshake with `m = max(2, ceil(K/3))` and repair delayed 16 packet slots, compared with no FEC: | Handshake | Loss | Burst 1 | Burst 2 | Burst 4 | Burst 8 | |---|---|---|---|---|---| | two messages (2, 2) no FEC | 5% | 81.4% | 85.6% | 87.9% | 89.0% | | two messages (2, 2) FEC | 5% | 99.9% | 99.4% | 99.3% | 98.6% | | two messages (2, 2) FEC | 10% | 99.3% | 97.5% | 97.4% | 96.3% | | hybrid (7, 9, 2) no FEC | 5% | 39.8% | 57.5% | 70.4% | 77.7% | | hybrid (7, 9, 2) FEC | 5% | 99.6% | 94.2% | 90.4% | 88.8% | | hybrid (7, 9, 2) FEC | 10% | 95.9% | 84.6% | 79.0% | 77.3% | With fresh repair symbols, the hybrid handshake completes within two attempts at least 99.1% of the time in every scenario above, and within three at least 99.99%. ## Prior art - **WireGuard / Rosenpass**: fit every message in one packet; cookies and biscuits for statelessness. Not possible at our sizes. - **IKEv2**: RFC 7383 fragments only after keys exist, with each fragment authenticated; RFC 9242/9370 move PQ key exchange into intermediate exchanges for that reason. - **QUIC / DTLS 1.3**: offset-based reassembly with ACKs; QUIC's 3x amplification limit and 1200-byte floor. - **Solana Turbine**: Reed-Solomon over 1232-byte UDP packets through untrusted relays; moved to Merkle-authenticated shreds after unauthenticated repair symbols enabled poisoning. - **kcp-go, UDPspeeder, CM256**: RS FEC over UDP; parity must scale up for small groups. - **gQUIC FEC (removed)**: FEC does not pay for bulk transfer under bursty loss; it does for short, latency-critical transfers such as handshakes. - **TFTP, NFS duplicate request cache, memcached UDP, IP reassembly bugs**: retransmit storms, ID-wrap misassembly, reflection amplification, and allocation from claimed sizes. ## Implementation: fragment transport and manager - `handshake/fec`, `handshake/fragment`: the code and the fragment transport. - `handshake_manager_pq.go` and the manager: - **Initiator.** Attempts alternate PQ and classical, starting with PQ (sticky peers get only PQ). The PQ attempt has its own local index registered alongside the classical one; whichever response completes first wins, and the other index is released (`settleIndex`). A timed out or deleted pending handshake releases both. - **Responder.** msg1 fragments go into the capped pre-authentication table. A decoded msg1 is looked up by hash among recently answered ones before any Noise, KEM or certificate work: the trailing repair fragments of an attempt rebuild a msg1 its data fragments already completed, and a retrying initiator offers fresh symbols for the same msg1. A repeat goes straight to the cached-response resend, which offers fresh msg2 symbols on the path the repeat came by and is held to the 3x amplification budget. - **Relays** carry fragments unchanged; repair fragments trail via a timer. - The pre-authentication table is capped at 4 MiB, 1024 entries, 4 per source (IPv4 /32, IPv6 /64, or relay channel), 16 KiB messages and a 3 s idle timeout. Cookies are demanded from half full. - Tests: codec and fragment tests and fuzzers, manager unit tests for the response budget, table tests for the per-path repeat clock, and e2e tests for a clean handshake, loss of data fragments in both directions, fallback to a peer that does not accept PQ, a lost msg2 recovered by fresh symbols, sticky mode under an attacker dropping PQ packets, relays, a direct msg2 lost on every attempt and answered through a relay instead, index cleanup on completion, fallback and timeout, and a responder flooded from spoofed sources demanding and accepting a cookie. Unit tests cover cookie binding and rotation, the never-amplify rule, the initiator's rate limited resend, and reload. ML-KEM also passes under `GOEXPERIMENT=boringcrypto` and `GODEBUG=fips140=on`. ## Implementation: certificates and the hybrid handshake - `cert`: `cert_v3.go` (format, `V2Form`, `ExtendToV3`, `SignPQ`), `pq_suite.go` (`PQSuite` and the suite table), `SignHybridWith` and the composite `Sign` path, the CA pool's v3 index, suite check and v2-form blocklisting, and the composite key PEM banners, generated per suite. - `handshake`: the `ix_hybrid_mlkem1024` subtype on `noise.HandshakeHybridIX`, `KEMMLKEM1024`, `HybridSubtype` mapping a suite to the subtype of its KEM, composite credentials, cipher states ordered by role for three message patterns, and `Machine.PeerCert` for a responder that has to act before completion. - `pki.go` loads a v3 certificate with its composite key and derives the v2 form; messages after the handshake treat v3 tunnels as v2 (`protocolVersion`). - `handshake_manager_hybrid.go`: pending responders, confirmation and reconfirmation, the initiator's msg3 resends and teardown, and the crossing window; `outside.go` keeps quiet about pending responder indexes. - `nebula-cert`: `ca`, `keygen` and `sign` take `-version 3`, `keygen -in-key` extends an existing host key, `sign -version 2` with a v3 CA signs as its v2 form, and `pq-extend` upgrades v2 certificates in place, keeping an encrypted CA key encrypted. `ca`, `keygen` and `pq-extend` of a CA take `-pq-suite`; `sign` and `pq-extend` of a host take the CA's suite. - Tests: certificate format, strictness, tampering, pool and blocklist rules and in-place extension on both curves; Machine-level hybrid handshakes including corrupted msg3 and impersonation with only the classical key; e2e tests for a clean hybrid handshake, a lost confirmation, a never confirmed handshake (no RecvErrors, teardown, expiry), a v2-only peer, an initiator that trusts only the v2 form, `require` both ways, and relays; pki loading; firewall `ca_sha`; and the `nebula-cert` flows. ## Open items - Publish DefinedNet/noise and drop the `replace` in `go.mod`, once Nate says the fork is ready to ship. The classical-only responder `e` is on the local clone's `drop-responder-ekem-key` branch (b4a892b), not yet in DefinedNet/noise. - DefinedNet/noise `ReadMessage` returns `ErrShortMessage` without rolling back the symmetric state ([DefinedNet/noise#2](https://github.com/DefinedNet/noise/issues/2), inherited from flynn/noise). A message that fails a length check after a key-mixing token leaves `ck` and `h` advanced, so the genuine message can never be read: one spoofed short packet to a pending initiator's index, which msg1 carries in the clear in every IX variant, kills that attempt until it times out, and the Machine reports it as recoverable. Blocks publishing the module; the fix is a deferred rollback that also restores `k`, `n` and `hasK`. Smaller pre-publish items are in [DefinedNet/noise#3](https://github.com/DefinedNet/noise/issues/3). Field tested on real hosts on 2026-10-14 (`pq-lab/2026-10-14-hybrid/`, untracked): LAN median 3.2 ms against 1.4 ms classical, +2.0 ms at 50 ms round trip, no fallbacks with `require` under loss. The cryptographic review of 2026-10-14 found no break in the composition or the certificate chain. Its outcomes are folded in above: deterministic ML-DSA signing, the fingerprint guidance, the `skem` oracle note, the psk2 note (moot now that handshake is dropped), the pending-responder exposure and the `sticky` default. Accepted as is: a tunnel that came up classical between two v3 holders (say, the hybrid attempt was dropped) stays classical until it rehandshakes for another reason. `require`, `sticky` and a CA pool without v2 forms cover deployments that need more. PKCS#11 keys do not support v3.