mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 12:59:08 +02:00
[client] pqkem: install strict-mode keys on idle lazy peers
ML-KEM strict mode promises that no traffic crosses on a non-PQ key. Under lazy connections an idle peer is still installed in WireGuard, so that traffic towards it can wake it, and it accepts a handshake the remote initiates directly. Both lazy listeners installed it without a preshared key, so a freshly created idle peer accepted a classical session before any KEM exchange, for example from a remote whose WireGuard still targets the victim's real endpoint (security review of #7098, finding F-002). The strict sentinel only reached WireGuard later, on the normal connection path. Let the lazy peer config carry a key provider that the listeners install with the idle peer. In strict mode it yields the PQ PSK once derived and the sentinel until then; it is read at install time so it is never stale. Outside strict mode it yields nothing and the idle peer's key is left unchanged, as before.
This commit is contained in:
@@ -185,10 +185,11 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
|
||||
// newLazyPeerConfig describes a peer connection to the lazy connection manager.
|
||||
func newLazyPeerConfig(peerKey string, conn *peer.Conn) lazyconn.PeerConfig {
|
||||
return lazyconn.PeerConfig{
|
||||
PublicKey: peerKey,
|
||||
AllowedIPs: conn.WgConfig().AllowedIps,
|
||||
PeerConnID: conn.ConnID(),
|
||||
Log: conn.Log,
|
||||
PublicKey: peerKey,
|
||||
AllowedIPs: conn.WgConfig().AllowedIps,
|
||||
PeerConnID: conn.ConnID(),
|
||||
Log: conn.Log,
|
||||
PresharedKey: conn.IdlePresharedKey,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ func (d *BindListener) setupLazyConn() error {
|
||||
IP: d.fakeIP.AsSlice(),
|
||||
Port: lazyBindPort,
|
||||
}
|
||||
return d.wgIface.UpdatePeer(d.peerCfg.PublicKey, d.peerCfg.AllowedIPs, 0, endpoint, nil)
|
||||
return d.wgIface.UpdatePeer(d.peerCfg.PublicKey, d.peerCfg.AllowedIPs, 0, endpoint, d.peerCfg.IdlePresharedKey())
|
||||
}
|
||||
|
||||
// ReadPackets blocks until activity is detected on the LazyConn or the listener is closed.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
)
|
||||
|
||||
// pskRecordingIface records the preshared key each idle peer is installed with.
|
||||
type pskRecordingIface struct {
|
||||
MocWGIface
|
||||
mu sync.Mutex
|
||||
psks []*wgtypes.Key
|
||||
}
|
||||
|
||||
func (r *pskRecordingIface) UpdatePeer(_ string, _ []netip.Prefix, _ time.Duration, _ *net.UDPAddr, psk *wgtypes.Key) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.psks = append(r.psks, psk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pskRecordingIface) installed() []*wgtypes.Key {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]*wgtypes.Key(nil), r.psks...)
|
||||
}
|
||||
|
||||
type idleListener interface {
|
||||
ReadPackets()
|
||||
Close()
|
||||
}
|
||||
|
||||
func closeListener(t *testing.T, l idleListener) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
l.ReadPackets()
|
||||
close(done)
|
||||
}()
|
||||
l.Close()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for ReadPackets to exit after Close")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListeners_InstallIdlePeerWithItsPresharedKey covers ML-KEM strict mode with lazy
|
||||
// connections. An idle peer is installed in WireGuard so a handshake the remote initiates
|
||||
// directly is accepted; installed without a key, a fresh peer accepted a classical session
|
||||
// before any post-quantum exchange, breaking strict mode's promise. Both listeners must
|
||||
// install the key the peer config provides, and leave it unchanged when there is none.
|
||||
func TestListeners_InstallIdlePeerWithItsPresharedKey(t *testing.T) {
|
||||
sentinel := wgtypes.Key{1, 2, 3}
|
||||
newCfg := func(psk func() *wgtypes.Key) lazyconn.PeerConfig {
|
||||
peer := &MocPeer{PeerID: "testPeer1"}
|
||||
return lazyconn.PeerConfig{
|
||||
PublicKey: peer.PeerID,
|
||||
PeerConnID: peer.ConnID(),
|
||||
AllowedIPs: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
|
||||
Log: log.WithField("peer", "testPeer1"),
|
||||
PresharedKey: psk,
|
||||
}
|
||||
}
|
||||
listeners := map[string]func(WgInterface, lazyconn.PeerConfig) (idleListener, error){
|
||||
"udp": func(iface WgInterface, cfg lazyconn.PeerConfig) (idleListener, error) {
|
||||
return NewUDPListener(iface, cfg)
|
||||
},
|
||||
"bind": func(iface WgInterface, cfg lazyconn.PeerConfig) (idleListener, error) {
|
||||
return NewBindListener(iface, newMockEndpointManager(), cfg)
|
||||
},
|
||||
}
|
||||
for name, newListener := range listeners {
|
||||
t.Run(name+"/with key", func(t *testing.T) {
|
||||
iface := &pskRecordingIface{}
|
||||
l, err := newListener(iface, newCfg(func() *wgtypes.Key { return &sentinel }))
|
||||
require.NoError(t, err)
|
||||
closeListener(t, l)
|
||||
|
||||
installed := iface.installed()
|
||||
require.Len(t, installed, 1, "the idle peer must be installed once")
|
||||
require.NotNil(t, installed[0], "the idle peer must be installed with the provided key")
|
||||
assert.Equal(t, sentinel, *installed[0], "the idle peer must carry the provided key")
|
||||
})
|
||||
t.Run(name+"/without key", func(t *testing.T) {
|
||||
iface := &pskRecordingIface{}
|
||||
l, err := newListener(iface, newCfg(nil))
|
||||
require.NoError(t, err)
|
||||
closeListener(t, l)
|
||||
|
||||
installed := iface.installed()
|
||||
require.Len(t, installed, 1, "the idle peer must be installed once")
|
||||
assert.Nil(t, installed[0], "without a key the idle peer's key must be left unchanged")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func (d *UDPListener) Close() {
|
||||
|
||||
func (d *UDPListener) createEndpoint() error {
|
||||
d.peerCfg.Log.Debugf("creating lazy endpoint: %s", d.endpoint.String())
|
||||
return d.wgIface.UpdatePeer(d.peerCfg.PublicKey, d.peerCfg.AllowedIPs, 0, d.endpoint, nil)
|
||||
return d.wgIface.UpdatePeer(d.peerCfg.PublicKey, d.peerCfg.AllowedIPs, 0, d.endpoint, d.peerCfg.IdlePresharedKey())
|
||||
}
|
||||
|
||||
func (d *UDPListener) newConn() (*net.UDPConn, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/id"
|
||||
)
|
||||
@@ -13,4 +14,17 @@ type PeerConfig struct {
|
||||
AllowedIPs []netip.Prefix
|
||||
PeerConnID id.ConnID
|
||||
Log *log.Entry
|
||||
// PresharedKey returns the key to install with the idle WireGuard peer, or nil to
|
||||
// leave the peer's key as it is. It is read when the peer is installed, not when the
|
||||
// config is built, so it reflects keys derived after the peer joined. A nil func
|
||||
// leaves the key unchanged too.
|
||||
PresharedKey func() *wgtypes.Key
|
||||
}
|
||||
|
||||
// IdlePresharedKey is the key to install with the idle WireGuard peer (nil: unchanged).
|
||||
func (c PeerConfig) IdlePresharedKey() *wgtypes.Key {
|
||||
if c.PresharedKey == nil {
|
||||
return nil
|
||||
}
|
||||
return c.PresharedKey()
|
||||
}
|
||||
|
||||
@@ -1141,6 +1141,21 @@ func (conn *Conn) pqPSK() (*wgtypes.Key, bool) {
|
||||
return &psk, true
|
||||
}
|
||||
|
||||
// IdlePresharedKey is the preshared key an idle (lazy) WireGuard peer is installed with.
|
||||
// An idle peer accepts a handshake the remote initiates directly, so in ML-KEM strict
|
||||
// mode it must already carry the PQ PSK, or the strict sentinel until one exists;
|
||||
// installed without a key, a fresh peer would accept a classical session before any
|
||||
// exchange. Outside strict mode it returns nil and leaves the idle peer's key unchanged.
|
||||
func (conn *Conn) IdlePresharedKey() *wgtypes.Key {
|
||||
if conn.config.PQ == nil || !conn.config.PQStrict {
|
||||
return nil
|
||||
}
|
||||
if psk, ok := conn.pqPSK(); ok {
|
||||
return psk
|
||||
}
|
||||
return conn.pqStrictSentinelKey
|
||||
}
|
||||
|
||||
// presharedKey resolves the WireGuard preshared key for the peer. pqPSK is the
|
||||
// post-quantum PSK looked up out of band via pqPSK (nil when none is derived yet), passed
|
||||
// in so the manager lock is never taken under conn.mu.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
@@ -88,3 +89,34 @@ func TestConn_presharedKey_PQ(t *testing.T) {
|
||||
require.NotEqual(t, nbPSK, *got, "the sentinel must not be the ordinary key")
|
||||
})
|
||||
}
|
||||
|
||||
// TestConn_IdlePresharedKey covers the key an idle (lazy) peer is installed with. Strict
|
||||
// mode must never leave an idle peer without a key, since the remote can complete a
|
||||
// classical handshake with it directly; everything else leaves the idle key unchanged.
|
||||
func TestConn_IdlePresharedKey(t *testing.T) {
|
||||
derivedPSK, err := wgtypes.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
sentinel, err := wgtypes.GenerateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
newConn := func(pq PQHandshaker, strict bool) *Conn {
|
||||
c := &Conn{Log: log.WithField("peer", "pq-idle-test")}
|
||||
c.config.PQ = pq
|
||||
c.config.PQStrict = strict
|
||||
if strict {
|
||||
c.pqStrictSentinelKey = &sentinel
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
got := newConn(fakePQ{ok: false}, true).IdlePresharedKey()
|
||||
require.NotNil(t, got, "strict mode must install a key on an idle peer")
|
||||
assert.Equal(t, sentinel, *got, "before a PQ PSK exists strict mode installs the sentinel")
|
||||
|
||||
got = newConn(fakePQ{psk: derivedPSK, ok: true}, true).IdlePresharedKey()
|
||||
require.NotNil(t, got, "strict mode must install a key on an idle peer")
|
||||
assert.Equal(t, derivedPSK, *got, "once derived, strict mode installs the PQ PSK")
|
||||
|
||||
assert.Nil(t, newConn(fakePQ{ok: false}, false).IdlePresharedKey(), "non-strict mode leaves the idle key unchanged")
|
||||
assert.Nil(t, newConn(nil, false).IdlePresharedKey(), "without ML-KEM the idle key is left unchanged")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user