Browse Source

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

JackDoan 6 days ago
parent
commit
b26c1e9c3e

+ 8 - 0
connection_state.go

@@ -92,6 +92,14 @@ func (cs *ConnectionState) NextMessageCounter() (uint64, bool) {
 	return c, true
 	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 {
 func (cs *ConnectionState) Curve() cert.Curve {
 	return cs.myCert.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
 	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
 // MessageIndex returns the noise handshake message index, which equals the
 // wire counter of the most recently sent or received message.
 // wire counter of the most recently sent or received message.
 func (m *Machine) MessageIndex() int {
 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 and pqCookieLimiter serve cookies while pqTable is under load.
 	pqCookies       *pqCookies
 	pqCookies       *pqCookies
 	pqCookieLimiter *ratelimit.Limiter
 	pqCookieLimiter *ratelimit.Limiter
+	// hybrid holds hybrid handshakes waiting on their third message.
+	hybrid hybridState
 }
 }
 
 
 type HandshakeHostInfo struct {
 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)
 	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
 	pq       *pqAttempt         // The initiator's post-quantum attempt, pending alongside the classical one
 	lastPQ   bool               // Whether the latest attempt was post-quantum
 	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) {
 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,
 		l:                      l,
 		pqTable:                fragment.NewTable[pqFragKey, pqFragSource](pqTableConfig),
 		pqTable:                fragment.NewTable[pqFragKey, pqFragSource](pqTableConfig),
 		pqAnswered:             newPQAnswered(hsTimeout(config.retries, config.tryInterval)),
 		pqAnswered:             newPQAnswered(hsTimeout(config.retries, config.tryInterval)),
+		hybrid:                 newHybridState(),
 	}
 	}
 	pq := config.pq
 	pq := config.pq
 	hm.pq.Store(&pq)
 	hm.pq.Store(&pq)
@@ -174,6 +180,8 @@ func (hm *HandshakeManager) Run(ctx context.Context) {
 			hm.NextOutboundHandshakeTimerTick(now)
 			hm.NextOutboundHandshakeTimerTick(now)
 			hm.pqTable.Expire(now)
 			hm.pqTable.Expire(now)
 			hm.pqAnswered.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
 	// don't yet support) are dropped here rather than silently routed through
 	// the IX path. Add a case when introducing a new pattern.
 	// the IX path. Add a case when introducing a new pattern.
 	switch h.Subtype {
 	switch h.Subtype {
-	case header.HandshakeIXPSK0, pqSubtype:
+	case header.HandshakeIXPSK0, header.HandshakeIXPSK2MLKEM1024, hybridSubtype:
 		// supported
 		// supported
 	default:
 	default:
 		hm.l.Debug("dropping handshake with unsupported subtype",
 		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()
 	cs := f.pki.getCertState()
 
 
 	v := cs.DefaultVersion()
 	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 {
 	if cs.GetCredential(v) == nil {
 		f.l.Error("Unable to handshake with host because no certificate is available",
 		f.l.Error("Unable to handshake with host because no certificate is available",
 			"from", via, "certVersion", v)
 			"from", via, "certVersion", v)
@@ -783,7 +813,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 
 
 	machine, err := handshake.NewMachine(
 	machine, err := handshake.NewMachine(
 		v, cs.GetCredential,
 		v, cs.GetCredential,
-		hm.certVerifier(), func() (uint32, error) { return generateIndex(f.l) },
+		hm.certVerifier(), allocIndex,
 		false, h.Subtype,
 		false, h.Subtype,
 	)
 	)
 	if err != nil {
 	if err != nil {
@@ -793,10 +823,18 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 
 
 	response, result, err := machine.ProcessPacket(nil, packet)
 	response, result, err := machine.ProcessPacket(nil, packet)
 	if err != nil {
 	if err != nil {
+		if pending != nil {
+			hm.DeleteHostInfo(pending.hostinfo)
+		}
 		f.l.Error("Failed to process handshake packet", "from", via, "error", err)
 		f.l.Error("Failed to process handshake packet", "from", via, "error", err)
 		return
 		return
 	}
 	}
 
 
+	if result == nil && pending != nil {
+		hm.awaitMsg3(via, pending, machine, packet, response, received)
+		return
+	}
+
 	if result == nil {
 	if result == nil {
 		// Multi-message pattern: the responder Machine would need to be
 		// Multi-message pattern: the responder Machine would need to be
 		// registered in hm.indexes so a future inbound packet finds it via
 		// 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)
 	response, result, err := machine.ProcessPacket(nil, packet)
 	if err != nil {
 	if err != nil {
 		// Recoverable errors are routine noise, log at Debug. Fatal errors get a Warn.
 		// 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)
 				"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 {
 		} 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)
 				"vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err)
+			hm.DeleteHostInfo(hostinfo)
 		}
 		}
 		return
 		return
 	}
 	}
 
 
-	if response != nil {
-		hm.sendHandshakeResponse(via, response, hostinfo, false, 0)
-	}
-
 	if result == nil {
 	if result == nil {
+		if response != nil {
+			hm.sendHandshakeResponse(via, response, hostinfo, false, 0)
+		}
 		return
 		return
 	}
 	}
 
 
@@ -1068,6 +1112,12 @@ func (hm *HandshakeManager) processResponse(via ViaSender, hh *HandshakeHostInfo
 	hm.Complete(hostinfo, f)
 	hm.Complete(hostinfo, f)
 	hm.notePQPeer(machine.Subtype(), vpnAddrs)
 	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 len(hh.packetStore) > 0 {
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
 		if f.l.Enabled(context.Background(), slog.LevelDebug) {
 			hostinfo.logger(f.l).Debug("Sending stored packets", "count", len(hh.packetStore))
 			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,
 		"initiatorIndex", hostinfo.remoteIndexId,
 		"responderIndex", hostinfo.localIndexId,
 		"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,
 		logFields = append(logFields,
 			"certName", peerCert.Certificate.Name(),
 			"certName", peerCert.Certificate.Name(),
 			"certVersion", peerCert.Certificate.Version(),
 			"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"
 	"sync"
 	"time"
 	"time"
 
 
+	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/handshake"
 	"github.com/slackhq/nebula/handshake"
 	"github.com/slackhq/nebula/handshake/fragment"
 	"github.com/slackhq/nebula/handshake/fragment"
@@ -19,9 +20,6 @@ import (
 )
 )
 
 
 const (
 const (
-	// pqSubtype is the post-quantum handshake this node speaks.
-	pqSubtype = header.HandshakeIXPSK2MLKEM1024
-
 	// DefaultPQRepairDelay is how long repair fragments trail the data fragments.
 	// DefaultPQRepairDelay is how long repair fragments trail the data fragments.
 	DefaultPQRepairDelay = 10 * time.Millisecond
 	DefaultPQRepairDelay = 10 * time.Millisecond
 
 
@@ -91,6 +89,7 @@ func (hm *HandshakeManager) reloadPQ(c *config.C) {
 // local index, so whichever answer arrives first wins.
 // local index, so whichever answer arrives first wins.
 type pqAttempt struct {
 type pqAttempt struct {
 	machine        *handshake.Machine
 	machine        *handshake.Machine
+	subtype        header.MessageSubType
 	index          uint32
 	index          uint32
 	sender         *fragment.Sender
 	sender         *fragment.Sender
 	msg2           *fragment.Assembly
 	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 {
 func (hm *HandshakeManager) usePQ(hh *HandshakeHostInfo, vpnIp netip.Addr, triggered bool) bool {
 	cfg := hm.pqConfig()
 	cfg := hm.pqConfig()
 	switch {
 	switch {
-	case !cfg.initiate:
+	case !cfg.initiate, hh.noPQ:
 		return false
 		return false
 	case cfg.require:
 	case cfg.require:
 		return true
 		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 {
 func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool {
 	cs := hm.f.pki.getCertState()
 	cs := hm.f.pki.getCertState()
 	v := hm.initiatingVersion(hh, cs)
 	v := hm.initiatingVersion(hh, cs)
+	subtype := header.HandshakeIXPSK2MLKEM1024
+	if cs.GetCredential(cert.Version3) != nil {
+		v = cert.Version3
+		subtype = hybridSubtype
+	}
 	if cs.GetCredential(v) == nil {
 	if cs.GetCredential(v) == nil {
 		hm.f.l.Error("Unable to handshake with host because no certificate is available",
 		hm.f.l.Error("Unable to handshake with host because no certificate is available",
 			"vpnAddrs", hh.hostinfo.vpnAddrs, "certVersion", v)
 			"vpnAddrs", hh.hostinfo.vpnAddrs, "certVersion", v)
 		return false
 		return false
 	}
 	}
 
 
-	pq := &pqAttempt{}
+	pq := &pqAttempt{subtype: subtype}
 	machine, err := handshake.NewMachine(
 	machine, err := handshake.NewMachine(
 		v, cs.GetCredential,
 		v, cs.GetCredential,
 		hm.certVerifier(), func() (uint32, error) { return hm.allocatePQIndex(hh, pq) },
 		hm.certVerifier(), func() (uint32, error) { return hm.allocatePQIndex(hh, pq) },
-		true, pqSubtype,
+		true, subtype,
 	)
 	)
 	if err != nil {
 	if err != nil {
 		hm.f.l.Error("Failed to create handshake machine",
 		hm.f.l.Error("Failed to create handshake machine",
@@ -229,7 +234,7 @@ func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool {
 		return false
 		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 {
 	if err != nil {
 		hm.f.l.Error("Failed to fragment handshake",
 		hm.f.l.Error("Failed to fragment handshake",
 			"vpnAddrs", hh.hostinfo.vpnAddrs, "error", err)
 			"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")
 	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) {
 func (hm *HandshakeManager) releasePQIndex(hh *HandshakeHostInfo, pq *pqAttempt) {
 	hm.Lock()
 	hm.Lock()
 	defer hm.Unlock()
 	defer hm.Unlock()
@@ -314,10 +327,22 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 
 
 	switch h.MessageCounter {
 	switch h.MessageCounter {
 	case 1:
 	case 1:
-		if !hm.pqConfig().accept {
+		cfg := hm.pqConfig()
+		if !cfg.accept {
 			hm.l.Debug("dropping post-quantum handshake, not accepting them", "from", via)
 			hm.l.Debug("dropping post-quantum handshake, not accepting them", "from", via)
 			return
 			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 {
 		if h.RemoteIndex != 0 {
 			hm.l.Debug("dropping stage-1 handshake with non-zero RemoteIndex",
 			hm.l.Debug("dropping stage-1 handshake with non-zero RemoteIndex",
 				"from", via, "remoteIndex", h.RemoteIndex)
 				"from", via, "remoteIndex", h.RemoteIndex)
@@ -326,7 +351,7 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 		now := time.Now()
 		now := time.Now()
 		key, src := pqFragKeyFor(via, fh.FragID)
 		key, src := pqFragKeyFor(via, fh.FragID)
 		if hm.pqTable.Load() >= pqCookieLoad && !(fh.HasCookie() && hm.pqCookies.valid(key, fh.Cookie, now)) {
 		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
 			return
 		}
 		}
 		res, err := hm.pqTable.Add(key, src, now, fh, sym)
 		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 {
 		if hh := hm.queryIndex(h.RemoteIndex); hh != nil {
 			hm.continuePQ(via, hh, h, fh, sym)
 			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.
 // notePQPeer remembers peers that completed a PQ handshake, for sticky mode.
 func (hm *HandshakeManager) notePQPeer(subtype header.MessageSubType, vpnAddrs []netip.Addr) {
 func (hm *HandshakeManager) notePQPeer(subtype header.MessageSubType, vpnAddrs []netip.Addr) {
-	if !hm.pqConfig().sticky || subtype != pqSubtype {
+	if !hm.pqConfig().sticky || !header.IsFragmented(subtype) {
 		return
 		return
 	}
 	}
 	for _, a := range vpnAddrs {
 	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
 // 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
 // under load. The reply is never larger than what triggered it, and replies are
 // rate limited as a whole.
 // 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 {
 	if received < pqCookieReplyLen {
 		return
 		return
 	}
 	}
@@ -634,9 +664,9 @@ func (hm *HandshakeManager) sendCookie(via ViaSender, fh fragment.Header, key pq
 	}
 	}
 	c := hm.pqCookies.make(key, now)
 	c := hm.pqCookies.make(key, now)
 	reply := make([]byte, header.Len, pqCookieReplyLen)
 	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[:]...)
 	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 {
 	if err := hm.sendTo(via)(reply); err != nil {
 		hm.l.Debug("Failed to send handshake cookie", "to", via, "error", err)
 		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 {
 	if err != nil {
 		return
 		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 {
 	if err := hm.sendFragmented(pkts, later, hm.sendTo(via)); err != nil {
 		hm.l.Debug("Failed to resend handshake with cookie", "to", via, "error", err)
 		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)
 		require.LessOrEqual(t, len(p), fragment.MaxPacketLen)
 		var h header.H
 		var h header.H
 		require.NoError(t, h.Parse(p))
 		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, remoteIndex, h.RemoteIndex)
 		assert.Equal(t, uint64(2), h.MessageCounter)
 		assert.Equal(t, uint64(2), h.MessageCounter)
 		fh, _, err := fragment.Parse(p[header.Len:])
 		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.
 	// A 2000 byte message is 2 symbols of 1000, so packets of 1024 bytes.
 	msg := make([]byte, header.Len, header.Len+2000)
 	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))
 	r := rand.New(rand.NewPCG(1, 1))
 	for range 2000 {
 	for range 2000 {
 		msg = append(msg, byte(r.Uint32()))
 		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.
 // 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) {
 func pqMsg1Fragment(t *testing.T, fragID uint32, cookie *fragment.Cookie) ([]byte, *header.H) {
 	t.Helper()
 	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)
 	require.NoError(t, err)
 	if cookie != nil {
 	if cookie != nil {
 		s.SetCookie(*cookie)
 		s.SetCookie(*cookie)
@@ -238,7 +238,7 @@ func TestPQCookieUnderLoad(t *testing.T) {
 		var rh header.H
 		var rh header.H
 		require.NoError(t, rh.Parse(reply))
 		require.NoError(t, rh.Parse(reply))
 		assert.Equal(t, header.Handshake, rh.Type)
 		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, uint32(7), rh.RemoteIndex, "the reply names the initiator's FragID")
 		assert.Equal(t, uint64(pqCookieCounter), rh.MessageCounter)
 		assert.Equal(t, uint64(pqCookieCounter), rh.MessageCounter)
 		copy(cookie[:], reply[header.Len:])
 		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) {
 	t.Run("a reply never outgrows what prompted it", func(t *testing.T) {
 		p, h := pqMsg1Fragment(t, 8, nil)
 		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())
 		assert.Empty(t, conn.take())
 		hm.handleFragment(via, p, h)
 		hm.handleFragment(via, p, h)
 		assert.Len(t, conn.take(), 1)
 		assert.Len(t, conn.take(), 1)
@@ -281,7 +281,7 @@ func TestPQHandleCookie(t *testing.T) {
 	hm, conn := newPQTestManager(t)
 	hm, conn := newPQTestManager(t)
 	via := ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.1:4242")}
 	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)
 	require.NoError(t, err)
 	_, _, err = s.Attempt(2)
 	_, _, err = s.Attempt(2)
 	require.NoError(t, err)
 	require.NoError(t, err)
@@ -289,7 +289,7 @@ func TestPQHandleCookie(t *testing.T) {
 	hm.indexes[99] = hh
 	hm.indexes[99] = hh
 
 
 	reply := make([]byte, header.Len, pqCookieReplyLen)
 	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)...)
 	reply = append(reply, bytes16(0xab)...)
 	var h header.H
 	var h header.H
 	require.NoError(t, h.Parse(reply))
 	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) {
 	t.Run("replies for other indexes are ignored", func(t *testing.T) {
 		other := append([]byte(nil), reply...)
 		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
 		var oh header.H
 		require.NoError(t, oh.Parse(other))
 		require.NoError(t, oh.Parse(other))
 		hh.pq.cookieResendAt = time.Time{}
 		hh.pq.cookieResendAt = time.Time{}
@@ -393,6 +393,6 @@ func TestUsePQ(t *testing.T) {
 
 
 	set(pqHandshakeConfig{initiate: true, sticky: true})
 	set(pqHandshakeConfig{initiate: true, sticky: true})
 	assert.Equal(t, []bool{true, false}, attempts(false, false), "sticky alternates until the peer has done PQ")
 	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")
 	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
 	// At this point we should have a valid existing tunnel, verify and send
 	// recvError if necessary
 	// recvError if necessary
 	if hostinfo == nil || hostinfo.ConnectionState == nil {
 	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)
 			f.maybeSendRecvError(via.UdpAddr, h.RemoteIndex)
 		}
 		}
 		return
 		return