From 13658b74a0cc02cb169c09afe36d7681e4084a82 Mon Sep 17 00:00:00 2001 From: JackDoan Date: Wed, 14 Oct 2026 10:18:00 -0500 Subject: [PATCH] Add handshakes.pq.require, and give new paths and responsive peers the PQ attempt --- docs/pq-handshake-vs-quic.md | 131 +++++++++++++++++++++++++++++++++++ docs/pq-handshake.md | 12 +++- e2e/pq_handshakes_test.go | 75 +++++++++++++++++++- examples/config.yml | 3 + handshake_manager.go | 8 ++- handshake_manager_pq.go | 79 ++++++++++++++++++--- handshake_manager_pq_test.go | 48 +++++++++++++ relay_manager.go | 2 + 8 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 docs/pq-handshake-vs-quic.md diff --git a/docs/pq-handshake-vs-quic.md b/docs/pq-handshake-vs-quic.md new file mode 100644 index 00000000..08268fd8 --- /dev/null +++ b/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. diff --git a/docs/pq-handshake.md b/docs/pq-handshake.md index 4507dd48..3f4fa4db 100644 --- a/docs/pq-handshake.md +++ b/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 diff --git a/e2e/pq_handshakes_test.go b/e2e/pq_handshakes_test.go index ba23747c..f3e72a28 100644 --- a/e2e/pq_handshakes_test.go +++ b/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() + }) +} diff --git a/examples/config.yml b/examples/config.yml index 670e3f63..3f354826 100644 --- a/examples/config.yml +++ b/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 diff --git a/handshake_manager.go b/handshake_manager.go index 64944cf5..1359182c 100644 --- a/handshake_manager.go +++ b/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 diff --git a/handshake_manager_pq.go b/handshake_manager_pq.go index 17c38320..d673c915 100644 --- a/handshake_manager_pq.go +++ b/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 { diff --git a/handshake_manager_pq_test.go b/handshake_manager_pq_test.go index 9da6a799..d88b23b6 100644 --- a/handshake_manager_pq_test.go +++ b/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") } diff --git a/relay_manager.go b/relay_manager.go index a6e3f22c..5d0bbeea 100644 --- a/relay_manager.go +++ b/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.