diff --git a/connection_state.go b/connection_state.go index 3018048b..84d0d786 100644 --- a/connection_state.go +++ b/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() } diff --git a/e2e/pq_hybrid_test.go b/e2e/pq_hybrid_test.go new file mode 100644 index 00000000..d583a7fe --- /dev/null +++ b/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() +} diff --git a/handshake/machine.go b/handshake/machine.go index 9636ad8a..e9e1a59c 100644 --- a/handshake/machine.go +++ b/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 { diff --git a/handshake_manager.go b/handshake_manager.go index 1359182c..38f9e003 100644 --- a/handshake_manager.go +++ b/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() { + if !machine.Failed() { + f.l.Debug("Failed to process handshake packet", + "vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err) + } 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.Warn("Failed to process handshake packet, abandoning", "vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err) hm.DeleteHostInfo(hostinfo) - } else { - f.l.Debug("Failed to process handshake packet", - "vpnAddrs", hostinfo.vpnAddrs, "from", via, "error", err) } 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(), diff --git a/handshake_manager_hybrid.go b/handshake_manager_hybrid.go new file mode 100644 index 00000000..3b59b9b6 --- /dev/null +++ b/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) + } +} diff --git a/handshake_manager_pq.go b/handshake_manager_pq.go index d673c915..9c560994 100644 --- a/handshake_manager_pq.go +++ b/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) } diff --git a/handshake_manager_pq_test.go b/handshake_manager_pq_test.go index d88b23b6..9dcda5a2 100644 --- a/handshake_manager_pq_test.go +++ b/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") } diff --git a/outside.go b/outside.go index dc019e36..5242a629 100644 --- a/outside.go +++ b/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