From 5edcaefa33fb960aae28f233b6ac2dc2a43982d9 Mon Sep 17 00:00:00 2001 From: JackDoan Date: Wed, 14 Oct 2026 12:56:12 -0500 Subject: [PATCH] e2e: Back to back hybrid handshakes, and a classical attempt crossing a hybrid one --- e2e/pq_hybrid_test.go | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/e2e/pq_hybrid_test.go b/e2e/pq_hybrid_test.go index 9c15bd1a..97c43cd9 100644 --- a/e2e/pq_hybrid_test.go +++ b/e2e/pq_hybrid_test.go @@ -397,3 +397,107 @@ func TestHybridRelays(t *testing.T) { relayControl.Stop() theirControl.Stop() } + +// TestHybridBackToBack rebuilds the tunnel more times than the responder's +// per-source quota allows messages in progress, faster than its table expires +// entries. The repair fragments trailing each completed msg1 must not count +// against that quota, or later handshakes fall back to classical. +func TestHybridBackToBack(t *testing.T) { + t.Parallel() + myControl, theirControl, myVpn, theirVpn, _, theirUdp, r := newHybridPair(t, pqConfig(true, true, false), pqConfig(false, true, false)) + defer r.RenderFlow() + + for i := range 6 { + // Closing the last tunnel forgets the peer's address. + myControl.InjectLightHouseAddr(theirVpn[0].Addr(), theirUdp) + 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, nil), "tunnel %d never came up", i) + theirControl.GetFromTun(true) + // Let the trailing repair fragments land before tearing down. + r.RouteForAllExitFuncOrTimeout(50*time.Millisecond, func(*udp.Packet, *nebula.Control) router.ExitType { return router.KeepRouting }) + + mine := myControl.GetHostInfoByVpnAddr(theirVpn[0].Addr(), false) + theirs := theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false) + require.NotNil(t, mine) + require.NotNil(t, theirs) + assert.Equal(t, cert.Version3, mine.Cert.Version(), "tunnel %d is hybrid", i) + assert.Equal(t, cert.Version3, theirs.Cert.Version(), "tunnel %d is hybrid", i) + + myControl.CloseTunnel(theirVpn[0].Addr(), true) + theirControl.CloseTunnel(myVpn[0].Addr(), true) + } + + myControl.Stop() + theirControl.Stop() +} + +// TestHybridRaceWithClassical has the initiator's classical attempt reach the +// responder, which completes it at once, while the hybrid handshake is still +// waiting for msg3. The initiator gets the hybrid msg2 first and commits to it, +// so the responder must take the hybrid tunnel when msg3 arrives even though the +// classical one is newer. +func TestHybridRaceWithClassical(t *testing.T) { + t.Parallel() + // Both trust the v2 form of the CA too, or the classical attempt could not complete. + ca := newHybridCA(t) + ca2, _ := ca.v2() + myControl, theirControl, myVpn, theirVpn, _, _, r := newHybridPairWithCA(t, ca, + withCA(pqConfig(true, true, false), ca.ca, ca2), + withCA(pqConfig(false, true, false), ca.ca, ca2), + ) + defer r.RenderFlow() + + var heldMsg2 []*udp.Packet + var sawClassical, recvErrors int + myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me"))) + require.True(t, r.RouteForAllExitFuncOrTimeout(3*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType { + h, fh := handshakeOf(p) + switch { + case h != nil && fh != nil && h.MessageCounter == 2: + // Hold the hybrid msg2 back until the classical attempt has gone through. + heldMsg2 = append(heldMsg2, p.Copy()) + return router.Drop + case h != nil && fh == nil && h.MessageCounter == 1: + sawClassical++ + return router.RouteAndExit + } + return router.KeepRouting + }), "no classical attempt") + require.Positive(t, sawClassical) + + // Lose the classical msg2, so the initiator only ever completes the hybrid handshake. + r.RouteForAllExitFuncOrTimeout(50*time.Millisecond, func(p *udp.Packet, receiver *nebula.Control) router.ExitType { + if h, fh := handshakeOf(p); h != nil && fh == nil && h.MessageCounter == 2 { + return router.Drop + } + if h, fh := handshakeOf(p); h != nil && fh != nil && h.MessageCounter == 2 { + heldMsg2 = append(heldMsg2, p.Copy()) + return router.Drop + } + return router.KeepRouting + }) + require.NotNil(t, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false), "the responder completed the classical handshake") + + for _, p := range heldMsg2 { + r.InjectUDPPacket(theirControl, myControl, p) + } + require.True(t, routeUntilData(t, r, theirControl, 3*time.Second, func(p *udp.Packet, _ *nebula.Control) router.ExitType { + if isType(p, header.RecvError) { + recvErrors++ + } + return router.KeepRouting + }), "data never arrived") + assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80) + + assert.Zero(t, recvErrors) + mine := myControl.GetHostInfoByVpnAddr(theirVpn[0].Addr(), false) + theirs := theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false) + require.NotNil(t, mine) + require.NotNil(t, theirs) + assert.Equal(t, cert.Version3, mine.Cert.Version()) + assert.Equal(t, cert.Version3, theirs.Cert.Version(), "the responder's primary tunnel is the hybrid one") + assert.Equal(t, mine.LocalIndex, theirs.RemoteIndex) + + myControl.Stop() + theirControl.Stop() +}