Browse Source

Speak post-quantum handshakes over fragments, alternating with classical until one answers

JackDoan 6 days ago
parent
commit
a36ce25af4
10 changed files with 1395 additions and 54 deletions
  1. 274 0
      docs/pq-handshake.md
  2. 351 0
      e2e/pq_handshakes_test.go
  3. 15 0
      examples/config.yml
  4. 130 45
      handshake_manager.go
  5. 446 0
      handshake_manager_pq.go
  6. 155 0
      handshake_manager_pq_test.go
  7. 5 0
      hostmap.go
  8. 6 0
      main.go
  9. 7 3
      relay_manager.go
  10. 6 6
      relay_manager_test.go

+ 274 - 0
docs/pq-handshake.md

@@ -0,0 +1,274 @@
+# Post-quantum handshakes
+
+Status: draft. Decisions below were made on 2026-10-13; open items are listed at the end.
+Phase 1 is implemented (see [Implementation](#implementation-phase-1)).
+
+## 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, later.** Needs certificates whose CA signature and host
+   keys are post-quantum. This requires reissuing certificates, so it ships second.
+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 (the current `go.mod`); `crypto/mldsa` arrives in
+Go 1.27 and is only needed for phase 2.
+
+## Phase 1: ephemeral ML-KEM on today's certificates
+
+A new handshake subtype, `ix_psk2_mlkem1024` (2), IX-shaped (2 messages; the responder
+completes on msg1 as today), using the flynn/noise `psk2` modifier. The Noise protocol name
+becomes `Noise_IXpsk2_...`, which separates it from the classical subtype.
+
+- **msg1**: the initiator generates a fresh ML-KEM-1024 key pair and places the
+  encapsulation key in `NebulaHandshakeDetails.KEMKey` (field 9). msg1's payload is mixed
+  into the Noise transcript, so it cannot be stripped or replaced without msg2 failing.
+  With the psk modifier Noise also mixes `e` into the cipher key, so msg1's static key and
+  payload are encrypted, though only under a key derived from the public ephemeral.
+- **Responder**: after `ReadMessage(msg1)` it encapsulates to that key, obtaining
+  `(ct, ss)`, and calls `HandshakeState.SetPresharedKey(psk)` before writing msg2, with
+  `psk = HKDF-Extract(SHA-256, salt = "nebula ix_psk2_mlkem1024 v1", ikm = ss || ct)`.
+  The ciphertext is bound in because, unlike the encapsulation key, it is not part of the
+  Noise transcript.
+- **msg2** on the wire is `ct || noise_msg2`. The ciphertext travels outside the Noise
+  message because the initiator needs `ss` before processing msg2's `psk` token. Any change
+  to `ct` yields a different `ss` (ML-KEM implicit rejection) and the handshake fails.
+
+Transport keys depend on both the X25519 DH outputs and the ML-KEM shared secret.
+Authentication is unchanged and classical. With a minimal v2 certificate msg1 and msg2 are
+about 1.84 KB each, so each travels as 2 data and 2 repair fragments of about 936 bytes.
+A KEM key on the classical subtype, or a missing or malformed one on this subtype, is a fatal
+protocol error; a forged ciphertext only fails that packet.
+
+## Phase 2: pqIX hybrid with certificate v3
+
+Certificate v3 is hybrid: the classical static key and CA signature plus a static
+ML-KEM-1024 key and an ML-DSA-87 CA signature. Host authentication is by KEM rather than by
+signature, following PQNoise's pqIX, while keeping the classical DH tokens:
+
+- **msg1** (I to R): `e, s`, ephemeral ML-KEM key, initiator cert.
+- **msg2** (R to I): `e, s`, ciphertext to I's ephemeral KEM key, ciphertext to I's static
+  KEM key, responder cert.
+- **msg3** (I to R): ciphertext to R's static KEM key, plus first data. R authenticates I
+  when msg3 decrypts; I authenticates R implicitly when R's first reply decrypts.
+
+The initiator never caches the responder's certificate, so every handshake has three
+messages and carries both full certificates. With ML-DSA-87:
+
+| Message | Size | Data fragments (k) |
+|---|---|---|
+| msg1 | ~8.2 KB | 7 |
+| msg2 | ~9.8 KB | 9 |
+| msg3 | ~1.6 KB | 2 |
+
+flynn/noise cannot mix multiple KEM secrets into the chaining key, so phase 2 needs a
+KEM-aware Noise symmetric state written in this repository. Both subtypes coexist.
+
+Cryptographic review of the hybrid composition will be done by in-house cryptographers.
+
+## 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
+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 / 1176)`, `SymbolSize = ceil(MsgLen / K)`. Every symbol has the same
+  size, so a packet is at most 16 + 8 + 1176 = 1200 bytes.
+- `Index < K` is a data symbol (a slice of the message, the last one zero padded);
+  `Index >= K` is a repair symbol.
+- `Flags` is reserved for a cookie extension and must be zero.
+
+### 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 <= 14).
+- 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 entry is forgotten immediately. A late duplicate fragment starts a new entry
+  and, once enough arrive, reconstructs the same message again; the handshake layer's
+  duplicate detection recognises 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.
+
+### Retransmission and amplification
+
+- Only timers cause transmission. Reactive resends (the responder resending its cached
+  msg2 when a duplicate msg1 completes) are rate limited to one per handshake per retry
+  interval, to avoid TFTP's Sorcerer's Apprentice amplification.
+- 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.
+- In phase 2 the initiator retransmits msg3 until any authenticated packet arrives from
+  the responder; the responder sends a small confirmation on completion and holds 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**: when the table is under
+pressure, a fragment from an unvalidated address is answered with a stateless cookie
+(a MAC over source address, FragID and a time bucket), and the initiator resends with the
+cookie. This costs a round trip only while under attack, in the style of WireGuard's
+mac2, QUIC Retry and the SCTP state cookie. A CA-keyed pre-filter MAC was considered and
+rejected: networks commonly trust many CAs.
+
+## 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.
+- Old nodes silently drop unknown subtypes, so discovery is speculative: an initiator with
+  `initiate` enabled alternates PQ and classical attempts on successive retries, keeping
+  both pending, and the first to complete wins.
+- `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 |
+|---|---|---|---|---|---|
+| phase 1 (2, 2) no FEC | 5% | 81.4% | 85.6% | 87.9% | 89.0% |
+| phase 1 (2, 2) FEC | 5% | 99.9% | 99.4% | 99.3% | 98.6% |
+| phase 1 (2, 2) FEC | 10% | 99.3% | 97.5% | 97.4% | 96.3% |
+| phase 2 (7, 9, 2) no FEC | 5% | 39.8% | 57.5% | 70.4% | 77.7% |
+| phase 2 (7, 9, 2) FEC | 5% | 99.6% | 94.2% | 90.4% | 88.8% |
+| phase 2 (7, 9, 2) FEC | 10% | 95.9% | 84.6% | 79.0% | 77.3% |
+
+With fresh repair symbols, phase 2 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 (phase 1)
+
+- `handshake`: the `ix_psk2_mlkem1024` subtype (`patterns.go`, `kem.go`), the `KEMKey`
+  payload field, and the psk2 plumbing in `Machine`.
+- `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, is rate limited to one per retry interval 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.
+- Tests: Noise-level KEM tests, codec and fragment tests and fuzzers, manager unit tests
+  for the response budget and rate limit, 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, and index cleanup on completion, fallback and timeout. ML-KEM and HKDF also
+  pass under `GOEXPERIMENT=boringcrypto` and `GODEBUG=fips140=on`.
+
+## Open items
+
+1. Cookie wire format and the load threshold that triggers it.
+2. Whether the decoder keeps spare symbols to identify injected symbols.
+3. msg3 confirmation message format (phase 2).
+4. Configuration reload: the `handshakes.pq` settings are read at startup only, like the
+   rest of the `handshakes` section.

+ 351 - 0
e2e/pq_handshakes_test.go

@@ -0,0 +1,351 @@
+//go:build e2e_testing
+
+package e2e
+
+import (
+	"net/netip"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula"
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/cert_test"
+	"github.com/slackhq/nebula/e2e/router"
+	"github.com/slackhq/nebula/handshake/fragment"
+	"github.com/slackhq/nebula/header"
+	"github.com/slackhq/nebula/udp"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func pqConfig(initiate, accept, sticky bool) m {
+	return m{"handshakes": m{"pq": m{
+		"initiate":     initiate,
+		"accept":       accept,
+		"sticky":       sticky,
+		"repair_delay": "0s",
+	}}}
+}
+
+// handshakeOf returns the handshake header p carries, looking inside relay
+// wrapping, plus the fragment header when the handshake is fragmented.
+func handshakeOf(p *udp.Packet) (*header.H, *fragment.Header) {
+	b := p.Data
+	h := &header.H{}
+	if h.Parse(b) != nil {
+		return nil, nil
+	}
+	if h.Type == header.Message && h.Subtype == header.MessageRelay {
+		// Relays authenticate but do not encrypt the inner packet.
+		b = b[header.Len:]
+		if h.Parse(b) != nil {
+			return nil, nil
+		}
+	}
+	if h.Type != header.Handshake {
+		return nil, nil
+	}
+	if !header.IsFragmented(h.Subtype) {
+		return h, nil
+	}
+	// A relayed packet still has the relay's AEAD tag on the end.
+	for _, tag := range []int{0, 16} {
+		if len(b) < header.Len+tag {
+			break
+		}
+		if fh, _, err := fragment.Parse(b[header.Len : len(b)-tag]); err == nil {
+			return h, &fh
+		}
+	}
+	return h, nil
+}
+
+// handshakeTally counts handshake packets by style as the router moves them.
+type handshakeTally struct {
+	sync.Mutex
+	pq, classical int
+	maxPQLen      int
+}
+
+func (ht *handshakeTally) observe(p *udp.Packet) {
+	h, _ := handshakeOf(p)
+	if h == nil {
+		return
+	}
+	ht.Lock()
+	defer ht.Unlock()
+	if h.Subtype == header.HandshakeIXPSK2MLKEM1024 {
+		ht.pq++
+		ht.maxPQLen = max(ht.maxPQLen, len(p.Data))
+	} else {
+		ht.classical++
+	}
+}
+
+// isTunnelData reports whether p is ordinary tunnel traffic headed for to,
+// directly or as the last hop of a relay.
+func isTunnelData(p *udp.Packet, receiver, to *nebula.Control) bool {
+	h := &header.H{}
+	if receiver != to || h.Parse(p.Data) != nil || h.Type != header.Message {
+		return false
+	}
+	if h.Subtype == header.MessageRelay {
+		if h.Parse(p.Data[header.Len:]) != nil {
+			return false
+		}
+	}
+	return h.Type == header.Message && h.Subtype == header.MessageNone
+}
+
+func newPQPair(t *testing.T, mine, theirs m) (myControl, theirControl *nebula.Control, myVpn, theirVpn []netip.Prefix, myUdp, theirUdp netip.AddrPort, r *router.R) {
+	t.Helper()
+	ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
+	myControl, myVpn, myUdp, _ = newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.1/24", mine)
+	theirControl, theirVpn, theirUdp, _ = newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.2/24", theirs)
+	myControl.InjectLightHouseAddr(theirVpn[0].Addr(), theirUdp)
+	r = router.NewR(t, myControl, theirControl)
+	myControl.Start()
+	theirControl.Start()
+	return
+}
+
+func TestPQHandshake(t *testing.T) {
+	t.Parallel()
+	// They only accept, which is all a PQ initiator needs.
+	myControl, theirControl, myVpn, theirVpn, myUdp, theirUdp, r := newPQPair(t, pqConfig(true, true, false), pqConfig(false, true, false))
+	defer r.RenderFlow()
+
+	var tally handshakeTally
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, r.RouteForAllExitFuncOrTimeout(5*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		tally.observe(p)
+		if isTunnelData(p, receiver, theirControl) {
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	}), "tunnel never came up")
+
+	assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
+	assert.Zero(t, tally.classical, "no classical handshake should be needed")
+	assert.GreaterOrEqual(t, tally.pq, 4, "both messages travel as several fragments")
+	assert.LessOrEqual(t, tally.maxPQLen, fragment.MaxPacketLen)
+
+	assertHostInfoPair(t, myUdp, theirUdp, myVpn, theirVpn, myControl, theirControl)
+	assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+	assert.Empty(t, myControl.ListHostmapIndexes(true), "no pending index may outlive the handshake")
+	assert.Empty(t, theirControl.ListHostmapIndexes(true))
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestPQHandshakeLoss drops data fragments of both messages; the repair
+// fragments must carry the handshake without a retry.
+func TestPQHandshakeLoss(t *testing.T) {
+	t.Parallel()
+	myControl, theirControl, myVpn, theirVpn, _, _, r := newPQPair(t, pqConfig(true, true, false), pqConfig(false, true, false))
+	defer r.RenderFlow()
+
+	var tally handshakeTally
+	dropped := 0
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, r.RouteForAllExitFuncOrTimeout(5*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		if h, fh := handshakeOf(p); fh != nil {
+			// Every data fragment of msg1, and the first of msg2.
+			if (h.MessageCounter == 1 && int(fh.Index) < 2) || (h.MessageCounter == 2 && fh.Index == 0) {
+				dropped++
+				return router.Drop
+			}
+		}
+		tally.observe(p)
+		if isTunnelData(p, receiver, theirControl) {
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	}), "tunnel never came up")
+
+	assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
+	assert.Equal(t, 3, dropped)
+	assert.Zero(t, tally.classical, "the first attempt should succeed from repair fragments")
+	assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestPQFallback has a peer that does not accept PQ handshakes. The initiator's
+// next attempt is classical and the tunnel comes up anyway.
+func TestPQFallback(t *testing.T) {
+	t.Parallel()
+	myControl, theirControl, myVpn, theirVpn, myUdp, theirUdp, r := newPQPair(t, pqConfig(true, true, false), nil)
+	defer r.RenderFlow()
+
+	var tally handshakeTally
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, r.RouteForAllExitFuncOrTimeout(5*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		tally.observe(p)
+		if isTunnelData(p, receiver, theirControl) {
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	}), "tunnel never came up")
+
+	assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
+	assert.Positive(t, tally.pq, "a PQ attempt comes first")
+	assert.Positive(t, tally.classical, "then a classical one")
+
+	assertHostInfoPair(t, myUdp, theirUdp, myVpn, theirVpn, myControl, theirControl)
+	assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+	assert.Empty(t, myControl.ListHostmapIndexes(true), "the losing PQ attempt's index must be released")
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestPQResendFreshSymbols loses the responder's whole first msg2. The
+// initiator's next PQ attempt offers fresh msg1 symbols, the responder
+// recognises the repeat and resends msg2 with symbols it has not sent before.
+func TestPQResendFreshSymbols(t *testing.T) {
+	t.Parallel()
+	myControl, theirControl, myVpn, theirVpn, _, _, r := newPQPair(t, pqConfig(true, true, false), pqConfig(false, true, false))
+	defer r.RenderFlow()
+
+	var firstMsg2, freshMsg2, freshMsg1 int
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, r.RouteForAllExitFuncOrTimeout(10*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		h, fh := handshakeOf(p)
+		switch {
+		case h != nil && fh == nil:
+			// Keep the classical fallback out of the way.
+			return router.Drop
+		case fh != nil && h.MessageCounter == 2 && fh.Index < 4:
+			firstMsg2++
+			return router.Drop
+		case fh != nil && h.MessageCounter == 2:
+			freshMsg2++
+		case fh != nil && h.MessageCounter == 1 && fh.Index >= 4:
+			freshMsg1++
+		}
+		if isTunnelData(p, receiver, theirControl) {
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	}), "tunnel never came up")
+
+	assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
+	assert.Equal(t, 4, firstMsg2, "the first msg2 is 2 data and 2 repair fragments")
+	assert.Positive(t, freshMsg1, "the retry offers fresh msg1 symbols")
+	assert.Positive(t, freshMsg2, "the resend offers fresh msg2 symbols")
+	assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestPQSticky checks a sticky initiator never offers a classical handshake to a
+// peer it has already completed a PQ handshake with.
+func TestPQSticky(t *testing.T) {
+	t.Parallel()
+	myControl, theirControl, myVpn, theirVpn, _, _, r := newPQPair(t, pqConfig(true, true, true), pqConfig(false, true, false))
+	defer r.RenderFlow()
+
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, r.RouteForAllExitFuncOrTimeout(5*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		if isTunnelData(p, receiver, theirControl) {
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	}), "tunnel never came up")
+	theirControl.GetFromTun(true)
+
+	t.Log("Rehandshake while an attacker drops every PQ packet")
+	var tally handshakeTally
+	myControl.ReHandshake(theirVpn[0].Addr())
+	r.RouteForAllExitFuncOrTimeout(time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		tally.observe(p)
+		if h, _ := handshakeOf(p); h != nil && h.Subtype == header.HandshakeIXPSK2MLKEM1024 {
+			return router.Drop
+		}
+		return router.KeepRouting
+	})
+	assert.Positive(t, tally.pq, "PQ attempts keep being made")
+	assert.Zero(t, tally.classical, "sticky must never fall back")
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+func TestPQRelays(t *testing.T) {
+	t.Parallel()
+	ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
+	useRelays := pqConfig(true, true, false)
+	useRelays["relay"] = m{"use_relays": true}
+	amRelay := pqConfig(true, true, false)
+	amRelay["relay"] = m{"am_relay": true}
+	myControl, myVpnIpNet, _, _ := newSimpleServer(cert.Version1, ca, caKey, "me     ", "10.128.0.1/24", useRelays)
+	relayControl, relayVpnIpNet, relayUdpAddr, _ := newSimpleServer(cert.Version1, ca, caKey, "relay  ", "10.128.0.128/24", amRelay)
+	theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version1, ca, caKey, "them   ", "10.128.0.2/24", useRelays)
+
+	myControl.InjectLightHouseAddr(relayVpnIpNet[0].Addr(), relayUdpAddr)
+	myControl.InjectRelays(theirVpnIpNet[0].Addr(), []netip.Addr{relayVpnIpNet[0].Addr()})
+	relayControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
+
+	r := router.NewR(t, myControl, relayControl, theirControl)
+	defer r.RenderFlow()
+
+	myControl.Start()
+	relayControl.Start()
+	theirControl.Start()
+
+	var tally handshakeTally
+	var relayedPQ int
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, r.RouteForAllExitFuncOrTimeout(10*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		tally.observe(p)
+		outer := &header.H{}
+		if outer.Parse(p.Data) == nil && outer.Type == header.Message && outer.Subtype == header.MessageRelay {
+			if h, fh := handshakeOf(p); h != nil && fh != nil {
+				relayedPQ++
+				assert.LessOrEqual(t, len(p.Data), fragment.MaxPacketLen+32, "a relayed fragment fits the IPv6 minimum MTU")
+			}
+		}
+		if isTunnelData(p, receiver, theirControl) {
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	}), "tunnel never came up")
+
+	assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), 80, 80)
+	assert.Positive(t, relayedPQ, "the end to end handshake is PQ through the relay")
+	r.RenderHostmaps("Final hostmaps", myControl, relayControl, theirControl)
+
+	myControl.Stop()
+	relayControl.Stop()
+	theirControl.Stop()
+}
+
+// TestPQTimeoutReleasesIndexes lets a handshake with both a PQ and a classical
+// attempt pending time out, and checks neither index is left behind.
+func TestPQTimeoutReleasesIndexes(t *testing.T) {
+	t.Parallel()
+	mine := pqConfig(true, true, false)
+	mine["handshakes"].(m)["retries"] = 2
+	myControl, theirControl, myVpn, theirVpn, _, _, r := newPQPair(t, mine, pqConfig(false, true, false))
+	defer r.RenderFlow()
+
+	var tally handshakeTally
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	r.RouteForAllExitFuncOrTimeout(1500*time.Millisecond, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		tally.observe(p)
+		return router.Drop
+	})
+
+	assert.Positive(t, tally.pq)
+	assert.Positive(t, tally.classical)
+	assert.Nil(t, myControl.GetHostInfoByVpnAddr(theirVpn[0].Addr(), true), "the handshake should have timed out")
+	assert.Empty(t, myControl.ListHostmapIndexes(true))
+
+	myControl.Stop()
+	theirControl.Stop()
+}

+ 15 - 0
examples/config.yml

@@ -418,6 +418,21 @@ logging:
   # after receiving the response for lighthouse queries
   # after receiving the response for lighthouse queries
   #trigger_buffer: 64
   #trigger_buffer: 64
 
 
+  # pq enables handshakes that mix an ephemeral ML-KEM-1024 key exchange into the session keys, protecting
+  # recorded traffic against a future quantum computer. Authentication is unchanged. These handshakes are too
+  # large for one packet and travel as fragments with forward error correction.
+  #pq:
+    # initiate starts post-quantum handshakes. Attempts alternate between post-quantum and classical until one is
+    # answered, so peers that do not support them still connect.
+    #initiate: false
+    # accept answers post-quantum handshakes from peers.
+    #accept: false
+    # sticky never falls back to a classical handshake with a peer that has completed a post-quantum one since
+    # nebula started, which defeats an attacker who drops post-quantum handshakes to force a downgrade.
+    #sticky: false
+    # repair_delay is how long the forward error correction fragments trail the rest of a handshake message.
+    #repair_delay: 10ms
+
 # Tunnel manager settings
 # Tunnel manager settings
 #tunnels:
 #tunnels:
   # drop_inactive controls whether inactive tunnels are maintained or dropped after the inactive_timeout period has
   # drop_inactive controls whether inactive tunnels are maintained or dropped after the inactive_timeout period has

+ 130 - 45
handshake_manager.go

@@ -15,6 +15,7 @@ import (
 	"github.com/rcrowley/go-metrics"
 	"github.com/rcrowley/go-metrics"
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/handshake"
 	"github.com/slackhq/nebula/handshake"
+	"github.com/slackhq/nebula/handshake/fragment"
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/udp"
 	"github.com/slackhq/nebula/udp"
 )
 )
@@ -49,6 +50,7 @@ type HandshakeConfig struct {
 	tryInterval   time.Duration
 	tryInterval   time.Duration
 	retries       int64
 	retries       int64
 	triggerBuffer int
 	triggerBuffer int
+	pq            pqHandshakeConfig
 
 
 	messageMetrics *MessageMetrics
 	messageMetrics *MessageMetrics
 }
 }
@@ -74,6 +76,13 @@ type HandshakeManager struct {
 
 
 	// can be used to trigger outbound handshake for the given vpnIp
 	// can be used to trigger outbound handshake for the given vpnIp
 	trigger chan netip.Addr
 	trigger chan netip.Addr
+
+	// pqTable reassembles post-quantum msg1s from senders that have not authenticated yet.
+	pqTable *fragment.Table[pqFragKey, pqFragSource]
+	// pqPeers holds vpn addresses that completed a post-quantum handshake, for sticky mode.
+	pqPeers sync.Map
+	// pqAnswered recognises repeats of post-quantum msg1s already answered.
+	pqAnswered *pqAnswered
 }
 }
 
 
 type HandshakeHostInfo struct {
 type HandshakeHostInfo struct {
@@ -89,6 +98,7 @@ type HandshakeHostInfo struct {
 
 
 	hostinfo *HostInfo
 	hostinfo *HostInfo
 	machine  *handshake.Machine // The handshake state machine, set during stage 0 (initiator) or beginHandshake (responder multi-message)
 	machine  *handshake.Machine // The handshake state machine, set during stage 0 (initiator) or beginHandshake (responder multi-message)
+	pq       *pqAttempt         // The initiator's post-quantum attempt, pending alongside the classical one
 }
 }
 
 
 func (hh *HandshakeHostInfo) cachePacket(l *slog.Logger, t header.MessageType, st header.MessageSubType, packet []byte, f packetCallback, m *cachedPacketMetrics) {
 func (hh *HandshakeHostInfo) cachePacket(l *slog.Logger, t header.MessageType, st header.MessageSubType, packet []byte, f packetCallback, m *cachedPacketMetrics) {
@@ -130,6 +140,8 @@ func NewHandshakeManager(l *slog.Logger, mainHostMap *HostMap, lightHouse *Light
 		metricInitiated:        metrics.GetOrRegisterCounter("handshake_manager.initiated", nil),
 		metricInitiated:        metrics.GetOrRegisterCounter("handshake_manager.initiated", nil),
 		metricTimedOut:         metrics.GetOrRegisterCounter("handshake_manager.timed_out", nil),
 		metricTimedOut:         metrics.GetOrRegisterCounter("handshake_manager.timed_out", nil),
 		l:                      l,
 		l:                      l,
+		pqTable:                fragment.NewTable[pqFragKey, pqFragSource](pqTableConfig),
+		pqAnswered:             newPQAnswered(hsTimeout(config.retries, config.tryInterval)),
 	}
 	}
 }
 }
 
 
@@ -145,6 +157,8 @@ func (hm *HandshakeManager) Run(ctx context.Context) {
 			hm.handleOutbound(vpnIP, true)
 			hm.handleOutbound(vpnIP, true)
 		case now := <-clockSource.C:
 		case now := <-clockSource.C:
 			hm.NextOutboundHandshakeTimerTick(now)
 			hm.NextOutboundHandshakeTimerTick(now)
+			hm.pqTable.Expire(now)
+			hm.pqAnswered.expire(now)
 		}
 		}
 	}
 	}
 }
 }
@@ -154,7 +168,7 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head
 	// don't yet support) are dropped here rather than silently routed through
 	// don't yet support) are dropped here rather than silently routed through
 	// the IX path. Add a case when introducing a new pattern.
 	// the IX path. Add a case when introducing a new pattern.
 	switch h.Subtype {
 	switch h.Subtype {
-	case header.HandshakeIXPSK0:
+	case header.HandshakeIXPSK0, pqSubtype:
 		// supported
 		// supported
 	default:
 	default:
 		hm.l.Debug("dropping handshake with unsupported subtype",
 		hm.l.Debug("dropping handshake with unsupported subtype",
@@ -170,6 +184,11 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head
 		}
 		}
 	}
 	}
 
 
+	if header.IsFragmented(h.Subtype) {
+		hm.handleFragment(via, packet, h)
+		return
+	}
+
 	// First message of a new handshake. The wire format requires RemoteIndex
 	// First message of a new handshake. The wire format requires RemoteIndex
 	// to be zero here (the initiator has no responder index to fill in yet),
 	// to be zero here (the initiator has no responder index to fill in yet),
 	// and generateIndex never allocates 0, so any non-zero RemoteIndex on a
 	// and generateIndex never allocates 0, so any non-zero RemoteIndex on a
@@ -181,7 +200,7 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head
 				"from", via, "remoteIndex", h.RemoteIndex)
 				"from", via, "remoteIndex", h.RemoteIndex)
 			return
 			return
 		}
 		}
-		hm.beginHandshake(via, packet, h)
+		hm.beginHandshake(via, packet, h, 0)
 		return
 		return
 	}
 	}
 
 
@@ -230,6 +249,9 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
 				"style": header.SubTypeName(header.Handshake, hh.machine.Subtype()),
 				"style": header.SubTypeName(header.Handshake, hh.machine.Subtype()),
 			})
 			})
 		}
 		}
+		if hh.pq != nil {
+			fields = append(fields, "pqInitiatorIndex", hh.pq.index)
+		}
 		hh.hostinfo.logger(hm.l).Info("Handshake timed out", fields...)
 		hh.hostinfo.logger(hm.l).Info("Handshake timed out", fields...)
 		hm.metricTimedOut.Inc(1)
 		hm.metricTimedOut.Inc(1)
 		hm.DeleteHostInfo(hostinfo)
 		hm.DeleteHostInfo(hostinfo)
@@ -239,25 +261,44 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
 	// Increment the counter to increase our delay, linear backoff
 	// Increment the counter to increase our delay, linear backoff
 	hh.counter++
 	hh.counter++
 
 
-	// Check if we have a handshake packet to transmit yet
-	if !hh.ready {
-		if !hm.buildStage0Packet(hh) {
+	// Pick this attempt's style and make sure its first message exists. A PQ
+	// attempt sends msg1 as fragments: now goes out immediately, later after the
+	// repair delay, and every attempt offers fresh repair symbols.
+	var now, later [][]byte
+	var machine *handshake.Machine
+	if hm.usePQ(hh, vpnIp) {
+		if hh.pq == nil && !hm.buildPQAttempt(hh) {
 			hm.OutboundHandshakeTimer.Add(vpnIp, hm.config.tryInterval*time.Duration(hh.counter))
 			hm.OutboundHandshakeTimer.Add(vpnIp, hm.config.tryInterval*time.Duration(hh.counter))
 			return
 			return
 		}
 		}
+		var err error
+		now, later, err = hh.pq.sender.Attempt(fragment.DefaultRepair(hh.pq.sender.Layout().K))
+		if err != nil {
+			hostinfo.logger(hm.l).Error("Failed to fragment handshake", "error", err)
+			hm.OutboundHandshakeTimer.Add(vpnIp, hm.config.tryInterval*time.Duration(hh.counter))
+			return
+		}
+		machine = hh.pq.machine
+	} else {
+		if !hh.ready {
+			if !hm.buildStage0Packet(hh) {
+				hm.OutboundHandshakeTimer.Add(vpnIp, hm.config.tryInterval*time.Duration(hh.counter))
+				return
+			}
+		}
+		// TODO: this hardcodes "always retransmit stage 0", which is correct for
+		// IX (the initiator only ever sends one packet, msg1) but wrong the
+		// moment a 3+ message pattern lands. The retry loop should resend the
+		// most recent outgoing message, not always stage 0. That implies
+		// HandshakeHostInfo tracking a single "currentOutbound" packet (bytes +
+		// header metadata) that gets replaced as the handshake progresses,
+		// instead of indexing into HandshakePacket.
+		now = [][]byte{hostinfo.HandshakePacket[handshakePacketStage0]}
+		machine = hh.machine
 	}
 	}
-
-	// TODO: this hardcodes "always retransmit stage 0", which is correct for
-	// IX (the initiator only ever sends one packet, msg1) but wrong the
-	// moment a 3+ message pattern lands. The retry loop should resend the
-	// most recent outgoing message, not always stage 0. That implies
-	// HandshakeHostInfo tracking a single "currentOutbound" packet (bytes +
-	// header metadata) that gets replaced as the handshake progresses,
-	// instead of indexing into HandshakePacket.
-	stage0 := hostinfo.HandshakePacket[handshakePacketStage0]
 	hsFields := m{
 	hsFields := m{
-		"stage": uint64(hh.machine.MessageIndex()),
-		"style": header.SubTypeName(header.Handshake, hh.machine.Subtype()),
+		"stage": uint64(machine.MessageIndex()),
+		"style": header.SubTypeName(header.Handshake, machine.Subtype()),
 	}
 	}
 
 
 	remotes := hostinfo.remotes.CopyAddrs(hm.mainHostMap.GetPreferredRanges())
 	remotes := hostinfo.remotes.CopyAddrs(hm.mainHostMap.GetPreferredRanges())
@@ -285,8 +326,10 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
 	// Send the handshake to all known ips, stage 2 takes care of assigning the hostinfo.remote based on the first to reply
 	// Send the handshake to all known ips, stage 2 takes care of assigning the hostinfo.remote based on the first to reply
 	var sentTo []netip.AddrPort
 	var sentTo []netip.AddrPort
 	hostinfo.remotes.ForEach(hm.mainHostMap.GetPreferredRanges(), func(addr netip.AddrPort, _ bool) {
 	hostinfo.remotes.ForEach(hm.mainHostMap.GetPreferredRanges(), func(addr netip.AddrPort, _ bool) {
-		hm.messageMetrics.Tx(header.Handshake, hh.machine.Subtype(), 1)
-		err := hm.outside.WriteTo(stage0, addr)
+		hm.messageMetrics.Tx(header.Handshake, machine.Subtype(), int64(len(now)+len(later)))
+		err := hm.sendFragmented(now, later, func(p []byte) error {
+			return hm.outside.WriteTo(p, addr)
+		})
 		if err != nil {
 		if err != nil {
 			// These repeat every attempt, so match the success log below and only shout when the remotes changed
 			// These repeat every attempt, so match the success log below and only shout when the remotes changed
 			level := slog.LevelDebug
 			level := slog.LevelDebug
@@ -322,7 +365,7 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
 		)
 		)
 	}
 	}
 
 
-	hm.f.relayManager.StartRelays(hm.f, vpnIp, hh, stage0)
+	hm.f.relayManager.StartRelays(hm.f, vpnIp, hh, now, later)
 
 
 	// If a lighthouse triggered this attempt then we are still in the timer wheel and do not need to re-add
 	// If a lighthouse triggered this attempt then we are still in the timer wheel and do not need to re-add
 	if !lighthouseTriggered {
 	if !lighthouseTriggered {
@@ -532,6 +575,10 @@ func (hm *HandshakeManager) DeleteHostInfo(hostinfo *HostInfo) {
 func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) {
 func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) {
 	for _, addr := range hostinfo.vpnAddrs {
 	for _, addr := range hostinfo.vpnAddrs {
 		if cur, ok := hm.vpnIps[addr]; ok && cur.hostinfo == hostinfo {
 		if cur, ok := hm.vpnIps[addr]; ok && cur.hostinfo == hostinfo {
+			// A PQ attempt holds its own index alongside the hostinfo's.
+			if idx := cur.pqIndex(); idx != 0 && hm.indexes[idx] == cur {
+				delete(hm.indexes, idx)
+			}
 			delete(hm.vpnIps, addr)
 			delete(hm.vpnIps, addr)
 		}
 		}
 	}
 	}
@@ -642,9 +689,8 @@ func hsTimeout(tries int64, interval time.Duration) time.Duration {
 	return time.Duration(tries / 2 * ((2 * int64(interval)) + (tries-1)*int64(interval)))
 	return time.Duration(tries / 2 * ((2 * int64(interval)) + (tries-1)*int64(interval)))
 }
 }
 
 
-// buildStage0Packet creates the initial handshake packet for the initiator.
-func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
-	cs := hm.f.pki.getCertState()
+// initiatingVersion picks the certificate version to initiate a handshake with.
+func (hm *HandshakeManager) initiatingVersion(hh *HandshakeHostInfo, cs *CertState) cert.Version {
 	v := cs.DefaultVersion()
 	v := cs.DefaultVersion()
 	if hh.initiatingVersionOverride != cert.VersionPre1 {
 	if hh.initiatingVersionOverride != cert.VersionPre1 {
 		v = hh.initiatingVersionOverride
 		v = hh.initiatingVersionOverride
@@ -656,6 +702,13 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
 			}
 			}
 		}
 		}
 	}
 	}
+	return v
+}
+
+// buildStage0Packet creates the initial handshake packet for the initiator.
+func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
+	cs := hm.f.pki.getCertState()
+	v := hm.initiatingVersion(hh, cs)
 
 
 	cred := cs.GetCredential(v)
 	cred := cs.GetCredential(v)
 	if cred == nil {
 	if cred == nil {
@@ -694,7 +747,10 @@ func (hm *HandshakeManager) buildStage0Packet(hh *HandshakeHostInfo) bool {
 // beginHandshake handles an incoming handshake packet that doesn't match any
 // beginHandshake handles an incoming handshake packet that doesn't match any
 // existing pending handshake. It creates a new responder Machine and processes
 // existing pending handshake. It creates a new responder Machine and processes
 // the first message.
 // the first message.
-func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *header.H) {
+//
+// received is the number of bytes a fragmented msg1 arrived in, which bounds the
+// response sent back before the address is validated; 0 for unfragmented ones.
+func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *header.H, received int) {
 	f := hm.f
 	f := hm.f
 	cs := f.pki.getCertState()
 	cs := f.pki.getCertState()
 
 
@@ -708,7 +764,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 	machine, err := handshake.NewMachine(
 	machine, err := handshake.NewMachine(
 		v, cs.GetCredential,
 		v, cs.GetCredential,
 		hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) },
 		hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) },
-		false, header.HandshakeIXPSK0,
+		false, h.Subtype,
 	)
 	)
 	if err != nil {
 	if err != nil {
 		f.l.Error("Failed to create handshake machine", "from", via, "error", err)
 		f.l.Error("Failed to create handshake machine", "from", via, "error", err)
@@ -798,11 +854,15 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 
 
 	existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
 	existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
 	if err != nil {
 	if err != nil {
-		hm.handleCheckAndCompleteError(err, existing, hostinfo, via)
+		hm.handleCheckAndCompleteError(err, existing, hostinfo, via, machine.Subtype(), received)
 		return
 		return
 	}
 	}
 
 
-	hm.sendHandshakeResponse(via, response, hostinfo, false)
+	hm.sendHandshakeResponse(via, response, hostinfo, false, received)
+	if header.IsFragmented(machine.Subtype()) {
+		hm.pqAnswered.add(packet[header.Len:], hostinfo, time.Now())
+	}
+	hm.notePQPeer(machine.Subtype(), vpnAddrs)
 	hostinfo.remotes.RefreshFromHandshake(vpnAddrs)
 	hostinfo.remotes.RefreshFromHandshake(vpnAddrs)
 
 
 	// Don't wait for UpdateWorker
 	// Don't wait for UpdateWorker
@@ -813,23 +873,32 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 
 
 // continueHandshake feeds an incoming packet to an existing pending handshake Machine.
 // continueHandshake feeds an incoming packet to an existing pending handshake Machine.
 func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostInfo, packet []byte) {
 func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostInfo, packet []byte) {
-	f := hm.f
-
 	hh.Lock()
 	hh.Lock()
 	defer hh.Unlock()
 	defer hh.Unlock()
 
 
-	// Re-verify hh is still tracked. Between queryIndex returning and us taking
-	// hh.Lock, handleOutbound may have timed out and deleted it. Once we hold
-	// hh.Lock no other deleter can race our index: handleOutbound also takes
-	// hh.Lock first, and handleRecvError targets a main-hostmap entry with a
-	// different localIndexId.
-	hm.RLock()
-	cur, ok := hm.indexes[hh.hostinfo.localIndexId]
-	hm.RUnlock()
-	if !ok || cur != hh {
+	if !hm.stillPending(hh, hh.hostinfo.localIndexId) {
 		return
 		return
 	}
 	}
+	hm.processResponse(via, hh, hh.machine, packet)
+}
 
 
+// stillPending re-verifies hh is still tracked under index. Between queryIndex
+// returning and the caller taking hh.Lock, handleOutbound may have timed out and
+// deleted it. Once hh.Lock is held no other deleter can race the index:
+// handleOutbound also takes hh.Lock first, and handleRecvError targets a
+// main-hostmap entry with a different localIndexId. The caller must hold hh.Lock.
+func (hm *HandshakeManager) stillPending(hh *HandshakeHostInfo, index uint32) bool {
+	hm.RLock()
+	defer hm.RUnlock()
+	cur, ok := hm.indexes[index]
+	return ok && cur == hh
+}
+
+// processResponse runs a responder's message through one of the initiator's
+// pending attempts and completes the tunnel when the handshake finishes. The
+// caller must hold hh.Lock.
+func (hm *HandshakeManager) processResponse(via ViaSender, hh *HandshakeHostInfo, machine *handshake.Machine, packet []byte) {
+	f := hm.f
 	hostinfo := hh.hostinfo
 	hostinfo := hh.hostinfo
 	if !via.IsRelayed {
 	if !via.IsRelayed {
 		if !f.lightHouse.GetRemoteAllowList().AllowAll(hostinfo.vpnAddrs, via.UdpAddr.Addr()) {
 		if !f.lightHouse.GetRemoteAllowList().AllowAll(hostinfo.vpnAddrs, via.UdpAddr.Addr()) {
@@ -839,7 +908,6 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 		}
 		}
 	}
 	}
 
 
-	machine := hh.machine
 	if machine == nil {
 	if machine == nil {
 		f.l.Error("No handshake machine available for continuation",
 		f.l.Error("No handshake machine available for continuation",
 			"vpnAddrs", hostinfo.vpnAddrs, "from", via)
 			"vpnAddrs", hostinfo.vpnAddrs, "from", via)
@@ -862,7 +930,7 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 	}
 	}
 
 
 	if response != nil {
 	if response != nil {
-		hm.sendHandshakeResponse(via, response, hostinfo, false)
+		hm.sendHandshakeResponse(via, response, hostinfo, false, 0)
 	}
 	}
 
 
 	if result == nil {
 	if result == nil {
@@ -975,7 +1043,10 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 	hostinfo.vpnAddrs = vpnAddrs
 	hostinfo.vpnAddrs = vpnAddrs
 	hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
 	hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
 
 
+	// The classical and PQ attempts each hold an index; keep the one that won.
+	hm.settleIndex(hh, result.LocalIndex)
 	hm.Complete(hostinfo, f)
 	hm.Complete(hostinfo, f)
+	hm.notePQPeer(machine.Subtype(), vpnAddrs)
 
 
 	if len(hh.packetStore) > 0 {
 	if len(hh.packetStore) > 0 {
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
@@ -1054,20 +1125,23 @@ func (hm *HandshakeManager) validatePeerCert(via ViaSender, remoteCert *cert.Cac
 // cached is true when msg is a stored response being retransmitted because
 // cached is true when msg is a stored response being retransmitted because
 // the peer's stage-1 retransmit landed (the ErrAlreadySeen path); false on a
 // the peer's stage-1 retransmit landed (the ErrAlreadySeen path); false on a
 // fresh response.
 // fresh response.
-func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hostinfo *HostInfo, cached bool) {
+//
+// received is the number of bytes a fragmented msg1 arrived in; it bounds what a
+// fragmented response may send back. It is ignored for unfragmented subtypes.
+func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hostinfo *HostInfo, cached bool, received int) {
 	if msg == nil {
 	if msg == nil {
 		return
 		return
 	}
 	}
 
 
 	f := hm.f
 	f := hm.f
-	f.messageMetrics.Tx(header.Handshake, header.MessageSubType(msg[1]), 1)
+	subtype := header.MessageSubType(msg[1])
 
 
 	// Common log fields. peerCert may be nil during intermediate
 	// Common log fields. peerCert may be nil during intermediate
 	// multi-message flows (handshake hasn't completed yet); skip the cert
 	// multi-message flows (handshake hasn't completed yet); skip the cert
 	// block if so.
 	// block if so.
 	logFields := []any{
 	logFields := []any{
 		"vpnAddrs", hostinfo.vpnAddrs,
 		"vpnAddrs", hostinfo.vpnAddrs,
-		"handshake", m{"stage": uint64(2), "style": header.SubTypeName(header.Handshake, header.HandshakeIXPSK0)},
+		"handshake", m{"stage": uint64(2), "style": header.SubTypeName(header.Handshake, subtype)},
 		"cached", cached,
 		"cached", cached,
 		"initiatorIndex", hostinfo.remoteIndexId,
 		"initiatorIndex", hostinfo.remoteIndexId,
 		"responderIndex", hostinfo.localIndexId,
 		"responderIndex", hostinfo.localIndexId,
@@ -1081,6 +1155,17 @@ func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hos
 		)
 		)
 	}
 	}
 
 
+	if header.IsFragmented(subtype) {
+		fields := append(logFields, "from", via)
+		if err := hm.sendFragmentedResponse(via, msg, hostinfo, cached, received); err != nil {
+			f.l.Debug("Did not send handshake message", append(fields, "error", err)...)
+		} else {
+			f.l.Info("Handshake message sent", fields...)
+		}
+		return
+	}
+
+	f.messageMetrics.Tx(header.Handshake, subtype, 1)
 	if !via.IsRelayed {
 	if !via.IsRelayed {
 		fields := append(logFields, "from", via)
 		fields := append(logFields, "from", via)
 		err := f.outside.WriteTo(msg, via.UdpAddr)
 		err := f.outside.WriteTo(msg, via.UdpAddr)
@@ -1107,10 +1192,10 @@ func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hos
 // This only fires from the responder-side beginHandshake path, after the
 // This only fires from the responder-side beginHandshake path, after the
 // peer cert has been validated and ConnectionState populated, so peerCert
 // peer cert has been validated and ConnectionState populated, so peerCert
 // is always non-nil for the cases that log it.
 // is always non-nil for the cases that log it.
-func (hm *HandshakeManager) handleCheckAndCompleteError(err error, existing, hostinfo *HostInfo, via ViaSender) {
+func (hm *HandshakeManager) handleCheckAndCompleteError(err error, existing, hostinfo *HostInfo, via ViaSender, subtype header.MessageSubType, received int) {
 	f := hm.f
 	f := hm.f
 	peerCert := hostinfo.ConnectionState.peerCert
 	peerCert := hostinfo.ConnectionState.peerCert
-	hsFields := m{"stage": uint64(1), "style": header.SubTypeName(header.Handshake, header.HandshakeIXPSK0)}
+	hsFields := m{"stage": uint64(1), "style": header.SubTypeName(header.Handshake, subtype)}
 
 
 	switch err {
 	switch err {
 	case ErrAlreadySeen:
 	case ErrAlreadySeen:
@@ -1121,7 +1206,7 @@ func (hm *HandshakeManager) handleCheckAndCompleteError(err error, existing, hos
 		// ephemeral keys; a freshly-built one would have different keys and break
 		// ephemeral keys; a freshly-built one would have different keys and break
 		// the tunnel even though both sides "completed" the handshake.
 		// the tunnel even though both sides "completed" the handshake.
 		if msg := existing.HandshakePacket[handshakePacketStage2]; msg != nil {
 		if msg := existing.HandshakePacket[handshakePacketStage2]; msg != nil {
-			hm.sendHandshakeResponse(via, msg, existing, true)
+			hm.sendHandshakeResponse(via, msg, existing, true, received)
 		}
 		}
 
 
 	case ErrExistingHostInfo:
 	case ErrExistingHostInfo:

+ 446 - 0
handshake_manager_pq.go

@@ -0,0 +1,446 @@
+package nebula
+
+import (
+	"context"
+	"crypto/sha256"
+	"errors"
+	"log/slog"
+	"net/netip"
+	"sync"
+	"time"
+
+	"github.com/slackhq/nebula/handshake"
+	"github.com/slackhq/nebula/handshake/fragment"
+	"github.com/slackhq/nebula/header"
+)
+
+const (
+	// pqSubtype is the post-quantum handshake this node speaks.
+	pqSubtype = header.HandshakeIXPSK2MLKEM1024
+
+	// DefaultPQRepairDelay is how long repair fragments trail the data fragments.
+	DefaultPQRepairDelay = 10 * time.Millisecond
+
+	// pqAmplification bounds what a responder sends in reply to an unvalidated
+	// address, as a multiple of what it received from it.
+	pqAmplification = 3
+)
+
+// pqTableConfig bounds the responder's reassembly of msg1 fragments from senders
+// that have not authenticated yet.
+var pqTableConfig = fragment.TableConfig{
+	MaxBytes:     4 << 20,
+	MaxEntries:   1024,
+	MaxPerSource: 4,
+	MaxMsgLen:    16384,
+	TTL:          3 * time.Second,
+}
+
+type pqHandshakeConfig struct {
+	initiate    bool          // start PQ handshakes
+	accept      bool          // answer PQ handshakes
+	sticky      bool          // never fall back to classical for a peer that completed PQ
+	repairDelay time.Duration // delay between data and repair fragments
+}
+
+// pqAttempt is the initiator's post-quantum attempt for a pending handshake. It
+// lives alongside the classical attempt in HandshakeHostInfo and has its own
+// local index, so whichever answer arrives first wins.
+type pqAttempt struct {
+	machine *handshake.Machine
+	index   uint32
+	sender  *fragment.Sender
+	msg2    *fragment.Assembly
+}
+
+// pqFragKey identifies one msg1 being reassembled.
+type pqFragKey struct {
+	addr   netip.AddrPort
+	relay  uint32
+	fragID uint32
+}
+
+// pqFragSource groups msg1 reassemblies for the per-source quota: an IPv4 /32,
+// an IPv6 /64, or one relay channel.
+type pqFragSource struct {
+	prefix netip.Prefix
+	relay  uint32
+}
+
+func pqFragKeyFor(via ViaSender, fragID uint32) (pqFragKey, pqFragSource) {
+	if via.IsRelayed {
+		return pqFragKey{relay: via.relay.LocalIndex, fragID: fragID},
+			pqFragSource{relay: via.relay.LocalIndex}
+	}
+	addr := via.UdpAddr.Addr().Unmap()
+	bits := 64
+	if addr.Is4() {
+		bits = 32
+	}
+	prefix, _ := addr.Prefix(bits)
+	return pqFragKey{addr: via.UdpAddr, fragID: fragID}, pqFragSource{prefix: prefix}
+}
+
+// reassembled rebuilds the packet the handshake Machine expects from the header
+// of a fragment and the decoded message.
+func reassembled(h *header.H, msg []byte) []byte {
+	p := make([]byte, header.Len, header.Len+len(msg))
+	header.Encode(p, header.Version, header.Handshake, h.Subtype, h.RemoteIndex, h.MessageCounter)
+	return append(p, msg...)
+}
+
+// usePQ decides whether this outbound attempt is post-quantum. Attempts
+// alternate, starting with PQ, because a peer that does not speak PQ silently
+// drops it. A peer that already completed a PQ handshake never gets a classical
+// attempt when sticky is on.
+func (hm *HandshakeManager) usePQ(hh *HandshakeHostInfo, vpnIp netip.Addr) bool {
+	if !hm.config.pq.initiate {
+		return false
+	}
+	if hm.config.pq.sticky {
+		if _, ok := hm.pqPeers.Load(vpnIp); ok {
+			return true
+		}
+	}
+	return hh.counter%2 == 1
+}
+
+// buildPQAttempt creates the initiator's PQ Machine and msg1 sender.
+func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool {
+	cs := hm.f.pki.getCertState()
+	v := hm.initiatingVersion(hh, cs)
+	if cs.GetCredential(v) == nil {
+		hm.f.l.Error("Unable to handshake with host because no certificate is available",
+			"vpnAddrs", hh.hostinfo.vpnAddrs, "certVersion", v)
+		return false
+	}
+
+	pq := &pqAttempt{}
+	machine, err := handshake.NewMachine(
+		v, cs.GetCredential,
+		hm.certVerifier(), func() (uint32, error) { return hm.allocatePQIndex(hh, pq) },
+		true, pqSubtype,
+	)
+	if err != nil {
+		hm.f.l.Error("Failed to create handshake machine",
+			"vpnAddrs", hh.hostinfo.vpnAddrs, "error", err)
+		return false
+	}
+
+	msg, err := machine.Initiate(nil)
+	if err != nil {
+		hm.f.l.Error("Failed to initiate handshake",
+			"vpnAddrs", hh.hostinfo.vpnAddrs, "error", err)
+		hm.releasePQIndex(hh, pq)
+		return false
+	}
+
+	sender, err := fragment.NewSender(msg[header.Len:], pqSubtype, 0, 1, pq.index)
+	if err != nil {
+		hm.f.l.Error("Failed to fragment handshake",
+			"vpnAddrs", hh.hostinfo.vpnAddrs, "error", err)
+		hm.releasePQIndex(hh, pq)
+		return false
+	}
+
+	pq.machine = machine
+	pq.sender = sender
+	hh.pq = pq
+	return true
+}
+
+// allocatePQIndex registers a local index for the PQ attempt. Unlike
+// allocateIndex it leaves hostinfo.localIndexId alone; that is settled when one
+// of the attempts completes.
+func (hm *HandshakeManager) allocatePQIndex(hh *HandshakeHostInfo, pq *pqAttempt) (uint32, error) {
+	hm.mainHostMap.RLock()
+	defer hm.mainHostMap.RUnlock()
+	hm.Lock()
+	defer hm.Unlock()
+
+	for range 32 {
+		index, err := generateIndex(hm.l)
+		if err != nil {
+			return 0, err
+		}
+
+		_, inPending := hm.indexes[index]
+		_, inMain := hm.mainHostMap.Indexes[index]
+
+		if !inMain && !inPending {
+			pq.index = index
+			hm.indexes[index] = hh
+			return index, nil
+		}
+	}
+
+	return 0, errors.New("failed to generate unique localIndexId")
+}
+
+func (hm *HandshakeManager) releasePQIndex(hh *HandshakeHostInfo, pq *pqAttempt) {
+	hm.Lock()
+	defer hm.Unlock()
+	if pq.index != 0 && hm.indexes[pq.index] == hh {
+		delete(hm.indexes, pq.index)
+	}
+}
+
+// settleIndex makes winner the hostinfo's local index and releases the index of
+// the attempt that lost.
+func (hm *HandshakeManager) settleIndex(hh *HandshakeHostInfo, winner uint32) {
+	hm.Lock()
+	defer hm.Unlock()
+	for _, idx := range []uint32{hh.hostinfo.localIndexId, hh.pqIndex()} {
+		if idx != 0 && idx != winner && hm.indexes[idx] == hh {
+			delete(hm.indexes, idx)
+		}
+	}
+	hh.hostinfo.localIndexId = winner
+}
+
+func (hh *HandshakeHostInfo) pqIndex() uint32 {
+	if hh.pq == nil {
+		return 0
+	}
+	return hh.pq.index
+}
+
+// handleFragment routes one fragment of a PQ handshake message.
+func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *header.H) {
+	fh, sym, err := fragment.Parse(packet[header.Len:])
+	if err != nil {
+		hm.l.Debug("dropping malformed handshake fragment", "from", via, "error", err)
+		return
+	}
+
+	switch h.MessageCounter {
+	case 1:
+		if !hm.config.pq.accept {
+			hm.l.Debug("dropping post-quantum handshake, not accepting them", "from", via)
+			return
+		}
+		if h.RemoteIndex != 0 {
+			hm.l.Debug("dropping stage-1 handshake with non-zero RemoteIndex",
+				"from", via, "remoteIndex", h.RemoteIndex)
+			return
+		}
+		key, src := pqFragKeyFor(via, fh.FragID)
+		res, err := hm.pqTable.Add(key, src, time.Now(), fh, sym)
+		if err != nil {
+			hm.l.Debug("dropping handshake fragment", "from", via, "error", err)
+			return
+		}
+		if res == nil {
+			return
+		}
+		if existing := hm.pqAnswered.lookup(res.Msg, time.Now()); existing != nil {
+			// Already answered: resend, rate limited, with fresh symbols.
+			if msg := existing.HandshakePacket[handshakePacketStage2]; msg != nil {
+				hm.sendHandshakeResponse(via, msg, existing, true, res.Received)
+			}
+			return
+		}
+		hm.beginHandshake(via, reassembled(h, res.Msg), h, res.Received)
+
+	case 2:
+		if hh := hm.queryIndex(h.RemoteIndex); hh != nil {
+			hm.continuePQ(via, hh, h, fh, sym)
+		}
+	}
+}
+
+// continuePQ collects msg2 fragments for the initiator's PQ attempt.
+func (hm *HandshakeManager) continuePQ(via ViaSender, hh *HandshakeHostInfo, h *header.H, fh fragment.Header, sym []byte) {
+	hh.Lock()
+	defer hh.Unlock()
+
+	pq := hh.pq
+	if pq == nil || pq.index != h.RemoteIndex || !hm.stillPending(hh, pq.index) {
+		return
+	}
+
+	if pq.msg2 == nil {
+		a, err := fragment.NewAssembly(fh)
+		if err != nil {
+			return
+		}
+		pq.msg2 = a
+	}
+	ready, err := pq.msg2.Add(fh, sym)
+	if errors.Is(err, fragment.ErrMismatch) {
+		// The responder's FragID and MsgLen never change, so this is a stray or a
+		// forgery. Start over from this fragment rather than wedge.
+		pq.msg2 = nil
+		return
+	}
+	if err != nil || !ready {
+		return
+	}
+
+	msg, err := pq.msg2.Decode()
+	pq.msg2 = nil
+	if err != nil {
+		hm.l.Debug("failed to decode handshake fragments", "from", via, "error", err)
+		return
+	}
+	hm.processResponse(via, hh, pq.machine, reassembled(h, msg))
+}
+
+// sendFragmented sends packets to a peer: now immediately and later after the
+// repair delay. It returns the first error from the immediate sends; errors from
+// delayed sends are only logged.
+func (hm *HandshakeManager) sendFragmented(now, later [][]byte, send func([]byte) error) error {
+	var first error
+	for _, p := range now {
+		if err := send(p); err != nil && first == nil {
+			first = err
+		}
+	}
+	sendLater := func() {
+		for _, p := range later {
+			if err := send(p); err != nil {
+				hm.l.Debug("Failed to send handshake repair fragment", "error", err)
+			}
+		}
+	}
+	switch {
+	case len(later) == 0:
+	case hm.config.pq.repairDelay <= 0:
+		sendLater()
+	default:
+		time.AfterFunc(hm.config.pq.repairDelay, sendLater)
+	}
+	return first
+}
+
+// sendFragmentedResponse sends the responder's msg2 as fragments. A resend of a
+// cached response (cached) offers fresh symbols and is rate limited to one per
+// retry interval. received is how many bytes the triggering msg1 took, which
+// bounds what may be sent back before the address is validated.
+func (hm *HandshakeManager) sendFragmentedResponse(via ViaSender, msg []byte, hostinfo *HostInfo, cached bool, received int) error {
+	now := time.Now()
+	if cached {
+		last := hostinfo.handshakeResendAt.Load()
+		if now.UnixNano()-last < int64(hm.config.tryInterval) ||
+			!hostinfo.handshakeResendAt.CompareAndSwap(last, now.UnixNano()) {
+			return errors.New("handshake response resend rate limited")
+		}
+	} else {
+		hostinfo.handshakeResendAt.Store(now.UnixNano())
+	}
+
+	var h header.H
+	if err := h.Parse(msg); err != nil {
+		return err
+	}
+	s, err := fragment.NewSender(msg[header.Len:], h.Subtype, h.RemoteIndex, h.MessageCounter, hostinfo.localIndexId)
+	if err != nil {
+		return err
+	}
+
+	l := s.Layout()
+	repair := fragment.DefaultRepair(l.K)
+	if received > 0 {
+		pktLen := header.Len + fragment.HeaderLen + l.SymbolSize
+		budget := pqAmplification * received / pktLen
+		if budget < l.K {
+			return errors.New("handshake response exceeds the amplification budget")
+		}
+		repair = min(repair, budget-l.K)
+	}
+
+	n := uint32(l.K + repair)
+	s.SetNext(int(hostinfo.handshakeNextSymbol.Add(n) - n))
+	pkts, later, err := s.Attempt(repair)
+	if err != nil {
+		return err
+	}
+
+	f := hm.f
+	var send func([]byte) error
+	if !via.IsRelayed {
+		send = func(p []byte) error {
+			return f.outside.WriteTo(p, via.UdpAddr)
+		}
+	} else {
+		if via.relay == nil {
+			return errors.New("both addr and via.relay are nil")
+		}
+		hostinfo.relayState.InsertRelayTo(via.relayHI.vpnAddrs[0])
+		via.relayHI.relayState.UpdateRelayForByIdxState(via.relay.LocalIndex, Established)
+		send = func(p []byte) error {
+			f.SendVia(via.relayHI, via.relay, p, make([]byte, 12), make([]byte, mtu), false, 0)
+			return nil
+		}
+	}
+	f.messageMetrics.Tx(header.Handshake, h.Subtype, int64(len(pkts)+len(later)))
+	return hm.sendFragmented(pkts, later, send)
+}
+
+// notePQPeer remembers peers that completed a PQ handshake, for sticky mode.
+func (hm *HandshakeManager) notePQPeer(subtype header.MessageSubType, vpnAddrs []netip.Addr) {
+	if !hm.config.pq.sticky || subtype != pqSubtype {
+		return
+	}
+	for _, a := range vpnAddrs {
+		hm.pqPeers.Store(a, struct{}{})
+	}
+	if hm.l.Enabled(context.Background(), slog.LevelDebug) {
+		hm.l.Debug("Remembering post-quantum peer", "vpnAddrs", vpnAddrs)
+	}
+}
+
+// pqAnswered remembers the msg1s a responder recently answered, keyed by hash,
+// so a repeat is recognised before any noise, KEM or certificate work. Repeats
+// are routine: the trailing repair fragments of an attempt rebuild a msg1 that
+// its data fragments already completed, and a retrying initiator offers fresh
+// symbols for the same msg1. Entries only exist for completed handshakes, which
+// need a valid certificate, and live as long as an initiator keeps retrying.
+type pqAnswered struct {
+	sync.Mutex
+	ttl     time.Duration
+	entries map[[sha256.Size]byte]pqAnsweredEntry
+}
+
+type pqAnsweredEntry struct {
+	hostinfo *HostInfo
+	at       time.Time
+}
+
+const pqAnsweredMax = 4096
+
+func newPQAnswered(ttl time.Duration) *pqAnswered {
+	return &pqAnswered{ttl: ttl, entries: map[[sha256.Size]byte]pqAnsweredEntry{}}
+}
+
+func (a *pqAnswered) add(msg1 []byte, hostinfo *HostInfo, now time.Time) {
+	a.Lock()
+	defer a.Unlock()
+	if len(a.entries) >= pqAnsweredMax {
+		for k := range a.entries {
+			delete(a.entries, k)
+			break
+		}
+	}
+	a.entries[sha256.Sum256(msg1)] = pqAnsweredEntry{hostinfo: hostinfo, at: now}
+}
+
+func (a *pqAnswered) lookup(msg1 []byte, now time.Time) *HostInfo {
+	a.Lock()
+	defer a.Unlock()
+	e, ok := a.entries[sha256.Sum256(msg1)]
+	if !ok || now.Sub(e.at) > a.ttl {
+		return nil
+	}
+	return e.hostinfo
+}
+
+func (a *pqAnswered) expire(now time.Time) {
+	a.Lock()
+	defer a.Unlock()
+	for k, e := range a.entries {
+		if now.Sub(e.at) > a.ttl {
+			delete(a.entries, k)
+		}
+	}
+}

+ 155 - 0
handshake_manager_pq_test.go

@@ -0,0 +1,155 @@
+package nebula
+
+import (
+	"math/rand/v2"
+	"net/netip"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/handshake/fragment"
+	"github.com/slackhq/nebula/header"
+	"github.com/slackhq/nebula/test"
+	"github.com/slackhq/nebula/udp"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// recordingConn captures what is written to it.
+type recordingConn struct {
+	udp.NoopConn
+	sync.Mutex
+	sent [][]byte
+}
+
+func (c *recordingConn) WriteTo(b []byte, _ netip.AddrPort) error {
+	c.Lock()
+	defer c.Unlock()
+	c.sent = append(c.sent, append([]byte(nil), b...))
+	return nil
+}
+
+func (c *recordingConn) take() [][]byte {
+	c.Lock()
+	defer c.Unlock()
+	s := c.sent
+	c.sent = nil
+	return s
+}
+
+func newPQTestManager(t *testing.T) (*HandshakeManager, *recordingConn) {
+	l := test.NewLogger()
+	conn := &recordingConn{}
+	cfg := defaultHandshakeConfig
+	cfg.pq = pqHandshakeConfig{initiate: true, accept: true}
+	hm := NewHandshakeManager(l, newHostMap(l), newTestLighthouse(), conn, cfg)
+	hm.f = &Interface{
+		handshakeManager: hm,
+		outside:          conn,
+		messageMetrics:   newMessageMetricsOnlyRecvError(),
+		l:                l,
+	}
+	return hm, conn
+}
+
+// fragmentIndexes checks every packet is a fragment of the expected response and
+// returns their symbol indexes.
+func fragmentIndexes(t *testing.T, pkts [][]byte, fragID, remoteIndex uint32) []int {
+	t.Helper()
+	var out []int
+	for _, p := range pkts {
+		require.LessOrEqual(t, len(p), fragment.MaxPacketLen)
+		var h header.H
+		require.NoError(t, h.Parse(p))
+		assert.Equal(t, pqSubtype, h.Subtype)
+		assert.Equal(t, remoteIndex, h.RemoteIndex)
+		assert.Equal(t, uint64(2), h.MessageCounter)
+		fh, _, err := fragment.Parse(p[header.Len:])
+		require.NoError(t, err)
+		assert.Equal(t, fragID, fh.FragID)
+		out = append(out, int(fh.Index))
+	}
+	return out
+}
+
+func TestSendFragmentedResponse(t *testing.T) {
+	hm, conn := newPQTestManager(t)
+	via := ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.1:4242")}
+	hostinfo := &HostInfo{localIndexId: 77}
+
+	// A 2000 byte message is 2 symbols of 1000, so packets of 1024 bytes.
+	msg := make([]byte, header.Len, header.Len+2000)
+	header.Encode(msg, header.Version, header.Handshake, pqSubtype, 5, 2)
+	r := rand.New(rand.NewPCG(1, 1))
+	for range 2000 {
+		msg = append(msg, byte(r.Uint32()))
+	}
+	const pktLen = header.Len + fragment.HeaderLen + 1000
+
+	t.Run("first send", func(t *testing.T) {
+		require.NoError(t, hm.sendFragmentedResponse(via, msg, hostinfo, false, 0))
+		assert.Equal(t, []int{0, 1, 2, 3}, fragmentIndexes(t, conn.take(), 77, 5))
+	})
+
+	t.Run("resends are rate limited", func(t *testing.T) {
+		require.Error(t, hm.sendFragmentedResponse(via, msg, hostinfo, true, 10*pktLen))
+		assert.Empty(t, conn.take())
+	})
+
+	allowResend := func() {
+		hostinfo.handshakeResendAt.Store(time.Now().Add(-time.Second).UnixNano())
+	}
+
+	t.Run("resend offers fresh symbols", func(t *testing.T) {
+		allowResend()
+		require.NoError(t, hm.sendFragmentedResponse(via, msg, hostinfo, true, 10*pktLen))
+		assert.Equal(t, []int{4, 5, 6, 7}, fragmentIndexes(t, conn.take(), 77, 5))
+	})
+
+	t.Run("amplification budget trims repair", func(t *testing.T) {
+		allowResend()
+		// 3x of a little over one packet allows 3 packets: both data and 1 repair.
+		require.NoError(t, hm.sendFragmentedResponse(via, msg, hostinfo, true, pktLen+10))
+		assert.Equal(t, []int{8, 9, 10}, fragmentIndexes(t, conn.take(), 77, 5))
+	})
+
+	t.Run("amplification budget below the data refuses", func(t *testing.T) {
+		allowResend()
+		require.Error(t, hm.sendFragmentedResponse(via, msg, hostinfo, true, pktLen/2))
+		assert.Empty(t, conn.take())
+	})
+}
+
+func TestPQAnswered(t *testing.T) {
+	now := time.Unix(1000, 0)
+	a := newPQAnswered(5 * time.Second)
+	hi := &HostInfo{}
+
+	assert.Nil(t, a.lookup([]byte("msg1"), now))
+	a.add([]byte("msg1"), hi, now)
+	assert.Same(t, hi, a.lookup([]byte("msg1"), now.Add(time.Second)))
+	assert.Nil(t, a.lookup([]byte("other"), now))
+	assert.Nil(t, a.lookup([]byte("msg1"), now.Add(6*time.Second)), "stale entries are not answered")
+
+	a.expire(now.Add(6 * time.Second))
+	assert.Empty(t, a.entries)
+
+	for i := range pqAnsweredMax + 10 {
+		a.add([]byte{byte(i), byte(i >> 8)}, hi, now)
+	}
+	assert.Len(t, a.entries, pqAnsweredMax)
+}
+
+func TestPQFragKeyFor(t *testing.T) {
+	k1, s1 := pqFragKeyFor(ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.1:4242")}, 9)
+	k2, s2 := pqFragKeyFor(ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.1:4243")}, 9)
+	assert.NotEqual(t, k1, k2, "different ports are different messages")
+	assert.Equal(t, s1, s2, "but share an IPv4 /32 quota")
+
+	_, s3 := pqFragKeyFor(ViaSender{UdpAddr: netip.MustParseAddrPort("[2001:db8::1]:1")}, 9)
+	_, s4 := pqFragKeyFor(ViaSender{UdpAddr: netip.MustParseAddrPort("[2001:db8::ffff]:1")}, 9)
+	assert.Equal(t, s3, s4, "an IPv6 /64 shares a quota")
+
+	_, s5 := pqFragKeyFor(ViaSender{UdpAddr: netip.MustParseAddrPort("[::ffff:192.0.2.1]:1")}, 9)
+	assert.Equal(t, s1, s5, "v4-mapped addresses count as IPv4")
+}

+ 5 - 0
hostmap.go

@@ -260,6 +260,11 @@ type HostInfo struct {
 	// We need these to avoid replayed handshake packets creating new hostinfos which causes churn
 	// We need these to avoid replayed handshake packets creating new hostinfos which causes churn
 	HandshakePacket map[uint8][]byte
 	HandshakePacket map[uint8][]byte
 
 
+	// A responder resending a fragmented handshake response offers symbols it has
+	// not sent before, at most once per retry interval.
+	handshakeResendAt   atomic.Int64  // unix nanos of the last send
+	handshakeNextSymbol atomic.Uint32 // next fragment symbol index to offer
+
 	// nextLHQuery is the earliest we can ask the lighthouse for new information.
 	// nextLHQuery is the earliest we can ask the lighthouse for new information.
 	// This is used to limit lighthouse re-queries in chatty clients
 	// This is used to limit lighthouse re-queries in chatty clients
 	nextLHQuery atomic.Int64
 	nextLHQuery atomic.Int64

+ 6 - 0
main.go

@@ -232,6 +232,12 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 		retries:        int64(c.GetInt("handshakes.retries", DefaultHandshakeRetries)),
 		retries:        int64(c.GetInt("handshakes.retries", DefaultHandshakeRetries)),
 		triggerBuffer:  c.GetInt("handshakes.trigger_buffer", DefaultHandshakeTriggerBuffer),
 		triggerBuffer:  c.GetInt("handshakes.trigger_buffer", DefaultHandshakeTriggerBuffer),
 		messageMetrics: messageMetrics,
 		messageMetrics: messageMetrics,
+		pq: pqHandshakeConfig{
+			initiate:    c.GetBool("handshakes.pq.initiate", false),
+			accept:      c.GetBool("handshakes.pq.accept", false),
+			sticky:      c.GetBool("handshakes.pq.sticky", false),
+			repairDelay: c.GetDuration("handshakes.pq.repair_delay", DefaultPQRepairDelay),
+		},
 	}
 	}
 
 
 	handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)
 	handshakeManager := NewHandshakeManager(l, hostMap, lightHouse, udpConns[0], handshakeConfig)

+ 7 - 3
relay_manager.go

@@ -57,8 +57,9 @@ func (rm *relayManager) GetUseRelays() bool {
 // StartRelays drives the relay-establishment side of an outbound handshake attempt.
 // StartRelays drives the relay-establishment side of an outbound handshake attempt.
 // For each candidate relay it either kicks off a handshake to the relay, sends a CreateRelayRequest, retransmits
 // For each candidate relay it either kicks off a handshake to the relay, sends a CreateRelayRequest, retransmits
 // one that may have been lost, or, once the relay is Established, forwards the in-progress
 // one that may have been lost, or, once the relay is Established, forwards the in-progress
-// stage 0 handshake packet for vpnIp through it.
-func (rm *relayManager) StartRelays(f *Interface, vpnIp netip.Addr, hh *HandshakeHostInfo, stage0 []byte) {
+// stage 0 handshake packets for vpnIp through it: now immediately and later after the
+// handshake repair delay.
+func (rm *relayManager) StartRelays(f *Interface, vpnIp netip.Addr, hh *HandshakeHostInfo, now, later [][]byte) {
 	hostinfo := hh.hostinfo
 	hostinfo := hh.hostinfo
 	if !rm.GetUseRelays() || len(hostinfo.remotes.relays) == 0 {
 	if !rm.GetUseRelays() || len(hostinfo.remotes.relays) == 0 {
 		hh.lastRelays = nil
 		hh.lastRelays = nil
@@ -161,7 +162,10 @@ func (rm *relayManager) StartRelays(f *Interface, vpnIp netip.Addr, hh *Handshak
 		switch existingRelay.State {
 		switch existingRelay.State {
 		case Established:
 		case Established:
 			hl.Log(context.Background(), level, "Send handshake via relay", "relay", relay.String())
 			hl.Log(context.Background(), level, "Send handshake via relay", "relay", relay.String())
-			f.SendVia(relayHostInfo, existingRelay, stage0, make([]byte, 12), make([]byte, mtu), false, 0)
+			_ = f.handshakeManager.sendFragmented(now, later, func(p []byte) error {
+				f.SendVia(relayHostInfo, existingRelay, p, make([]byte, 12), make([]byte, mtu), false, 0)
+				return nil
+			})
 		case Disestablished:
 		case Disestablished:
 			// Mark this relay as 'requested'
 			// Mark this relay as 'requested'
 			relayHostInfo.relayState.UpdateRelayForByIpState(vpnIp, Requested)
 			relayHostInfo.relayState.UpdateRelayForByIpState(vpnIp, Requested)

+ 6 - 6
relay_manager_test.go

@@ -50,7 +50,7 @@ func TestStartRelaysLogDedupe(t *testing.T) {
 		var buf bytes.Buffer
 		var buf bytes.Buffer
 		rm := newRM(&buf)
 		rm := newRM(&buf)
 		hh := newHH()
 		hh := newHH()
-		rm.StartRelays(f, vpnIp, hh, nil)
+		rm.StartRelays(f, vpnIp, hh, nil, nil)
 		assert.Equal(t, []netip.Addr{vpnIp}, hh.lastRelays, "lastRelays should record the relay set we just attempted")
 		assert.Equal(t, []netip.Addr{vpnIp}, hh.lastRelays, "lastRelays should record the relay set we just attempted")
 		assert.Contains(t, buf.String(), "level=INFO "+msg, "expected Info level on first attempt")
 		assert.Contains(t, buf.String(), "level=INFO "+msg, "expected Info level on first attempt")
 	})
 	})
@@ -59,10 +59,10 @@ func TestStartRelaysLogDedupe(t *testing.T) {
 		var buf bytes.Buffer
 		var buf bytes.Buffer
 		rm := newRM(&buf)
 		rm := newRM(&buf)
 		hh := newHH()
 		hh := newHH()
-		rm.StartRelays(f, vpnIp, hh, nil)
+		rm.StartRelays(f, vpnIp, hh, nil, nil)
 		first := append([]netip.Addr(nil), hh.lastRelays...)
 		first := append([]netip.Addr(nil), hh.lastRelays...)
 		buf.Reset()
 		buf.Reset()
-		rm.StartRelays(f, vpnIp, hh, nil)
+		rm.StartRelays(f, vpnIp, hh, nil, nil)
 		assert.Equal(t, first, hh.lastRelays)
 		assert.Equal(t, first, hh.lastRelays)
 		assert.Contains(t, buf.String(), "level=DEBUG "+msg, "expected Debug level on identical retry")
 		assert.Contains(t, buf.String(), "level=DEBUG "+msg, "expected Debug level on identical retry")
 		assert.NotContains(t, buf.String(), "level=INFO "+msg, "Info should not fire on identical retry")
 		assert.NotContains(t, buf.String(), "level=INFO "+msg, "Info should not fire on identical retry")
@@ -72,13 +72,13 @@ func TestStartRelaysLogDedupe(t *testing.T) {
 		var buf bytes.Buffer
 		var buf bytes.Buffer
 		rm := newRM(&buf)
 		rm := newRM(&buf)
 		hh := newHH()
 		hh := newHH()
-		rm.StartRelays(f, vpnIp, hh, nil)
+		rm.StartRelays(f, vpnIp, hh, nil, nil)
 		buf.Reset()
 		buf.Reset()
 
 
 		// The lighthouse handed us a new set this round.
 		// The lighthouse handed us a new set this round.
 		hh.hostinfo.remotes.relays = []netip.Addr{vpnIp, otherRelay}
 		hh.hostinfo.remotes.relays = []netip.Addr{vpnIp, otherRelay}
 
 
-		rm.StartRelays(f, vpnIp, hh, nil)
+		rm.StartRelays(f, vpnIp, hh, nil, nil)
 		assert.Equal(t, []netip.Addr{vpnIp, otherRelay}, hh.lastRelays)
 		assert.Equal(t, []netip.Addr{vpnIp, otherRelay}, hh.lastRelays)
 		assert.Contains(t, buf.String(), "level=INFO "+msg, "expected Info when the relay list changes")
 		assert.Contains(t, buf.String(), "level=INFO "+msg, "expected Info when the relay list changes")
 	})
 	})
@@ -90,7 +90,7 @@ func TestStartRelaysLogDedupe(t *testing.T) {
 		hh := newHH()
 		hh := newHH()
 		hh.lastRelays = []netip.Addr{vpnIp}
 		hh.lastRelays = []netip.Addr{vpnIp}
 
 
-		rm.StartRelays(f, vpnIp, hh, nil)
+		rm.StartRelays(f, vpnIp, hh, nil, nil)
 		assert.Nil(t, hh.lastRelays, "with relays disabled lastRelays should be cleared")
 		assert.Nil(t, hh.lastRelays, "with relays disabled lastRelays should be cleared")
 		assert.NotContains(t, buf.String(), msg, "should not log when we shortcut out")
 		assert.NotContains(t, buf.String(), msg, "should not log when we shortcut out")
 	})
 	})