Make post-quantum handshakes hybrid only: drop ix_psk2_mlkem1024, need a v3 certificate, and leave a classical msg1 crossing a hybrid one unanswered

This commit is contained in:
JackDoan committed 2026-10-14 14:58:29 -05:00
1 parent 656164b259
commit dff5c51dd6
18 files changed
+337 -609

No files matched your search

+13 -93
View File
@@ -10,7 +10,6 @@ import (
"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"
@@ -79,7 +78,7 @@ func (ht *handshakeTally) observe(p *udp.Packet) {
}
ht.Lock()
defer ht.Unlock()
if h.Subtype == header.HandshakeIXPSK2MLKEM1024 {
if h.Subtype == header.HandshakeIXHybridMLKEM1024 {
ht.pq++
ht.maxPQLen = max(ht.maxPQLen, len(p.Data))
} else {
@@ -102,11 +101,14 @@ func isTunnelData(p *udp.Packet, receiver, to *nebula.Control) bool {
return h.Type == header.Message && h.Subtype == header.MessageNone
}
// newPQPair builds two hosts with v3 certificates that also trust the CA's v2
// form, so a classical handshake can complete when the hybrid one does not.
func newPQPair(t *testing.T, mine, theirs m) (myControl, theirControl *nebula.Control, myVpn, theirVpn []netip.Prefix, myUdp, theirUdp netip.AddrPort, r *router.R) {
t.Helper()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
myControl, myVpn, myUdp, _ = newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.1/24", mine)
theirControl, theirVpn, theirUdp, _ = newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.2/24", theirs)
ca := newHybridCA(t)
ca2, _ := ca.v2()
myControl, myVpn, myUdp, _ = newSimpleServer(cert.Version3, ca.ca, ca.key, "me", "10.128.0.1/24", withCA(mine, ca.ca, ca2))
theirControl, theirVpn, theirUdp, _ = newSimpleServer(cert.Version3, ca.ca, ca.key, "them", "10.128.0.2/24", withCA(theirs, ca.ca, ca2))
myControl.InjectLightHouseAddr(theirVpn[0].Addr(), theirUdp)
r = router.NewR(t, myControl, theirControl)
myControl.Start()
@@ -114,36 +116,6 @@ func newPQPair(t *testing.T, mine, theirs m) (myControl, theirControl *nebula.Co
return
}
func TestPQHandshake(t *testing.T) {
t.Parallel()
// They only accept, which is all a PQ initiator needs.
myControl, theirControl, myVpn, theirVpn, myUdp, theirUdp, r := newPQPair(t, pqConfig(true, true, false), pqConfig(false, true, false))
defer r.RenderFlow()
var tally handshakeTally
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
require.True(t, r.RouteForAllExitFuncOrTimeout(5*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
tally.observe(p)
if isTunnelData(p, receiver, theirControl) {
return router.RouteAndExit
}
return router.KeepRouting
}), "tunnel never came up")
assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
assert.Zero(t, tally.classical, "no classical handshake should be needed")
assert.GreaterOrEqual(t, tally.pq, 4, "both messages travel as several fragments")
assert.LessOrEqual(t, tally.maxPQLen, fragment.MaxPacketLen)
assertHostInfoPair(t, myUdp, theirUdp, myVpn, theirVpn, myControl, theirControl)
assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
assert.Empty(t, myControl.ListHostmapIndexes(true), "no pending index may outlive the handshake")
assert.Empty(t, theirControl.ListHostmapIndexes(true))
myControl.Stop()
theirControl.Stop()
}
// TestPQHandshakeLoss drops data fragments of both messages; the repair
// fragments must carry the handshake without a retry.
func TestPQHandshakeLoss(t *testing.T) {
@@ -223,12 +195,12 @@ func TestPQResendFreshSymbols(t *testing.T) {
case h != nil && fh == nil:
// Keep the classical fallback out of the way.
return router.Drop
case fh != nil && h.MessageCounter == 2 && fh.Index < 4:
case fh != nil && h.MessageCounter == 2 && fh.Index < 12:
firstMsg2++
return router.Drop
case fh != nil && h.MessageCounter == 2:
freshMsg2++
case fh != nil && h.MessageCounter == 1 && fh.Index >= 4:
case fh != nil && h.MessageCounter == 1 && fh.Index >= 10:
freshMsg1++
}
if isTunnelData(p, receiver, theirControl) {
@@ -238,7 +210,7 @@ func TestPQResendFreshSymbols(t *testing.T) {
}), "tunnel never came up")
assertUdpPacket(t, []byte("Hi from me"), theirControl.GetFromTun(true), myVpn[0].Addr(), theirVpn[0].Addr(), 80, 80)
assert.Equal(t, 4, firstMsg2, "the first msg2 is 2 data and 2 repair fragments")
assert.Equal(t, 12, firstMsg2, "the first msg2 is 9 data and 3 repair fragments")
assert.Positive(t, freshMsg1, "the retry offers fresh msg1 symbols")
assert.Positive(t, freshMsg2, "the resend offers fresh msg2 symbols")
assertTunnel(t, myVpn[0].Addr(), theirVpn[0].Addr(), myControl, theirControl, r)
@@ -268,7 +240,7 @@ func TestPQSticky(t *testing.T) {
myControl.ReHandshake(theirVpn[0].Addr())
r.RouteForAllExitFuncOrTimeout(time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
tally.observe(p)
if h, _ := handshakeOf(p); h != nil && h.Subtype == header.HandshakeIXPSK2MLKEM1024 {
if h, _ := handshakeOf(p); h != nil && h.Subtype == header.HandshakeIXHybridMLKEM1024 {
return router.Drop
}
return router.KeepRouting
@@ -280,58 +252,6 @@ func TestPQSticky(t *testing.T) {
theirControl.Stop()
}
func TestPQRelays(t *testing.T) {
t.Parallel()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
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.Version1, ca, caKey, "me ", "10.128.0.1/24", useRelays)
relayControl, relayVpnIpNet, relayUdpAddr, _ := newSimpleServer(cert.Version1, ca, caKey, "relay ", "10.128.0.128/24", amRelay)
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version1, ca, caKey, "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 tally handshakeTally
var relayedPQ, relayedClassical int
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me")))
require.True(t, r.RouteForAllExitFuncOrTimeout(10*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
tally.observe(p)
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 {
relayedPQ++
assert.LessOrEqual(t, len(p.Data), fragment.MaxPacketLen+32, "a relayed fragment fits the IPv6 minimum MTU")
} else if h != nil {
relayedClassical++
}
}
if isTunnelData(p, receiver, theirControl) {
return router.RouteAndExit
}
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, relayedPQ, "the end to end handshake is PQ through the relay")
assert.Zero(t, relayedClassical, "the relay coming up carries the PQ attempt, not the classical retry")
r.RenderHostmaps("Final hostmaps", myControl, relayControl, theirControl)
myControl.Stop()
relayControl.Stop()
theirControl.Stop()
}
// TestPQTimeoutReleasesIndexes lets a handshake with both a PQ and a classical
// attempt pending time out, and checks neither index is left behind.
func TestPQTimeoutReleasesIndexes(t *testing.T) {
@@ -370,7 +290,7 @@ func TestPQCookieUnderLoad(t *testing.T) {
t.Log("Flood them with the first fragment of msg1s nobody will finish")
for i := range 512 {
from := netip.AddrPortFrom(netip.AddrFrom4([4]byte{198, 18, byte(i >> 8), byte(i)}), 4242)
s, err := fragment.NewSender(make([]byte, 2000), header.HandshakeIXPSK2MLKEM1024, 0, 1, uint32(i+1))
s, err := fragment.NewSender(make([]byte, 2000), header.HandshakeIXHybridMLKEM1024, 0, 1, uint32(i+1))
require.NoError(t, err)
pkts, _, err := s.Attempt(0)
require.NoError(t, err)
@@ -382,7 +302,7 @@ func TestPQCookieUnderLoad(t *testing.T) {
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpn[0].Addr(), 80, myVpn[0].Addr(), 80, []byte("Hi from me")))
require.True(t, r.RouteForAllExitFuncOrTimeout(5*time.Second, func(p *udp.Packet, receiver *nebula.Control) router.ExitType {
tally.observe(p)
if h, fh := handshakeOf(p); h != nil && h.Subtype == header.HandshakeIXPSK2MLKEM1024 {
if h, fh := handshakeOf(p); h != nil && h.Subtype == header.HandshakeIXHybridMLKEM1024 {
switch {
case h.MessageCounter == 0:
cookieReplies++
+109 -103
View File
@@ -23,9 +23,9 @@ import (
// hybridTally counts handshake packets by subtype and message.
type hybridTally struct {
sync.Mutex
hybrid, phase1, classical int
msg3 int
maxLen int
hybrid, classical int
msg3 int
maxLen int
}
func (ht *hybridTally) observe(p *udp.Packet) {
@@ -42,8 +42,6 @@ func (ht *hybridTally) observe(p *udp.Packet) {
if h.MessageCounter == 3 {
ht.msg3++
}
case header.HandshakeIXPSK2MLKEM1024:
ht.phase1++
default:
ht.classical++
}
@@ -139,7 +137,7 @@ func TestHybridHandshake(t *testing.T) {
}), "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.Zero(t, tally.classical, "a node with a v3 certificate speaks the hybrid handshake")
assert.GreaterOrEqual(t, tally.hybrid, 7+9+2, "all three messages travel as several fragments")
assert.Positive(t, tally.msg3)
assert.LessOrEqual(t, tally.maxLen, fragment.MaxPacketLen)
@@ -233,9 +231,9 @@ func TestHybridNeverConfirmed(t *testing.T) {
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.
// TestHybridToV2Peer has a v3 initiator meet a peer that holds only a v2
// certificate. Its post-quantum settings do nothing without a v3 certificate, so
// the tunnel comes up classical on the v2 form.
func TestHybridToV2Peer(t *testing.T) {
t.Parallel()
ca := newHybridCA(t)
@@ -257,7 +255,6 @@ func TestHybridToV2Peer(t *testing.T) {
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())
@@ -313,41 +310,12 @@ func TestHybridRequire(t *testing.T) {
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)
assert.Zero(t, tally.classical)
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) {
@@ -432,75 +400,113 @@ func TestHybridBackToBack(t *testing.T) {
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.
// TestHybridRaceWithClassical has the initiator's classical attempt cross its
// hybrid one: the hybrid msg2 is held back until the classical msg1 has reached
// the responder, and the classical msg2 is lost, so the initiator commits to the
// hybrid handshake.
//
// Arriving within the crossing window, the classical msg1 goes unanswered and
// the responder ends up with the hybrid tunnel alone. Held past the window, as on
// a very slow path, the responder completes it, and must still 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()
for _, tc := range []struct {
name string
delay time.Duration
answered bool
}{
{"within the window", 0, false},
{"past the window", 500 * time.Millisecond, true},
} {
t.Run(tc.name, func(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)
var heldMsg2 []*udp.Packet
var classicalMsg1 *udp.Packet
holdMsg2 := func(p *udp.Packet) bool {
if h, fh := handshakeOf(p); h != nil && fh != nil && h.MessageCounter == 2 {
heldMsg2 = append(heldMsg2, p.Copy())
return true
}
return false
}
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 {
if holdMsg2(p) {
return router.Drop
}
if h, fh := handshakeOf(p); h != nil && fh == nil && h.MessageCounter == 1 {
classicalMsg1 = p.Copy()
return router.ExitNow
}
return router.KeepRouting
}), "no classical attempt")
// 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")
time.Sleep(tc.delay)
r.InjectUDPPacket(myControl, theirControl, classicalMsg1)
for _, p := range heldMsg2 {
r.InjectUDPPacket(theirControl, myControl, p)
// Lose any classical msg2, so the initiator only ever completes the hybrid
// handshake. With msg2 held the initiator keeps retrying, and a later
// classical retry may land past the window; only the crossing one counts.
var classicalMsg2 int
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 == 1 {
return router.Drop
}
if h, fh := handshakeOf(p); h != nil && fh == nil && h.MessageCounter == 2 {
classicalMsg2++
return router.Drop
}
if holdMsg2(p) {
return router.Drop
}
return router.KeepRouting
})
if tc.answered {
require.Positive(t, classicalMsg2, "past the window the classical msg1 is answered")
require.NotNil(t, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false))
} else {
require.Zero(t, classicalMsg2, "within the window the classical msg1 goes unanswered")
require.Nil(t, theirControl.GetHostInfoByVpnAddr(myVpn[0].Addr(), false))
}
var recvErrors int
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)
if !tc.answered {
assert.Len(t, theirControl.ListHostmapIndexes(false), 1, "and the only one")
}
myControl.Stop()
theirControl.Stop()
})
}
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()
}
// TestHybridWrongResponder gives the initiator a stale address for its peer