Explorar o código

Run hybrid handshakes: v3 holders initiate them, responders wait for msg3, initiators resend it until confirmed

JackDoan hai 6 días
pai
achega
b26c1e9c3e

+ 8 - 0
connection_state.go

@@ -92,6 +92,14 @@ func (cs *ConnectionState) NextMessageCounter() (uint64, bool) {
 	return c, true
 }
 
+// heardFromPeer reports whether anything from the peer has decrypted under these
+// keys, beyond the handshake messages the replay window was seeded with.
+func (cs *ConnectionState) heardFromPeer(handshakeMessages uint64) bool {
+	cs.decryptLock.Lock()
+	defer cs.decryptLock.Unlock()
+	return cs.window.current > handshakeMessages
+}
+
 func (cs *ConnectionState) Curve() cert.Curve {
 	return cs.myCert.Curve()
 }

+ 399 - 0
e2e/pq_hybrid_test.go

@@ -0,0 +1,399 @@
+//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"
+)
+
+// hybridTally counts handshake packets by subtype and message.
+type hybridTally struct {
+	sync.Mutex
+	hybrid, phase1, classical int
+	msg3                      int
+	maxLen                    int
+}
+
+func (ht *hybridTally) observe(p *udp.Packet) {
+	h, _ := handshakeOf(p)
+	if h == nil {
+		return
+	}
+	ht.Lock()
+	defer ht.Unlock()
+	switch h.Subtype {
+	case header.HandshakeIXHybridMLKEM1024:
+		ht.hybrid++
+		ht.maxLen = max(ht.maxLen, len(p.Data))
+		if h.MessageCounter == 3 {
+			ht.msg3++
+		}
+	case header.HandshakeIXPSK2MLKEM1024:
+		ht.phase1++
+	default:
+		ht.classical++
+	}
+}
+
+func isType(p *udp.Packet, t header.MessageType) bool {
+	h := &header.H{}
+	return h.Parse(p.Data) == nil && h.Type == t
+}
+
+type hybridCA struct {
+	ca  cert.Certificate
+	key []byte
+}
+
+func newHybridCA(t *testing.T) hybridCA {
+	t.Helper()
+	ca, _, key, _ := cert_test.NewTestCaCert(cert.Version3, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
+	return hybridCA{ca: ca, key: key}
+}
+
+// v2 returns the v2 form of the CA and its classical key, what a node that only has v2 certificates gets.
+func (h hybridCA) v2() (cert.Certificate, []byte) {
+	return cert.V2Form(h.ca), h.key[:len(h.key)-cert.MLDSA87PrivateKeySize]
+}
+
+// trust returns a pki.ca override trusting the given CAs.
+func trust(cas ...cert.Certificate) string {
+	var s string
+	for _, c := range cas {
+		b, err := c.MarshalPEM()
+		if err != nil {
+			panic(err)
+		}
+		s += string(b)
+	}
+	return s
+}
+
+func withCA(cfg m, cas ...cert.Certificate) m {
+	if cfg == nil {
+		cfg = m{}
+	}
+	cfg["pki"] = m{"ca": trust(cas...)}
+	return cfg
+}
+
+func newHybridPair(t *testing.T, mine, theirs m) (myControl, theirControl *nebula.Control, myVpn, theirVpn []netip.Prefix, myUdp, theirUdp netip.AddrPort, r *router.R) {
+	t.Helper()
+	return newHybridPairWithCA(t, newHybridCA(t), mine, theirs)
+}
+
+func newHybridPairWithCA(t *testing.T, ca hybridCA, mine, theirs m) (myControl, theirControl *nebula.Control, myVpn, theirVpn []netip.Prefix, myUdp, theirUdp netip.AddrPort, r *router.R) {
+	t.Helper()
+	myControl, myVpn, myUdp, _ = newSimpleServer(cert.Version3, ca.ca, ca.key, "me", "10.128.0.1/24", mine)
+	theirControl, theirVpn, theirUdp, _ = newSimpleServer(cert.Version3, ca.ca, ca.key, "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 routeUntilData(t *testing.T, r *router.R, to *nebula.Control, timeout time.Duration, fn func(p *udp.Packet, receiver *nebula.Control) router.ExitType) bool {
+	t.Helper()
+	return r.RouteForAllExitFuncOrTimeout(timeout, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		if fn != nil {
+			if e := fn(p, receiver); e != router.KeepRouting {
+				return e
+			}
+		}
+		if isTunnelData(p, receiver, to) {
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	})
+}
+
+func TestHybridHandshake(t *testing.T) {
+	t.Parallel()
+	myControl, theirControl, myVpn, theirVpn, myUdp, theirUdp, r := newHybridPair(t, pqConfig(true, true, false), pqConfig(false, true, false))
+	defer r.RenderFlow()
+
+	var tally hybridTally
+	var recvErrors int
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, routeUntilData(t, r, theirControl, 5*time.Second, func(p *udp.Packet, _ *nebula.Control) router.ExitType {
+		tally.observe(p)
+		if isType(p, header.RecvError) {
+			recvErrors++
+		}
+		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+tally.phase1, "a node with a v3 certificate speaks the hybrid handshake")
+	assert.GreaterOrEqual(t, tally.hybrid, 7+10+2, "all three messages travel as several fragments")
+	assert.Positive(t, tally.msg3)
+	assert.LessOrEqual(t, tally.maxLen, fragment.MaxPacketLen)
+	assert.Zero(t, recvErrors)
+
+	assertHostInfoPair(t, myUdp, theirUdp, myVpn, theirVpn, myControl, theirControl)
+	assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+	for _, c := range []*nebula.Control{myControl, theirControl} {
+		assert.Empty(t, c.ListHostmapIndexes(true), "no pending index may outlive the handshake")
+	}
+	assert.Equal(t, cert.Version3, myControl.GetHostInfoByVpnAddr(theirVpn[0].Addr(), false).Cert.Version())
+	assert.Equal(t, cert.Version3, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false).Cert.Version())
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestHybridConfirmationLost loses the responder's confirmation. The initiator
+// keeps resending msg3, and the responder, already done, confirms again rather
+// than leave the initiator to tear the tunnel down.
+func TestHybridConfirmationLost(t *testing.T) {
+	t.Parallel()
+	// Three retries make the handshake timeout 400ms, so the wait below outlasts it.
+	mine := pqConfig(true, true, false)
+	mine["handshakes"].(m)["retries"] = 3
+	myControl, theirControl, myVpn, theirVpn, _, _, r := newHybridPair(t, mine, pqConfig(false, true, false))
+	defer r.RenderFlow()
+
+	var tally hybridTally
+	var droppedConfirms, confirms int
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	r.RouteForAllExitFuncOrTimeout(2*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
+		tally.observe(p)
+		if receiver == myControl && isType(p, header.Test) {
+			if droppedConfirms == 0 {
+				droppedConfirms++
+				return router.Drop
+			}
+			confirms++
+			return router.RouteAndExit
+		}
+		return router.KeepRouting
+	})
+
+	assert.Equal(t, 1, droppedConfirms)
+	assert.Positive(t, confirms, "the responder confirms again")
+	assert.Greater(t, tally.msg3, 4, "msg3 was resent with fresh symbols")
+	assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
+
+	// Long past the handshake timeout, the tunnel must still be there.
+	time.Sleep(700 * time.Millisecond)
+	r.FlushAll()
+	assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestHybridNeverConfirmed loses every msg3. The responder never completes, must
+// not answer the initiator's data with RecvErrors meanwhile, and the initiator
+// closes the tunnel at the handshake timeout.
+func TestHybridNeverConfirmed(t *testing.T) {
+	t.Parallel()
+	mine := pqConfig(true, true, false)
+	mine["handshakes"].(m)["retries"] = 3
+	theirs := pqConfig(false, true, false)
+	theirs["handshakes"].(m)["retries"] = 3
+	myControl, theirControl, myVpn, theirVpn, _, _, r := newHybridPair(t, mine, theirs)
+	defer r.RenderFlow()
+
+	var recvErrors, dropped int
+	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 {
+		if h, fh := handshakeOf(p); fh != nil && h.MessageCounter == 3 {
+			dropped++
+			return router.Drop
+		}
+		if isType(p, header.RecvError) {
+			recvErrors++
+		}
+		return router.KeepRouting
+	})
+
+	assert.Greater(t, dropped, 4, "msg3 is resent")
+	assert.Zero(t, recvErrors, "data for a pending responder is dropped quietly")
+	assert.Nil(t, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false), "the responder never completes")
+	assert.Nil(t, myControl.GetHostInfoByVpnAddr(theirVpn[0].Addr(), false), "the initiator gave up on the tunnel")
+	assert.Empty(t, theirControl.ListHostmapIndexes(true), "the pending responder expired")
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestHybridToV2Peer has a v3 initiator meet a peer that speaks phase 1 post-quantum
+// handshakes but holds only a v2 certificate. Holding v3, the initiator never
+// offers phase 1, so the tunnel comes up classical on the v2 form.
+func TestHybridToV2Peer(t *testing.T) {
+	t.Parallel()
+	ca := newHybridCA(t)
+	ca2, key2 := ca.v2()
+	myControl, myVpn, myUdp, _ := newSimpleServer(cert.Version3, ca.ca, ca.key, "me", "10.128.0.1/24", withCA(pqConfig(true, true, false), ca.ca, ca2))
+	theirControl, theirVpn, theirUdp, _ := newSimpleServer(cert.Version2, ca2, key2, "them", "10.128.0.2/24", pqConfig(true, true, false))
+	myControl.InjectLightHouseAddr(theirVpn[0].Addr(), theirUdp)
+	r := router.NewR(t, myControl, theirControl)
+	defer r.RenderFlow()
+	myControl.Start()
+	theirControl.Start()
+
+	var tally hybridTally
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, routeUntilData(t, r, theirControl, 5*time.Second, func(p *udp.Packet, _ *nebula.Control) router.ExitType {
+		tally.observe(p)
+		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.hybrid, "the hybrid attempt comes first")
+	assert.Zero(t, tally.phase1, "a v3 holder never initiates phase 1")
+	assert.Positive(t, tally.classical)
+	assertHostInfoPair(t, myUdp, theirUdp, myVpn, theirVpn, myControl, theirControl)
+	assert.Equal(t, cert.Version2, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false).Cert.Version())
+	assert.Empty(t, myControl.ListHostmapIndexes(true))
+	assert.Empty(t, theirControl.ListHostmapIndexes(true))
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestHybridDistrustedV3 has an initiator that trusts only the v2 form of the CA.
+// Its hybrid attempt fails verifying the responder's v3 certificate, which must
+// leave the classical attempt going rather than end the handshake.
+func TestHybridDistrustedV3(t *testing.T) {
+	t.Parallel()
+	ca := newHybridCA(t)
+	ca2, _ := ca.v2()
+	myControl, theirControl, myVpn, theirVpn, _, _, r := newHybridPairWithCA(t, ca,
+		withCA(pqConfig(true, true, false), ca2),
+		withCA(pqConfig(false, true, false), ca.ca, ca2),
+	)
+	defer r.RenderFlow()
+
+	var tally hybridTally
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, routeUntilData(t, r, theirControl, 5*time.Second, func(p *udp.Packet, _ *nebula.Control) router.ExitType {
+		tally.observe(p)
+		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.hybrid)
+	assert.Positive(t, tally.classical)
+	assert.Equal(t, cert.Version2, myControl.GetHostInfoByVpnAddr(theirVpn[0].Addr(), false).Cert.Version())
+	assert.Empty(t, myControl.ListHostmapIndexes(true))
+
+	myControl.Stop()
+	theirControl.Stop()
+}
+
+// TestHybridRequire checks require with a v3 certificate means hybrid only.
+func TestHybridRequire(t *testing.T) {
+	t.Parallel()
+
+	t.Run("both", func(t *testing.T) {
+		myControl, theirControl, myVpn, theirVpn, _, _, r := newHybridPair(t, pqRequire(), pqRequire())
+		defer r.RenderFlow()
+
+		var tally hybridTally
+		myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
+		require.True(t, routeUntilData(t, r, theirControl, 5*time.Second, func(p *udp.Packet, _ *nebula.Control) router.ExitType {
+			tally.observe(p)
+			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+tally.phase1)
+		assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
+
+		myControl.Stop()
+		theirControl.Stop()
+	})
+
+	t.Run("responder refuses phase 1", func(t *testing.T) {
+		ca := newHybridCA(t)
+		ca2, key2 := ca.v2()
+		myControl, myVpn, _, _ := newSimpleServer(cert.Version2, ca2, key2, "me", "10.128.0.1/24", pqRequire())
+		theirControl, theirVpn, theirUdp, _ := newSimpleServer(cert.Version3, ca.ca, ca.key, "them", "10.128.0.2/24", withCA(pqRequire(), ca.ca, ca2))
+		myControl.InjectLightHouseAddr(theirVpn[0].Addr(), theirUdp)
+		r := router.NewR(t, myControl, theirControl)
+		defer r.RenderFlow()
+		myControl.Start()
+		theirControl.Start()
+
+		var tally hybridTally
+		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 {
+			tally.observe(p)
+			if h, _ := handshakeOf(p); h != nil && h.MessageCounter == 2 {
+				answers++
+			}
+			return router.KeepRouting
+		})
+		assert.Positive(t, tally.phase1)
+		assert.Zero(t, answers, "a phase 1 msg1 gets no answer from a v3 node requiring post-quantum")
+		assert.Nil(t, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false))
+
+		myControl.Stop()
+		theirControl.Stop()
+	})
+}
+
+func TestHybridRelays(t *testing.T) {
+	t.Parallel()
+	ca := newHybridCA(t)
+	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.Version3, ca.ca, ca.key, "me     ", "10.128.0.1/24", useRelays)
+	relayControl, relayVpnIpNet, relayUdpAddr, _ := newSimpleServer(cert.Version3, ca.ca, ca.key, "relay  ", "10.128.0.128/24", amRelay)
+	theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version3, ca.ca, ca.key, "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 relayedMsg3, relayedClassical int
+	myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me")))
+	require.True(t, routeUntilData(t, r, theirControl, 10*time.Second, func(p *udp.Packet, _ *nebula.Control) router.ExitType {
+		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 {
+				assert.LessOrEqual(t, len(p.Data), fragment.MaxPacketLen+32, "a relayed fragment fits the IPv6 minimum MTU")
+				if h.MessageCounter == 3 {
+					relayedMsg3++
+				}
+			} else if h != nil {
+				relayedClassical++
+			}
+		}
+		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, relayedMsg3, "msg3 goes through the relay too")
+	assert.Zero(t, relayedClassical)
+	r.RenderHostmaps("Final hostmaps", myControl, relayControl, theirControl)
+
+	myControl.Stop()
+	relayControl.Stop()
+	theirControl.Stop()
+}

+ 6 - 0
handshake/machine.go

@@ -134,6 +134,12 @@ func (m *Machine) Subtype() header.MessageSubType {
 	return m.subtype
 }
 
+// PeerCert returns the peer's verified certificate once a message carrying it
+// has been read, which for a three message pattern is before completion.
+func (m *Machine) PeerCert() *cert.CachedCertificate {
+	return m.result.RemoteCert
+}
+
 // MessageIndex returns the noise handshake message index, which equals the
 // wire counter of the most recently sent or received message.
 func (m *Machine) MessageIndex() int {

+ 63 - 11
handshake_manager.go

@@ -90,6 +90,8 @@ type HandshakeManager struct {
 	// pqCookies and pqCookieLimiter serve cookies while pqTable is under load.
 	pqCookies       *pqCookies
 	pqCookieLimiter *ratelimit.Limiter
+	// hybrid holds hybrid handshakes waiting on their third message.
+	hybrid hybridState
 }
 
 type HandshakeHostInfo struct {
@@ -107,6 +109,9 @@ type HandshakeHostInfo struct {
 	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
+	noPQ     bool               // The post-quantum attempt failed for good, only classical is left
+
+	responder *hybridResponder // Set for a responder's hybrid handshake waiting for msg3
 }
 
 func (hh *HandshakeHostInfo) cachePacket(l *slog.Logger, t header.MessageType, st header.MessageSubType, packet []byte, f packetCallback, m *cachedPacketMetrics) {
@@ -150,6 +155,7 @@ func NewHandshakeManager(l *slog.Logger, mainHostMap *HostMap, lightHouse *Light
 		l:                      l,
 		pqTable:                fragment.NewTable[pqFragKey, pqFragSource](pqTableConfig),
 		pqAnswered:             newPQAnswered(hsTimeout(config.retries, config.tryInterval)),
+		hybrid:                 newHybridState(),
 	}
 	pq := config.pq
 	hm.pq.Store(&pq)
@@ -174,6 +180,8 @@ func (hm *HandshakeManager) Run(ctx context.Context) {
 			hm.NextOutboundHandshakeTimerTick(now)
 			hm.pqTable.Expire(now)
 			hm.pqAnswered.expire(now)
+			hm.expireHybridResponders(now)
+			hm.tickHybridConfirms(now)
 		}
 	}
 }
@@ -183,7 +191,7 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head
 	// don't yet support) are dropped here rather than silently routed through
 	// the IX path. Add a case when introducing a new pattern.
 	switch h.Subtype {
-	case header.HandshakeIXPSK0, pqSubtype:
+	case header.HandshakeIXPSK0, header.HandshakeIXPSK2MLKEM1024, hybridSubtype:
 		// supported
 	default:
 		hm.l.Debug("dropping handshake with unsupported subtype",
@@ -775,6 +783,28 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 	cs := f.pki.getCertState()
 
 	v := cs.DefaultVersion()
+	allocIndex := func() (uint32, error) { return generateIndex(f.l) }
+
+	// A hybrid responder answers with its v3 certificate and has to wait for msg3,
+	// so its index is registered right away for msg3 to find.
+	var pending *HandshakeHostInfo
+	if h.Subtype == hybridSubtype {
+		v = cert.Version3
+		pending = &HandshakeHostInfo{
+			hostinfo: &HostInfo{
+				HandshakePacket: make(map[uint8][]byte, 0),
+				relayState: RelayState{
+					relays:         nil,
+					relayForByAddr: map[netip.Addr]*Relay{},
+					relayForByIdx:  map[uint32]*Relay{},
+				},
+			},
+			startTime: time.Now(),
+			responder: &hybridResponder{},
+		}
+		allocIndex = func() (uint32, error) { return hm.allocateIndex(pending) }
+	}
+
 	if cs.GetCredential(v) == nil {
 		f.l.Error("Unable to handshake with host because no certificate is available",
 			"from", via, "certVersion", v)
@@ -783,7 +813,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 
 	machine, err := handshake.NewMachine(
 		v, cs.GetCredential,
-		hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) },
+		hm.certVerifier(), allocIndex,
 		false, h.Subtype,
 	)
 	if err != nil {
@@ -793,10 +823,18 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 
 	response, result, err := machine.ProcessPacket(nil, packet)
 	if err != nil {
+		if pending != nil {
+			hm.DeleteHostInfo(pending.hostinfo)
+		}
 		f.l.Error("Failed to process handshake packet", "from", via, "error", err)
 		return
 	}
 
+	if result == nil && pending != nil {
+		hm.awaitMsg3(via, pending, machine, packet, response, received)
+		return
+	}
+
 	if result == nil {
 		// Multi-message pattern: the responder Machine would need to be
 		// registered in hm.indexes so a future inbound packet finds it via
@@ -938,22 +976,28 @@ func (hm *HandshakeManager) processResponse(via ViaSender, hh *HandshakeHostInfo
 	response, result, err := machine.ProcessPacket(nil, packet)
 	if err != nil {
 		// Recoverable errors are routine noise, log at Debug. Fatal errors get a Warn.
-		if machine.Failed() {
-			f.l.Warn("Failed to process handshake packet, abandoning",
+		if !machine.Failed() {
+			f.l.Debug("Failed to process handshake packet",
 				"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
-			hm.DeleteHostInfo(hostinfo)
+		} else if hh.pq != nil && machine == hh.pq.machine && !hm.pqConfig().require {
+			// A post-quantum attempt can fail where the classical one would not, a v3
+			// certificate from a CA only trusted in its v2 form for one. Keep going
+			// with classical alone.
+			f.l.Warn("Failed to process post-quantum handshake packet, continuing with classical",
+				"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
+			hm.abandonPQ(hh)
 		} else {
-			f.l.Debug("Failed to process handshake packet",
+			f.l.Warn("Failed to process handshake packet, abandoning",
 				"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
+			hm.DeleteHostInfo(hostinfo)
 		}
 		return
 	}
 
-	if response != nil {
-		hm.sendHandshakeResponse(via, response, hostinfo, false, 0)
-	}
-
 	if result == nil {
+		if response != nil {
+			hm.sendHandshakeResponse(via, response, hostinfo, false, 0)
+		}
 		return
 	}
 
@@ -1068,6 +1112,12 @@ func (hm *HandshakeManager) processResponse(via ViaSender, hh *HandshakeHostInfo
 	hm.Complete(hostinfo, f)
 	hm.notePQPeer(machine.Subtype(), vpnAddrs)
 
+	// A three message pattern finishes with our msg3, which goes out before any
+	// cached packets do and keeps going until the responder is heard from.
+	if response != nil {
+		hm.startHybridConfirm(via, hostinfo, response, result.MessageIndex)
+	}
+
 	if len(hh.packetStore) > 0 {
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
 			hostinfo.logger(f.l).Debug("Sending stored packets", "count", len(hh.packetStore))
@@ -1166,7 +1216,9 @@ func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hos
 		"initiatorIndex", hostinfo.remoteIndexId,
 		"responderIndex", hostinfo.localIndexId,
 	}
-	if peerCert := hostinfo.ConnectionState.peerCert; peerCert != nil {
+	if hostinfo.ConnectionState == nil {
+		// A hybrid responder answers msg1 before it has keys.
+	} else if peerCert := hostinfo.ConnectionState.peerCert; peerCert != nil {
 		logFields = append(logFields,
 			"certName", peerCert.Certificate.Name(),
 			"certVersion", peerCert.Certificate.Version(),

+ 380 - 0
handshake_manager_hybrid.go

@@ -0,0 +1,380 @@
+package nebula
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+	"sync"
+	"time"
+
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/handshake"
+	"github.com/slackhq/nebula/handshake/fragment"
+	"github.com/slackhq/nebula/header"
+)
+
+// Hybrid handshakes (ix_hybrid_mlkem1024) take three messages, so unlike IX the
+// responder has to wait for one more message and the initiator finishes by
+// sending rather than receiving:
+//
+//   - The responder answers msg1 with msg2 and keeps the handshake pending under
+//     its local index until msg3 arrives. Data for that index is dropped quietly
+//     meanwhile, it must not draw a RecvError. Once msg3 verifies the tunnel
+//     goes live and an empty TestReply tells the initiator so.
+//   - The initiator has the keys as soon as it writes msg3, so the tunnel goes
+//     live then, readable only by the real responder. msg3 is resent with fresh
+//     symbols until something from the responder decrypts, and if nothing does
+//     within the handshake timeout the tunnel is closed.
+
+const (
+	hybridSubtype = header.HandshakeIXHybridMLKEM1024
+
+	// hybridMsg3MaxLen bounds the msg3 a pending responder reassembles. msg3 is
+	// a ciphertext and two tags, about 1.6KB.
+	hybridMsg3MaxLen = 4096
+)
+
+// hybridResponder is the responder half of a pending hybrid handshake, waiting
+// for msg3.
+type hybridResponder struct {
+	msg3    *fragment.Assembly
+	expires time.Time
+}
+
+// hybridConfirm is an initiator's completed hybrid handshake that has not yet
+// heard from the responder.
+type hybridConfirm struct {
+	hostinfo     *HostInfo
+	sender       *fragment.Sender
+	send         func([]byte) error
+	handshakeLen uint64 // messages the handshake took, which the replay window was seeded with
+	deadline     time.Time
+	next         time.Time
+	attempts     int
+}
+
+// hybridState holds the hybrid handshakes waiting on their last step.
+type hybridState struct {
+	// responders maps local indexes to pending responders. Guarded by the
+	// HandshakeManager lock; the entries are also in its indexes.
+	responders map[uint32]*HandshakeHostInfo
+
+	confirmsMu sync.Mutex
+	confirms   map[*HostInfo]*hybridConfirm
+}
+
+func newHybridState() hybridState {
+	return hybridState{
+		responders: map[uint32]*HandshakeHostInfo{},
+		confirms:   map[*HostInfo]*hybridConfirm{},
+	}
+}
+
+// awaitMsg3 keeps a responder's hybrid handshake pending after msg1, and sends
+// msg2. hh is already in the indexes under the local index the Machine
+// allocated.
+func (hm *HandshakeManager) awaitMsg3(via ViaSender, hh *HandshakeHostInfo, machine *handshake.Machine, packet, response []byte, received int) {
+	f := hm.f
+	hostinfo := hh.hostinfo
+
+	remoteCert := machine.PeerCert()
+	if remoteCert == nil {
+		f.l.Error("Handshake did not produce a peer certificate", "from", via)
+		hm.DeleteHostInfo(hostinfo)
+		return
+	}
+	vpnAddrs, anyVpnAddrsInCommon, ok := hm.validatePeerCert(via, remoteCert)
+	if !ok {
+		hm.DeleteHostInfo(hostinfo)
+		return
+	}
+	var rh header.H
+	if err := rh.Parse(response); err != nil {
+		hm.DeleteHostInfo(hostinfo)
+		return
+	}
+
+	now := time.Now()
+	hostinfo.vpnAddrs = vpnAddrs
+	hostinfo.remoteIndexId = rh.RemoteIndex
+	// packet aliases the listener's incoming buffer, so this copy must stay.
+	hostinfo.HandshakePacket[handshakePacketStage0] = append([]byte(nil), packet[header.Len:]...)
+	hostinfo.HandshakePacket[handshakePacketStage2] = response
+	hostinfo.remotes = f.lightHouse.QueryCache(vpnAddrs)
+	if !via.IsRelayed {
+		hostinfo.SetRemote(via.UdpAddr)
+	}
+	hh.machine = machine
+	hh.responder.expires = now.Add(hsTimeout(hm.config.retries, hm.config.tryInterval))
+
+	hm.Lock()
+	hm.hybrid.responders[hostinfo.localIndexId] = hh
+	hm.Unlock()
+
+	msg := "Handshake message received"
+	if !anyVpnAddrsInCommon {
+		msg = "Handshake message received, but no vpnNetworks in common."
+	}
+	f.l.Info(msg,
+		"vpnAddrs", vpnAddrs,
+		"from", via,
+		"certName", remoteCert.Certificate.Name(),
+		"certVersion", remoteCert.Certificate.Version(),
+		"fingerprint", remoteCert.Fingerprint,
+		"issuer", remoteCert.Certificate.Issuer(),
+		"initiatorIndex", hostinfo.remoteIndexId,
+		"responderIndex", hostinfo.localIndexId,
+		"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())},
+	)
+
+	hm.sendHandshakeResponse(via, response, hostinfo, false, received)
+	hm.pqAnswered.add(packet[header.Len:], hostinfo, now)
+}
+
+// continueHybridResponder collects msg3 fragments for a pending responder and
+// completes the tunnel once they decode and verify.
+func (hm *HandshakeManager) continueHybridResponder(via ViaSender, h *header.H, fh fragment.Header, sym []byte) {
+	hh := hm.queryIndex(h.RemoteIndex)
+	if hh == nil || hh.responder == nil {
+		hm.reconfirmHybrid(h.RemoteIndex)
+		return
+	}
+	if fh.MsgLen > hybridMsg3MaxLen {
+		return
+	}
+
+	hh.Lock()
+	defer hh.Unlock()
+	if !hm.stillPending(hh, h.RemoteIndex) || fh.FragID != hh.hostinfo.remoteIndexId {
+		return
+	}
+
+	r := hh.responder
+	if r.msg3 == nil {
+		a, err := fragment.NewAssembly(fh)
+		if err != nil {
+			return
+		}
+		r.msg3 = a
+	}
+	ready, err := r.msg3.Add(fh, sym)
+	if errors.Is(err, fragment.ErrMismatch) {
+		r.msg3 = nil
+		return
+	}
+	if err != nil || !ready {
+		return
+	}
+	msg, err := r.msg3.Decode()
+	r.msg3 = nil
+	if err != nil {
+		hm.l.Debug("failed to decode handshake fragments", "from", via, "error", err)
+		return
+	}
+
+	_, result, err := hh.machine.ProcessPacket(nil, reassembled(h, msg))
+	if err != nil {
+		if hh.machine.Failed() {
+			hm.l.Warn("Failed to process handshake packet, abandoning",
+				"vpnAddrs", hh.hostinfo.vpnAddrs, "from", via, "error", err)
+			hm.forgetHybridResponder(hh)
+		} else {
+			hm.l.Debug("Failed to process handshake packet",
+				"vpnAddrs", hh.hostinfo.vpnAddrs, "from", via, "error", err)
+		}
+		return
+	}
+	if result == nil {
+		return
+	}
+	hm.completeHybridResponder(via, hh, result)
+}
+
+// completeHybridResponder brings up the tunnel of a responder whose msg3 verified.
+// The caller must hold hh.Lock.
+func (hm *HandshakeManager) completeHybridResponder(via ViaSender, hh *HandshakeHostInfo, result *handshake.Result) {
+	f := hm.f
+	hostinfo := hh.hostinfo
+	machine := hh.machine
+	defer hm.forgetHybridResponder(hh)
+
+	cs, err := newConnectionStateFromResult(result)
+	if err != nil {
+		f.l.Error("Discarding handshake with an invalid message index", "error", err, "vpnAddrs", hostinfo.vpnAddrs)
+		return
+	}
+	hostinfo.ConnectionState = cs
+	hostinfo.remoteIndexId = result.RemoteIndex
+	hostinfo.lastHandshakeTime = result.HandshakeTime
+	if !via.IsRelayed {
+		hostinfo.SetRemote(via.UdpAddr)
+	} else {
+		hostinfo.relayState.InsertRelayTo(via.relayHI.vpnAddrs[0])
+	}
+	remoteCert := result.RemoteCert
+	hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
+
+	existing, err := hm.CheckAndComplete(hostinfo, handshakePacketStage0, f)
+	if err != nil {
+		hm.handleCheckAndCompleteError(err, existing, hostinfo, via, machine.Subtype(), 0)
+		return
+	}
+
+	f.l.Info("Handshake completed",
+		"vpnAddrs", hostinfo.vpnAddrs,
+		"from", via,
+		"certName", remoteCert.Certificate.Name(),
+		"certVersion", remoteCert.Certificate.Version(),
+		"fingerprint", remoteCert.Fingerprint,
+		"issuer", remoteCert.Certificate.Issuer(),
+		"initiatorIndex", result.RemoteIndex,
+		"responderIndex", result.LocalIndex,
+		"handshake", m{"stage": uint64(machine.MessageIndex()), "style": header.SubTypeName(header.Handshake, machine.Subtype())},
+		"durationNs", time.Since(hh.startTime).Nanoseconds(),
+	)
+
+	// Let the initiator know, even if there is no traffic to do it.
+	hostinfo.handshakeResendAt.Store(time.Now().UnixNano())
+	hm.sendHybridConfirmation(hostinfo)
+
+	hm.notePQPeer(machine.Subtype(), hostinfo.vpnAddrs)
+	hostinfo.remotes.RefreshFromHandshake(hostinfo.vpnAddrs)
+	if f.lightHouse.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
+		f.lightHouse.TriggerUpdate()
+	}
+}
+
+func (hm *HandshakeManager) sendHybridConfirmation(hostinfo *HostInfo) {
+	hm.f.send(header.Test, header.TestReply, hostinfo.ConnectionState, hostinfo, []byte{}, make([]byte, 12, 12), make([]byte, mtu))
+}
+
+// reconfirmHybrid answers a msg3 for a hybrid tunnel this responder already
+// completed: the initiator is still resending it, so the confirmation was lost.
+// Replies are rate limited to one per retry interval and go to the tunnel's
+// remote, never to wherever the fragment claims to come from.
+func (hm *HandshakeManager) reconfirmHybrid(index uint32) {
+	hostinfo := hm.mainHostMap.QueryIndex(index)
+	if hostinfo == nil || hostinfo.ConnectionState == nil || hostinfo.ConnectionState.initiator {
+		return
+	}
+	if pc := hostinfo.ConnectionState.peerCert; pc == nil || pc.Certificate.Version() != cert.Version3 {
+		return
+	}
+	now := time.Now().UnixNano()
+	last := hostinfo.handshakeResendAt.Load()
+	if now-last < int64(hm.config.tryInterval) || !hostinfo.handshakeResendAt.CompareAndSwap(last, now) {
+		return
+	}
+	hm.sendHybridConfirmation(hostinfo)
+}
+
+// forgetHybridResponder releases a pending responder's index.
+func (hm *HandshakeManager) forgetHybridResponder(hh *HandshakeHostInfo) {
+	hm.Lock()
+	defer hm.Unlock()
+	idx := hh.hostinfo.localIndexId
+	if hm.indexes[idx] == hh {
+		delete(hm.indexes, idx)
+	}
+	if hm.hybrid.responders[idx] == hh {
+		delete(hm.hybrid.responders, idx)
+	}
+}
+
+// isHybridResponder reports whether index belongs to a responder still waiting
+// for msg3, whose initiator may already be sending data.
+func (hm *HandshakeManager) isHybridResponder(index uint32) bool {
+	hm.RLock()
+	defer hm.RUnlock()
+	_, ok := hm.hybrid.responders[index]
+	return ok
+}
+
+// expireHybridResponders drops responders that never got their msg3.
+func (hm *HandshakeManager) expireHybridResponders(now time.Time) {
+	hm.Lock()
+	defer hm.Unlock()
+	for idx, hh := range hm.hybrid.responders {
+		if now.After(hh.responder.expires) {
+			if hm.indexes[idx] == hh {
+				delete(hm.indexes, idx)
+			}
+			delete(hm.hybrid.responders, idx)
+		}
+	}
+}
+
+// startHybridConfirm sends an initiator's msg3 and keeps resending it until the
+// responder is heard from on the new tunnel.
+func (hm *HandshakeManager) startHybridConfirm(via ViaSender, hostinfo *HostInfo, msg3 []byte, handshakeLen uint64) {
+	var h header.H
+	if err := h.Parse(msg3); err != nil {
+		return
+	}
+	s, err := fragment.NewSender(msg3[header.Len:], h.Subtype, h.RemoteIndex, h.MessageCounter, hostinfo.localIndexId)
+	if err != nil {
+		hostinfo.logger(hm.l).Error("Failed to fragment handshake", "error", err)
+		return
+	}
+
+	now := time.Now()
+	c := &hybridConfirm{
+		hostinfo:     hostinfo,
+		sender:       s,
+		send:         hm.sendTo(via),
+		handshakeLen: handshakeLen,
+		deadline:     now.Add(hsTimeout(hm.config.retries, hm.config.tryInterval)),
+		next:         now.Add(hm.config.tryInterval),
+		attempts:     1,
+	}
+	hm.hybrid.confirmsMu.Lock()
+	hm.hybrid.confirms[hostinfo] = c
+	hm.hybrid.confirmsMu.Unlock()
+	hm.sendHybridMsg3(c)
+}
+
+func (hm *HandshakeManager) sendHybridMsg3(c *hybridConfirm) {
+	pkts, later, err := c.sender.Attempt(fragment.DefaultRepair(c.sender.Layout().K))
+	if err != nil {
+		return
+	}
+	hm.messageMetrics.Tx(header.Handshake, hybridSubtype, int64(len(pkts)+len(later)))
+	if err := hm.sendFragmented(pkts, later, c.send); err != nil {
+		c.hostinfo.logger(hm.l).Debug("Failed to send handshake message", "error", err)
+	}
+}
+
+// tickHybridConfirms resends msg3 to responders not heard from yet, and closes
+// tunnels whose responder never was.
+func (hm *HandshakeManager) tickHybridConfirms(now time.Time) {
+	var resend, closing []*hybridConfirm
+	hm.hybrid.confirmsMu.Lock()
+	for hostinfo, c := range hm.hybrid.confirms {
+		switch {
+		case hm.mainHostMap.QueryIndex(hostinfo.localIndexId) != hostinfo:
+			delete(hm.hybrid.confirms, hostinfo)
+		case hostinfo.ConnectionState.heardFromPeer(c.handshakeLen):
+			delete(hm.hybrid.confirms, hostinfo)
+		case now.After(c.deadline):
+			delete(hm.hybrid.confirms, hostinfo)
+			closing = append(closing, c)
+		case !now.Before(c.next):
+			c.attempts++
+			c.next = now.Add(hm.config.tryInterval * time.Duration(c.attempts))
+			resend = append(resend, c)
+		}
+	}
+	hm.hybrid.confirmsMu.Unlock()
+
+	for _, c := range resend {
+		if hm.l.Enabled(context.Background(), slog.LevelDebug) {
+			c.hostinfo.logger(hm.l).Debug("Resending handshake message, responder not heard from yet", "attempts", c.attempts)
+		}
+		hm.sendHybridMsg3(c)
+	}
+	for _, c := range closing {
+		c.hostinfo.logger(hm.l).Info("Responder never confirmed the handshake, closing tunnel",
+			"localIndex", c.hostinfo.localIndexId, "remoteIndex", c.hostinfo.remoteIndexId)
+		hm.f.closeTunnel(c.hostinfo)
+	}
+}

+ 45 - 15
handshake_manager_pq.go

@@ -12,6 +12,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/handshake"
 	"github.com/slackhq/nebula/handshake/fragment"
@@ -19,9 +20,6 @@ import (
 )
 
 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
 
@@ -91,6 +89,7 @@ func (hm *HandshakeManager) reloadPQ(c *config.C) {
 // local index, so whichever answer arrives first wins.
 type pqAttempt struct {
 	machine        *handshake.Machine
+	subtype        header.MessageSubType
 	index          uint32
 	sender         *fragment.Sender
 	msg2           *fragment.Assembly
@@ -145,7 +144,7 @@ func reassembled(h *header.H, msg []byte) []byte {
 func (hm *HandshakeManager) usePQ(hh *HandshakeHostInfo, vpnIp netip.Addr, triggered bool) bool {
 	cfg := hm.pqConfig()
 	switch {
-	case !cfg.initiate:
+	case !cfg.initiate, hh.noPQ:
 		return false
 	case cfg.require:
 		return true
@@ -199,21 +198,27 @@ func (hm *HandshakeManager) relayEstablished(target netip.Addr, relayHI *HostInf
 	})
 }
 
-// buildPQAttempt creates the initiator's PQ Machine and msg1 sender.
+// buildPQAttempt creates the initiator's PQ Machine and msg1 sender. A node with
+// a v3 certificate speaks the hybrid handshake, anyone else the ephemeral KEM one.
 func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool {
 	cs := hm.f.pki.getCertState()
 	v := hm.initiatingVersion(hh, cs)
+	subtype := header.HandshakeIXPSK2MLKEM1024
+	if cs.GetCredential(cert.Version3) != nil {
+		v = cert.Version3
+		subtype = hybridSubtype
+	}
 	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{}
+	pq := &pqAttempt{subtype: subtype}
 	machine, err := handshake.NewMachine(
 		v, cs.GetCredential,
 		hm.certVerifier(), func() (uint32, error) { return hm.allocatePQIndex(hh, pq) },
-		true, pqSubtype,
+		true, subtype,
 	)
 	if err != nil {
 		hm.f.l.Error("Failed to create handshake machine",
@@ -229,7 +234,7 @@ func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool {
 		return false
 	}
 
-	sender, err := fragment.NewSender(msg[header.Len:], pqSubtype, 0, 1, pq.index)
+	sender, err := fragment.NewSender(msg[header.Len:], subtype, 0, 1, pq.index)
 	if err != nil {
 		hm.f.l.Error("Failed to fragment handshake",
 			"vpnAddrs", hh.hostinfo.vpnAddrs, "error", err)
@@ -271,6 +276,14 @@ func (hm *HandshakeManager) allocatePQIndex(hh *HandshakeHostInfo, pq *pqAttempt
 	return 0, errors.New("failed to generate unique localIndexId")
 }
 
+// abandonPQ drops a failed PQ attempt, leaving classical attempts only. The
+// caller must hold hh.Lock.
+func (hm *HandshakeManager) abandonPQ(hh *HandshakeHostInfo) {
+	hm.releasePQIndex(hh, hh.pq)
+	hh.pq = nil
+	hh.noPQ = true
+}
+
 func (hm *HandshakeManager) releasePQIndex(hh *HandshakeHostInfo, pq *pqAttempt) {
 	hm.Lock()
 	defer hm.Unlock()
@@ -314,10 +327,22 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 
 	switch h.MessageCounter {
 	case 1:
-		if !hm.pqConfig().accept {
+		cfg := hm.pqConfig()
+		if !cfg.accept {
 			hm.l.Debug("dropping post-quantum handshake, not accepting them", "from", via)
 			return
 		}
+		if h.Subtype == hybridSubtype || cfg.require {
+			hasV3 := hm.f.pki.getCertState().GetCredential(cert.Version3) != nil
+			if h.Subtype == hybridSubtype && !hasV3 {
+				hm.l.Debug("dropping hybrid handshake, no v3 certificate to answer it with", "from", via)
+				return
+			}
+			if h.Subtype != hybridSubtype && hasV3 {
+				hm.l.Debug("dropping post-quantum handshake, hybrid handshakes are required", "from", via)
+				return
+			}
+		}
 		if h.RemoteIndex != 0 {
 			hm.l.Debug("dropping stage-1 handshake with non-zero RemoteIndex",
 				"from", via, "remoteIndex", h.RemoteIndex)
@@ -326,7 +351,7 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 		now := time.Now()
 		key, src := pqFragKeyFor(via, fh.FragID)
 		if hm.pqTable.Load() >= pqCookieLoad && !(fh.HasCookie() && hm.pqCookies.valid(key, fh.Cookie, now)) {
-			hm.sendCookie(via, fh, key, len(packet), now)
+			hm.sendCookie(via, h.Subtype, fh, key, len(packet), now)
 			return
 		}
 		res, err := hm.pqTable.Add(key, src, now, fh, sym)
@@ -350,6 +375,11 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 		if hh := hm.queryIndex(h.RemoteIndex); hh != nil {
 			hm.continuePQ(via, hh, h, fh, sym)
 		}
+
+	case 3:
+		if h.Subtype == hybridSubtype {
+			hm.continueHybridResponder(via, h, fh, sym)
+		}
 	}
 }
 
@@ -477,7 +507,7 @@ func (hm *HandshakeManager) sendFragmentedResponse(via ViaSender, msg []byte, ho
 
 // notePQPeer remembers peers that completed a PQ handshake, for sticky mode.
 func (hm *HandshakeManager) notePQPeer(subtype header.MessageSubType, vpnAddrs []netip.Addr) {
-	if !hm.pqConfig().sticky || subtype != pqSubtype {
+	if !hm.pqConfig().sticky || !header.IsFragmented(subtype) {
 		return
 	}
 	for _, a := range vpnAddrs {
@@ -625,7 +655,7 @@ func (c *pqCookies) valid(key pqFragKey, cookie fragment.Cookie, now time.Time)
 // sendCookie answers a msg1 fragment that arrived without a valid cookie while
 // under load. The reply is never larger than what triggered it, and replies are
 // rate limited as a whole.
-func (hm *HandshakeManager) sendCookie(via ViaSender, fh fragment.Header, key pqFragKey, received int, now time.Time) {
+func (hm *HandshakeManager) sendCookie(via ViaSender, subtype header.MessageSubType, fh fragment.Header, key pqFragKey, received int, now time.Time) {
 	if received < pqCookieReplyLen {
 		return
 	}
@@ -634,9 +664,9 @@ func (hm *HandshakeManager) sendCookie(via ViaSender, fh fragment.Header, key pq
 	}
 	c := hm.pqCookies.make(key, now)
 	reply := make([]byte, header.Len, pqCookieReplyLen)
-	header.Encode(reply, header.Version, header.Handshake, pqSubtype, fh.FragID, pqCookieCounter)
+	header.Encode(reply, header.Version, header.Handshake, subtype, fh.FragID, pqCookieCounter)
 	reply = append(reply, c[:]...)
-	hm.f.messageMetrics.Tx(header.Handshake, pqSubtype, 1)
+	hm.f.messageMetrics.Tx(header.Handshake, subtype, 1)
 	if err := hm.sendTo(via)(reply); err != nil {
 		hm.l.Debug("Failed to send handshake cookie", "to", via, "error", err)
 	}
@@ -674,7 +704,7 @@ func (hm *HandshakeManager) handleCookie(via ViaSender, packet []byte, h *header
 	if err != nil {
 		return
 	}
-	hm.messageMetrics.Tx(header.Handshake, pqSubtype, int64(len(pkts)+len(later)))
+	hm.messageMetrics.Tx(header.Handshake, pq.subtype, int64(len(pkts)+len(later)))
 	if err := hm.sendFragmented(pkts, later, hm.sendTo(via)); err != nil {
 		hm.l.Debug("Failed to resend handshake with cookie", "to", via, "error", err)
 	}

+ 9 - 9
handshake_manager_pq_test.go

@@ -62,7 +62,7 @@ func fragmentIndexes(t *testing.T, pkts [][]byte, fragID, remoteIndex uint32) []
 		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, header.HandshakeIXPSK2MLKEM1024, h.Subtype)
 		assert.Equal(t, remoteIndex, h.RemoteIndex)
 		assert.Equal(t, uint64(2), h.MessageCounter)
 		fh, _, err := fragment.Parse(p[header.Len:])
@@ -80,7 +80,7 @@ func TestSendFragmentedResponse(t *testing.T) {
 
 	// 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)
+	header.Encode(msg, header.Version, header.Handshake, header.HandshakeIXPSK2MLKEM1024, 5, 2)
 	r := rand.New(rand.NewPCG(1, 1))
 	for range 2000 {
 		msg = append(msg, byte(r.Uint32()))
@@ -183,7 +183,7 @@ func TestPQCookies(t *testing.T) {
 // pqMsg1Fragment builds the first fragment of a msg1, optionally with a cookie.
 func pqMsg1Fragment(t *testing.T, fragID uint32, cookie *fragment.Cookie) ([]byte, *header.H) {
 	t.Helper()
-	s, err := fragment.NewSender(make([]byte, 2000), pqSubtype, 0, 1, fragID)
+	s, err := fragment.NewSender(make([]byte, 2000), header.HandshakeIXPSK2MLKEM1024, 0, 1, fragID)
 	require.NoError(t, err)
 	if cookie != nil {
 		s.SetCookie(*cookie)
@@ -238,7 +238,7 @@ func TestPQCookieUnderLoad(t *testing.T) {
 		var rh header.H
 		require.NoError(t, rh.Parse(reply))
 		assert.Equal(t, header.Handshake, rh.Type)
-		assert.Equal(t, pqSubtype, rh.Subtype)
+		assert.Equal(t, header.HandshakeIXPSK2MLKEM1024, rh.Subtype)
 		assert.Equal(t, uint32(7), rh.RemoteIndex, "the reply names the initiator's FragID")
 		assert.Equal(t, uint64(pqCookieCounter), rh.MessageCounter)
 		copy(cookie[:], reply[header.Len:])
@@ -270,7 +270,7 @@ func TestPQCookieUnderLoad(t *testing.T) {
 
 	t.Run("a reply never outgrows what prompted it", func(t *testing.T) {
 		p, h := pqMsg1Fragment(t, 8, nil)
-		hm.sendCookie(via, fragment.Header{FragID: 8}, pqFragKey{}, pqCookieReplyLen-1, time.Now())
+		hm.sendCookie(via, header.HandshakeIXPSK2MLKEM1024, fragment.Header{FragID: 8}, pqFragKey{}, pqCookieReplyLen-1, time.Now())
 		assert.Empty(t, conn.take())
 		hm.handleFragment(via, p, h)
 		assert.Len(t, conn.take(), 1)
@@ -281,7 +281,7 @@ func TestPQHandleCookie(t *testing.T) {
 	hm, conn := newPQTestManager(t)
 	via := ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.1:4242")}
 
-	s, err := fragment.NewSender(make([]byte, 2000), pqSubtype, 0, 1, 99)
+	s, err := fragment.NewSender(make([]byte, 2000), header.HandshakeIXPSK2MLKEM1024, 0, 1, 99)
 	require.NoError(t, err)
 	_, _, err = s.Attempt(2)
 	require.NoError(t, err)
@@ -289,7 +289,7 @@ func TestPQHandleCookie(t *testing.T) {
 	hm.indexes[99] = hh
 
 	reply := make([]byte, header.Len, pqCookieReplyLen)
-	header.Encode(reply, header.Version, header.Handshake, pqSubtype, 99, pqCookieCounter)
+	header.Encode(reply, header.Version, header.Handshake, header.HandshakeIXPSK2MLKEM1024, 99, pqCookieCounter)
 	reply = append(reply, bytes16(0xab)...)
 	var h header.H
 	require.NoError(t, h.Parse(reply))
@@ -311,7 +311,7 @@ func TestPQHandleCookie(t *testing.T) {
 
 	t.Run("replies for other indexes are ignored", func(t *testing.T) {
 		other := append([]byte(nil), reply...)
-		header.Encode(other, header.Version, header.Handshake, pqSubtype, 98, pqCookieCounter)
+		header.Encode(other, header.Version, header.Handshake, header.HandshakeIXPSK2MLKEM1024, 98, pqCookieCounter)
 		var oh header.H
 		require.NoError(t, oh.Parse(other))
 		hh.pq.cookieResendAt = time.Time{}
@@ -393,6 +393,6 @@ func TestUsePQ(t *testing.T) {
 
 	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})
+	hm.notePQPeer(header.HandshakeIXPSK2MLKEM1024, []netip.Addr{peer})
 	assert.Equal(t, []bool{true, true, true}, attempts(false, false, false), "then only PQ")
 }

+ 3 - 1
outside.go

@@ -101,7 +101,9 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
 	// At this point we should have a valid existing tunnel, verify and send
 	// recvError if necessary
 	if hostinfo == nil || hostinfo.ConnectionState == nil {
-		if !via.IsRelayed {
+		// A hybrid responder waiting for msg3 already gets data from an initiator
+		// that has its keys. A RecvError would tear that new tunnel down.
+		if !via.IsRelayed && !f.handshakeManager.isHybridResponder(h.RemoteIndex) {
 			f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex)
 		}
 		return