mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 12:36:52 +01:00
Run hybrid handshakes: v3 holders initiate them, responders wait for msg3, initiators resend it until confirmed
This commit is contained in:
1 parent
5c5254dc18
commit
b26c1e9c3e
8 files changed
+913
-36
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -134,6 +134,12 @@ func (m *Machine) Subtype() header.MessageSubType {
|
||||
return m.subtype
|
||||
}
|
||||
|
||||
// PeerCert returns the peer's verified certificate once a message carrying it
|
||||
// has been read, which for a three message pattern is before completion.
|
||||
func (m *Machine) PeerCert() *cert.CachedCertificate {
|
||||
return m.result.RemoteCert
|
||||
}
|
||||
|
||||
// MessageIndex returns the noise handshake message index, which equals the
|
||||
// wire counter of the most recently sent or received message.
|
||||
func (m *Machine) MessageIndex() int {
|
||||
|
||||
+63
-11
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func fragmentIndexes(t *testing.T, pkts [][]byte, fragID, remoteIndex uint32) []
|
||||
require.LessOrEqual(t, len(p), fragment.MaxPacketLen)
|
||||
var h header.H
|
||||
require.NoError(t, h.Parse(p))
|
||||
assert.Equal(t, pqSubtype, h.Subtype)
|
||||
assert.Equal(t, header.HandshakeIXPSK2MLKEM1024, h.Subtype)
|
||||
assert.Equal(t, remoteIndex, h.RemoteIndex)
|
||||
assert.Equal(t, uint64(2), h.MessageCounter)
|
||||
fh, _, err := fragment.Parse(p[header.Len:])
|
||||
@@ -80,7 +80,7 @@ func TestSendFragmentedResponse(t *testing.T) {
|
||||
|
||||
// A 2000 byte message is 2 symbols of 1000, so packets of 1024 bytes.
|
||||
msg := make([]byte, header.Len, header.Len+2000)
|
||||
header.Encode(msg, header.Version, header.Handshake, pqSubtype, 5, 2)
|
||||
header.Encode(msg, header.Version, header.Handshake, header.HandshakeIXPSK2MLKEM1024, 5, 2)
|
||||
r := rand.New(rand.NewPCG(1, 1))
|
||||
for range 2000 {
|
||||
msg = append(msg, byte(r.Uint32()))
|
||||
@@ -183,7 +183,7 @@ func TestPQCookies(t *testing.T) {
|
||||
// pqMsg1Fragment builds the first fragment of a msg1, optionally with a cookie.
|
||||
func pqMsg1Fragment(t *testing.T, fragID uint32, cookie *fragment.Cookie) ([]byte, *header.H) {
|
||||
t.Helper()
|
||||
s, err := fragment.NewSender(make([]byte, 2000), pqSubtype, 0, 1, fragID)
|
||||
s, err := fragment.NewSender(make([]byte, 2000), header.HandshakeIXPSK2MLKEM1024, 0, 1, fragID)
|
||||
require.NoError(t, err)
|
||||
if cookie != nil {
|
||||
s.SetCookie(*cookie)
|
||||
@@ -238,7 +238,7 @@ func TestPQCookieUnderLoad(t *testing.T) {
|
||||
var rh header.H
|
||||
require.NoError(t, rh.Parse(reply))
|
||||
assert.Equal(t, header.Handshake, rh.Type)
|
||||
assert.Equal(t, pqSubtype, rh.Subtype)
|
||||
assert.Equal(t, header.HandshakeIXPSK2MLKEM1024, rh.Subtype)
|
||||
assert.Equal(t, uint32(7), rh.RemoteIndex, "the reply names the initiator's FragID")
|
||||
assert.Equal(t, uint64(pqCookieCounter), rh.MessageCounter)
|
||||
copy(cookie[:], reply[header.Len:])
|
||||
@@ -270,7 +270,7 @@ func TestPQCookieUnderLoad(t *testing.T) {
|
||||
|
||||
t.Run("a reply never outgrows what prompted it", func(t *testing.T) {
|
||||
p, h := pqMsg1Fragment(t, 8, nil)
|
||||
hm.sendCookie(via, fragment.Header{FragID: 8}, pqFragKey{}, pqCookieReplyLen-1, time.Now())
|
||||
hm.sendCookie(via, header.HandshakeIXPSK2MLKEM1024, fragment.Header{FragID: 8}, pqFragKey{}, pqCookieReplyLen-1, time.Now())
|
||||
assert.Empty(t, conn.take())
|
||||
hm.handleFragment(via, p, h)
|
||||
assert.Len(t, conn.take(), 1)
|
||||
@@ -281,7 +281,7 @@ func TestPQHandleCookie(t *testing.T) {
|
||||
hm, conn := newPQTestManager(t)
|
||||
via := ViaSender{UdpAddr: netip.MustParseAddrPort("192.0.2.1:4242")}
|
||||
|
||||
s, err := fragment.NewSender(make([]byte, 2000), pqSubtype, 0, 1, 99)
|
||||
s, err := fragment.NewSender(make([]byte, 2000), header.HandshakeIXPSK2MLKEM1024, 0, 1, 99)
|
||||
require.NoError(t, err)
|
||||
_, _, err = s.Attempt(2)
|
||||
require.NoError(t, err)
|
||||
@@ -289,7 +289,7 @@ func TestPQHandleCookie(t *testing.T) {
|
||||
hm.indexes[99] = hh
|
||||
|
||||
reply := make([]byte, header.Len, pqCookieReplyLen)
|
||||
header.Encode(reply, header.Version, header.Handshake, pqSubtype, 99, pqCookieCounter)
|
||||
header.Encode(reply, header.Version, header.Handshake, header.HandshakeIXPSK2MLKEM1024, 99, pqCookieCounter)
|
||||
reply = append(reply, bytes16(0xab)...)
|
||||
var h header.H
|
||||
require.NoError(t, h.Parse(reply))
|
||||
@@ -311,7 +311,7 @@ func TestPQHandleCookie(t *testing.T) {
|
||||
|
||||
t.Run("replies for other indexes are ignored", func(t *testing.T) {
|
||||
other := append([]byte(nil), reply...)
|
||||
header.Encode(other, header.Version, header.Handshake, pqSubtype, 98, pqCookieCounter)
|
||||
header.Encode(other, header.Version, header.Handshake, header.HandshakeIXPSK2MLKEM1024, 98, pqCookieCounter)
|
||||
var oh header.H
|
||||
require.NoError(t, oh.Parse(other))
|
||||
hh.pq.cookieResendAt = time.Time{}
|
||||
@@ -393,6 +393,6 @@ func TestUsePQ(t *testing.T) {
|
||||
|
||||
set(pqHandshakeConfig{initiate: true, sticky: true})
|
||||
assert.Equal(t, []bool{true, false}, attempts(false, false), "sticky alternates until the peer has done PQ")
|
||||
hm.notePQPeer(pqSubtype, []netip.Addr{peer})
|
||||
hm.notePQPeer(header.HandshakeIXPSK2MLKEM1024, []netip.Addr{peer})
|
||||
assert.Equal(t, []bool{true, true, true}, attempts(false, false, false), "then only PQ")
|
||||
}
|
||||
+3
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user