Просмотр исходного кода

Add handshakes.pq.require, and give new paths and responsive peers the PQ attempt

JackDoan 6 дней назад
Родитель
Сommit
13658b74a0

+ 131 - 0
docs/pq-handshake-vs-quic.md

@@ -0,0 +1,131 @@
+# Post-quantum handshake transport compared with QUIC
+
+Status: analysis, 2026-10-14. Compares the fragment transport in
+[pq-handshake.md](pq-handshake.md) with how QUIC makes its handshake reliable. TLS is out of
+scope; only the transport mechanics are compared.
+
+## Summary
+
+QUIC's handshake reliability is closed-loop: the receiver acknowledges, and the sender
+repairs exactly what is missing. Ours is open-loop: repair symbols are sent up front, and a
+timer sends fresh symbols. At handshake sizes (K <= 15) a QUIC-style acknowledgement
+shrinks to a 16-bit bitmap, and with an MDS code to a single count. So the real comparison
+is forward error correction versus acknowledgements, not QUIC versus us.
+
+FEC is the right core. One piece of feedback is worth adding, initiator to responder and for
+msg2 only (see [Worth taking from QUIC](#worth-taking-from-quic)).
+
+## What QUIC does
+
+- **Messages as a stream.** Handshake bytes travel as `CRYPTO` frames (offset and length)
+  and are reassembled in order. Initial and Handshake packets each have their own packet
+  number space.
+- **Immediate acknowledgements.** Receivers must acknowledge Initial and Handshake packets
+  without delay. ACK frames list ranges of received packet numbers.
+- **Loss detection** (RFC 9002). A packet is lost once 3 later packets are acknowledged, or
+  after 9/8 of the round-trip time. Lost bytes are resent in new packets.
+- **Probe timeout (PTO).** If a whole flight vanishes, a probe timer fires. Before any RTT
+  sample it starts at about 1 s (333 ms initial RTT plus 4x its variance) and doubles each
+  time. Both endpoints run it.
+- **Amplification limit and anti-deadlock.** The server may send at most 3x what it
+  received until the client's address is validated. The client must keep probing so a
+  server stuck at that limit cannot deadlock.
+- **Retry.** A stateless round trip to validate the address. `NEW_TOKEN` lets later
+  connections skip it.
+- **Congestion control from the first packet.** The initial window is 10 packets of 1200
+  bytes.
+
+## Side by side
+
+| | QUIC | pq-handshake |
+|---|---|---|
+| Receiver feedback | ACK ranges, sent immediately | None; the next handshake message is the only acknowledgement |
+| Partial loss | +1 to 2 RTT (ACK, detect, resend) | 0 RTT while at most m symbols are lost |
+| Total loss | PTO: about 1 s, then doubling | Next PQ tick: 300 ms, then 1 s (alternating), or 100/300/600 ms (sticky) |
+| Spurious retransmit | Wasted bytes | Still useful: fresh repair indexes add to what the receiver holds |
+| Reassembly | Every byte offset, in order | Any K of up to 256 symbols |
+| Who retransmits | Both sides on their own timers | Initiator on a timer; the responder resends msg2 only when a repeat msg1 arrives (rate limited) |
+| State from the first packet | Server buffers and acknowledges partial flights | Capped pre-authentication table that never acknowledges |
+| Address validation | Retry, 1 RTT | Cookie under load, 1 RTT |
+
+From the [simulation results](pq-handshake.md#simulation-results): phase 2 at 5% random
+loss with no FEC completes on the first attempt 39.8% of the time. Under
+acknowledgement-based recovery roughly 60% of handshakes would pay at least one extra round
+trip. With FEC, 0.4% wait for a retry. Spending bandwidth to save round trips is the stated
+design preference, and open-loop recovery delivers it.
+
+## Where the designs already agree
+
+- **1200-byte floor and the 3x amplification budget**, both taken directly from QUIC.
+- **Cookie and Retry.** Both cost one round trip and both can be forged by an on-path
+  attacker. QUIC's Retry integrity tag uses a fixed, published key.
+- **On-path injection.** QUIC's Initial keys are derived from the client's connection ID,
+  which an observer can read, so QUIC accepts the same handshake-blocking injection
+  weakness documented for our symbols in
+  [Injected symbols](pq-handshake.md#injected-symbols).
+- **Phase 2 msg3 confirmation.** Resending msg3 until any authenticated packet arrives,
+  with a `Test` from the responder on completion, is QUIC's handshake confirmation and
+  `HANDSHAKE_DONE`.
+
+## Worth taking from QUIC
+
+### 1. A msg2 progress probe from the initiator
+
+This is the main gap. Today, recovering a partially lost msg2 means the initiator resends a
+full msg1 attempt; the responder decodes it, matches it in the answered-msg1 cache, and
+resends msg2. In phase 2 that is about 12 fragments (~13 KB) uploaded just to act as an
+acknowledgement, and recovery waits for the 300 ms or 1 s tick.
+
+- The initiator already knows the responder's index: msg2 fragments carry it as `FragID`
+  (`sendFragmentedResponse` passes `hostinfo.localIndexId`).
+- A one-packet probe, "msg2: have n of K", sent to that index would recover in about one
+  round trip. The responder finds it the way phase 2 plans to find msg3.
+- Timing comes for free: msg1 send to first msg2 fragment is an RTT sample. Probe at
+  roughly RTT/8 plus the repair delay after the first msg2 fragment if decoding has not
+  completed.
+- Forging a probe needs the 32-bit responder index, so only an on-path attacker can, and
+  the existing 3x budget bounds the reply.
+- If no msg2 fragment arrived at all, the initiator keeps today's msg1 resend. That is the
+  same tail-loss case QUIC's PTO covers.
+
+The reverse direction, the responder reporting progress on msg1, should not be added. It
+needs per-entry timers on the pre-authentication path, which is exactly the state QUIC
+servers carry and the reason QUIC needs Retry.
+
+### 2. Buffer early data in phase 2
+
+QUIC stacks hold a few packets they cannot decrypt yet. The phase 2 plan drops data that
+reaches the responder before msg3 decodes. Because repair fragments trail by
+`handshakes.pq.repair_delay` (10 ms), losing one msg3 data fragment drops about 10 ms of
+the initiator's first data while the repair is in flight. A small queue on the pending
+responder entry, replayed once msg3 verifies, avoids this.
+
+### 3. Burst size across remotes
+
+QUIC handshakes on a single path. `handleOutbound` sends the same `now` and `later` sets to
+every known remote and to relays (`StartRelays`).
+
+- Phase 1 is fine.
+- In phase 2, four remotes means about 48 packets (~54 KB) leaving one uplink at once,
+  every PQ tick, about 4x QUIC's initial window.
+- The Gilbert-Elliott simulation treats each path independently, so it does not capture
+  this self-inflicted burst on a constrained uplink such as cellular.
+- Options: send the full FEC set only to the most likely remote and a reduced set to the
+  others, or interleave packets across remotes.
+
+## Not worth taking
+
+- **Packet numbers, ACK ranges, an RTT estimator, loss thresholds, congestion control.**
+  QUIC pays for this machinery because it also serves a data transport. Nebula carries
+  packets and leaves reliability to the overlay's own transports. Building it only for
+  handshakes of at most 15 fragments buys nothing a symbol count cannot.
+- **Offset-based reassembly.** It gives up the any-K-of-256 property and makes spurious
+  retransmits wasteful again.
+- **An RTT-adaptive retry timer.** Low value. The fixed 100 ms linear schedule already fires
+  sooner than QUIC's 1 s first PTO, and a spurious resend costs bandwidth, not correctness.
+
+## Conclusion
+
+Keep FEC as the core and add the msg2 progress probe. That gets QUIC's roughly one round
+trip tail recovery without pre-authentication state, and cuts phase 2's retry cost on the
+uplink by about 10x. Items 2 and 3 are phase 2 concerns; item 1 also helps phase 1.

+ 10 - 2
docs/pq-handshake.md

@@ -255,9 +255,17 @@ does not downgrade.
 - 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.
 - 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.
+  `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

+ 74 - 1
e2e/pq_handshakes_test.go

@@ -19,6 +19,10 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func pqRequire() m {
+	return m{"handshakes": m{"pq": m{"require": true, "repair_delay": "0s"}}}
+}
+
 func pqConfig(initiate, accept, sticky bool) m {
 	return m{"handshakes": m{"pq": m{
 		"initiate":     initiate,
@@ -299,7 +303,7 @@ func TestPQRelays(t *testing.T) {
 	theirControl.Start()
 
 	var tally handshakeTally
-	var relayedPQ int
+	var relayedPQ, relayedClassical 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)
@@ -308,6 +312,8 @@ func TestPQRelays(t *testing.T) {
 			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")
+			} else if h != nil {
+				relayedClassical++
 			}
 		}
 		if isTunnelData(p, receiver, theirControl) {
@@ -318,6 +324,7 @@ func TestPQRelays(t *testing.T) {
 
 	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")
+	assert.Zero(t, relayedClassical, "the relay coming up carries the PQ attempt, not the classical retry")
 	r.RenderHostmaps("Final hostmaps", myControl, relayControl, theirControl)
 
 	myControl.Stop()
@@ -401,3 +408,69 @@ func TestPQCookieUnderLoad(t *testing.T) {
 	myControl.Stop()
 	theirControl.Stop()
 }
+
+// TestPQRequire checks require mode never handshakes classically, in either
+// direction.
+func TestPQRequire(t *testing.T) {
+	t.Parallel()
+
+	t.Run("initiator", func(t *testing.T) {
+		// They neither speak PQ nor require it; we never fall back.
+		myControl, theirControl, myVpn, theirVpn, _, _, r := newPQPair(t, pqRequire(), nil)
+		defer r.RenderFlow()
+
+		var tally handshakeTally
+		myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+		r.RouteForAllExitFuncOrTimeout(time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+			tally.observe(p)
+			return router.KeepRouting
+		})
+		assert.Positive(t, tally.pq)
+		assert.Zero(t, tally.classical)
+		assert.Nil(t, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false), "no tunnel without PQ")
+
+		myControl.Stop()
+		theirControl.Stop()
+	})
+
+	t.Run("responder", func(t *testing.T) {
+		// We only speak classical; they refuse it.
+		myControl, theirControl, myVpn, theirVpn, _, _, r := newPQPair(t, nil, pqRequire())
+		defer r.RenderFlow()
+
+		var answers int
+		myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+		r.RouteForAllExitFuncOrTimeout(time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+			if h, _ := handshakeOf(p); h != nil && h.MessageCounter == 2 {
+				answers++
+			}
+			return router.KeepRouting
+		})
+		assert.Zero(t, answers, "a classical msg1 gets no answer")
+		assert.Nil(t, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false))
+
+		myControl.Stop()
+		theirControl.Stop()
+	})
+
+	t.Run("both", func(t *testing.T) {
+		myControl, theirControl, myVpn, theirVpn, _, _, r := newPQPair(t, pqRequire(), pqRequire())
+		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)
+		assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+
+		myControl.Stop()
+		theirControl.Stop()
+	})
+}

+ 3 - 0
examples/config.yml

@@ -427,6 +427,9 @@ logging:
     #initiate: false
     # accept answers post-quantum handshakes from peers.
     #accept: false
+    # require makes every handshake post-quantum: it implies initiate and accept, never offers a classical handshake
+    # and refuses classical handshakes from peers. Only enable it once every host can speak post-quantum handshakes.
+    #require: 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

+ 7 - 1
handshake_manager.go

@@ -106,6 +106,7 @@ type HandshakeHostInfo struct {
 	hostinfo *HostInfo
 	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
+	lastPQ   bool               // Whether the latest attempt was post-quantum
 }
 
 func (hh *HandshakeHostInfo) cachePacket(l *slog.Logger, t header.MessageType, st header.MessageSubType, packet []byte, f packetCallback, m *cachedPacketMetrics) {
@@ -214,6 +215,10 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head
 				"from", via, "remoteIndex", h.RemoteIndex)
 			return
 		}
+		if hm.pqConfig().require {
+			hm.l.Debug("dropping classical handshake, post-quantum handshakes are required", "from", via)
+			return
+		}
 		hm.beginHandshake(via, packet, h, 0)
 		return
 	}
@@ -280,7 +285,8 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
 	// repair delay, and every attempt offers fresh repair symbols.
 	var now, later [][]byte
 	var machine *handshake.Machine
-	if hm.usePQ(hh, vpnIp) {
+	hh.lastPQ = hm.usePQ(hh, vpnIp, lighthouseTriggered)
+	if hh.lastPQ {
 		if hh.pq == nil && !hm.buildPQAttempt(hh) {
 			hm.OutboundHandshakeTimer.Add(vpnIp, hm.config.tryInterval*time.Duration(hh.counter))
 			return

+ 69 - 10
handshake_manager_pq.go

@@ -43,17 +43,24 @@ var pqTableConfig = fragment.TableConfig{
 type pqHandshakeConfig struct {
 	initiate    bool          // start PQ handshakes
 	accept      bool          // answer PQ handshakes
+	require     bool          // only PQ handshakes, in either direction
 	sticky      bool          // never fall back to classical for a peer that completed PQ
 	repairDelay time.Duration // delay between data and repair fragments
 }
 
 func pqHandshakeConfigFrom(c *config.C) pqHandshakeConfig {
-	return pqHandshakeConfig{
+	cfg := pqHandshakeConfig{
 		initiate:    c.GetBool("handshakes.pq.initiate", false),
 		accept:      c.GetBool("handshakes.pq.accept", false),
+		require:     c.GetBool("handshakes.pq.require", false),
 		sticky:      c.GetBool("handshakes.pq.sticky", false),
 		repairDelay: c.GetDuration("handshakes.pq.repair_delay", DefaultPQRepairDelay),
 	}
+	if cfg.require {
+		cfg.initiate = true
+		cfg.accept = true
+	}
+	return cfg
 }
 
 // pqConfig returns the post-quantum settings in effect.
@@ -73,6 +80,7 @@ func (hm *HandshakeManager) reloadPQ(c *config.C) {
 	hm.l.Info("Post-quantum handshake settings changed",
 		"initiate", cfg.initiate, "oldInitiate", old.initiate,
 		"accept", cfg.accept, "oldAccept", old.accept,
+		"require", cfg.require, "oldRequire", old.require,
 		"sticky", cfg.sticky, "oldSticky", old.sticky,
 		"repairDelay", cfg.repairDelay, "oldRepairDelay", old.repairDelay,
 	)
@@ -87,6 +95,7 @@ type pqAttempt struct {
 	sender         *fragment.Sender
 	msg2           *fragment.Assembly
 	cookieResendAt time.Time // last resend prompted by a cookie reply
+	heard          bool      // a msg2 fragment or cookie reply arrived, so the peer speaks PQ
 }
 
 // pqFragKey identifies one msg1 being reassembled.
@@ -125,21 +134,69 @@ func reassembled(h *header.H, msg []byte) []byte {
 	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 {
+// usePQ decides whether this outbound attempt is post-quantum. A peer that does
+// not speak PQ silently drops it, so timer retries alternate between PQ and
+// classical, starting with PQ. An attempt triggered early by a lighthouse reply
+// is sending to addresses nothing has been sent to yet, and a new path gets the
+// PQ attempt first. With require, or for a peer that completed a PQ handshake
+// when sticky is on, every attempt is PQ. Once anything has come back for the PQ
+// attempt the peer evidently speaks PQ, and a classical attempt would only race
+// it, so classical stops getting turns.
+func (hm *HandshakeManager) usePQ(hh *HandshakeHostInfo, vpnIp netip.Addr, triggered bool) bool {
 	cfg := hm.pqConfig()
-	if !cfg.initiate {
+	switch {
+	case !cfg.initiate:
 		return false
-	}
-	if cfg.sticky {
+	case cfg.require:
+		return true
+	case hh.pq != nil && hh.pq.heard:
+		return true
+	case cfg.sticky:
 		if _, ok := hm.pqPeers.Load(vpnIp); ok {
 			return true
 		}
 	}
-	return hh.counter%2 == 1
+	return hh.counter <= 1 || triggered || !hh.lastPQ
+}
+
+// relayEstablished pushes the pending handshake with target through a relay that
+// just came up, rather than leaving it for the next retry. Nothing has gone over
+// the relay yet, so like any new path it gets the PQ attempt first, which then
+// counts as the PQ turn: the next retry is classical, as if it had been sent on
+// schedule. Without this, relays coming up while the first attempt is in flight
+// handed every relayed handshake to the classical retry.
+func (hm *HandshakeManager) relayEstablished(target netip.Addr, relayHI *HostInfo, relay *Relay) {
+	hh := hm.queryVpnIp(target)
+	if hh == nil {
+		return
+	}
+	hh.Lock()
+	defer hh.Unlock()
+	if hm.queryVpnIp(target) != hh {
+		return
+	}
+
+	var now, later [][]byte
+	switch {
+	case hh.pq != nil:
+		var err error
+		now, later, err = hh.pq.sender.Attempt(fragment.DefaultRepair(hh.pq.sender.Layout().K))
+		if err != nil {
+			return
+		}
+		hh.lastPQ = true
+	case hh.ready:
+		now = [][]byte{hh.hostinfo.HandshakePacket[handshakePacketStage0]}
+	default:
+		return
+	}
+
+	f := hm.f
+	hh.hostinfo.logger(hm.l).Info("Send handshake via new relay", "relay", relayHI.vpnAddrs[0])
+	_ = hm.sendFragmented(now, later, func(p []byte) error {
+		f.SendVia(relayHI, relay, p, make([]byte, 12), make([]byte, mtu), false, 0)
+		return nil
+	})
 }
 
 // buildPQAttempt creates the initiator's PQ Machine and msg1 sender.
@@ -305,6 +362,7 @@ func (hm *HandshakeManager) continuePQ(via ViaSender, hh *HandshakeHostInfo, h *
 	if pq == nil || pq.index != h.RemoteIndex || !hm.stillPending(hh, pq.index) {
 		return
 	}
+	pq.heard = true
 
 	if pq.msg2 == nil {
 		a, err := fragment.NewAssembly(fh)
@@ -605,6 +663,7 @@ func (hm *HandshakeManager) handleCookie(via ViaSender, packet []byte, h *header
 	var c fragment.Cookie
 	copy(c[:], packet[header.Len:])
 	pq.sender.SetCookie(c)
+	pq.heard = true
 
 	now := time.Now()
 	if now.Sub(pq.cookieResendAt) < hm.config.tryInterval {

+ 48 - 0
handshake_manager_pq_test.go

@@ -295,6 +295,7 @@ func TestPQHandleCookie(t *testing.T) {
 	require.NoError(t, h.Parse(reply))
 
 	hm.handleFragment(via, reply, &h)
+	assert.True(t, hh.pq.heard, "a cookie reply shows the peer speaks PQ")
 	sent := conn.take()
 	require.Len(t, sent, 4, "an immediate resend of fresh symbols")
 	for i, p := range sent {
@@ -347,4 +348,51 @@ func TestReloadPQ(t *testing.T) {
 
 	require.NoError(t, c.ReloadConfigString(`handshakes: {}`))
 	assert.Equal(t, pqHandshakeConfig{repairDelay: DefaultPQRepairDelay}, *hm.pqConfig(), "removing the section restores the defaults")
+
+	require.NoError(t, c.ReloadConfigString(`handshakes: {pq: {require: true}}`))
+	assert.Equal(t, pqHandshakeConfig{initiate: true, accept: true, require: true, repairDelay: DefaultPQRepairDelay}, *hm.pqConfig(), "require implies initiate and accept")
+}
+
+func TestUsePQ(t *testing.T) {
+	hm, _ := newPQTestManager(t)
+	peer := netip.MustParseAddr("10.1.0.1")
+	set := func(cfg pqHandshakeConfig) { hm.pq.Store(&cfg) }
+
+	// attempts plays a run of attempts, given whether each was triggered early,
+	// and returns which were PQ.
+	attempts := func(triggered ...bool) []bool {
+		hh := &HandshakeHostInfo{}
+		var out []bool
+		for _, tr := range triggered {
+			hh.counter++
+			hh.lastPQ = hm.usePQ(hh, peer, tr)
+			out = append(out, hh.lastPQ)
+		}
+		return out
+	}
+
+	set(pqHandshakeConfig{})
+	assert.Equal(t, []bool{false, false, false}, attempts(false, false, false), "off")
+
+	set(pqHandshakeConfig{initiate: true})
+	assert.Equal(t, []bool{true, false, true, false}, attempts(false, false, false, false), "timer retries alternate, PQ first")
+	assert.Equal(t, []bool{true, true, false, true, false}, attempts(false, true, false, true, false), "an early trigger is a new path, which gets PQ first")
+	assert.Equal(t, []bool{true, true, false}, attempts(true, true, false), "a triggered first attempt is PQ")
+
+	t.Run("hearing back stops the classical turns", func(t *testing.T) {
+		hh := &HandshakeHostInfo{pq: &pqAttempt{}}
+		hh.counter, hh.lastPQ = 1, true
+		hh.counter++
+		assert.False(t, hm.usePQ(hh, peer, false), "unheard, the classical attempt gets its turn")
+		hh.pq.heard = true
+		assert.True(t, hm.usePQ(hh, peer, false))
+	})
+
+	set(pqHandshakeConfig{initiate: true, accept: true, require: true})
+	assert.Equal(t, []bool{true, true, true, true}, attempts(false, false, true, false), "require never offers classical")
+
+	set(pqHandshakeConfig{initiate: true, sticky: true})
+	assert.Equal(t, []bool{true, false}, attempts(false, false), "sticky alternates until the peer has done PQ")
+	hm.notePQPeer(pqSubtype, []netip.Addr{peer})
+	assert.Equal(t, []bool{true, true, true}, attempts(false, false, false), "then only PQ")
 }

+ 2 - 0
relay_manager.go

@@ -364,6 +364,8 @@ func (rm *relayManager) handleCreateRelayResponse(v cert.Version, h *HostInfo, f
 	}
 	// Do I need to complete the relays now?
 	if relay.Type == TerminalType {
+		// The relay I asked for is up; a handshake waiting on it can go now.
+		f.handshakeManager.relayEstablished(relay.PeerAddr, h, relay)
 		return
 	}
 	// I'm the middle man. Let the initiator know that the I've established the relay they requested.