[client] pqkem: authenticate data-path messages under the shared PSK

The data-path socket identified a sender only by its source IP and port and
trusted that anything reaching the overlay address came through the tunnel.
On a host-bound socket that does not hold: Linux delivers a datagram to a
local overlay IP from a physical interface too (rp_filter is loose, the
firewall's input rules match the tunnel interface), so a LAN-adjacent host
spoofing a peer's overlay source could inject a KEM offer. A forged
bootstrap offer was adopted outright, desynchronising the two PSKs until a
re-bootstrap, and repeating it sustains the outage (security review of
#7098, finding F-003). No traffic is disclosed, but the exchange must not
accept unauthenticated input.

Seal every data-path message with an HMAC keyed from the PSK the two peers
share; only a peer that holds it can rotate. A reply is sealed under the key
that verified the request, because the responder has already committed the
new PSK when it answers. The previous PSK stays valid for verification so an
offer retransmitted after that commit is still answered. Bootstraps are
refused on the data path entirely: they start without a shared PSK and only
signalling can carry them.

A rotation between peers whose configured secrets differ is now refused at
authentication, so the configured-secret test checks that instead.
This commit is contained in:
riccardom
2026-10-19 17:02:15 +02:00
parent b10fc220f3
commit 13451079c3
5 changed files with 268 additions and 12 deletions
+6 -2
View File
@@ -87,9 +87,13 @@ func TestManager_InitiatorRejectsOfferFromResponderRole(t *testing.T) {
psk1 := wgB.psk("aaaa")
require.NotEqual(t, PSK{}, psk1, "bootstrap established a PSK")
rogue, err := dA.startExchangeTest("bbbb", false, ExchangeID{})
// Authenticated (sealed under the shared PSK) and shaped as a rotation, so only the
// role guard can stop it.
ack, err := newExchangeID()
require.NoError(t, err)
require.NoError(t, dB.OnDataPathMessage("aaaa", rogue))
rogue, err := dA.startExchangeTest("bbbb", false, ack)
require.NoError(t, err)
require.NoError(t, dB.OnDataPathMessage("aaaa", sealForPeer(t, dA, "bbbb", rogue)))
assert.Equal(t, psk1, wgB.psk("aaaa"),
"the initiator must not adopt a PSK from an offer sent by the role-responder")
+48
View File
@@ -0,0 +1,48 @@
package pqkem
import (
"crypto/hkdf"
"crypto/hmac"
"crypto/sha256"
"fmt"
)
// dataPathMACLabel separates the data-path MAC key from the PSK the consumer programs
// into its channel, so the same secret never serves two purposes directly.
const dataPathMACLabel = "netbird-pq-datapath-mac-v1"
// dataPathTagSize is the length of the HMAC-SHA256 tag appended to every data-path frame.
const dataPathTagSize = sha256.Size
// dataPathMACKey derives the key that authenticates data-path messages from a PSK both
// peers hold.
func dataPathMACKey(psk PSK) ([]byte, error) {
key, err := hkdf.Key(sha256.New, psk[:], nil, dataPathMACLabel, sha256.Size)
if err != nil {
return nil, fmt.Errorf("derive data-path mac key: %w", err)
}
return key, nil
}
// sealDataPath appends the frame's HMAC-SHA256 tag under key.
func sealDataPath(key, frame []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(frame)
sealed := make([]byte, 0, len(frame)+dataPathTagSize)
sealed = append(sealed, frame...)
return mac.Sum(sealed)
}
// openDataPath verifies a sealed frame under key and returns the frame without its tag.
func openDataPath(key, sealed []byte) ([]byte, bool) {
if len(sealed) < dataPathTagSize {
return nil, false
}
frame, tag := sealed[:len(sealed)-dataPathTagSize], sealed[len(sealed)-dataPathTagSize:]
mac := hmac.New(sha256.New, key)
mac.Write(frame)
if !hmac.Equal(tag, mac.Sum(nil)) {
return nil, false
}
return frame, true
}
+137
View File
@@ -0,0 +1,137 @@
package pqkem
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// sealForPeer seals a data-path frame under the PSK m currently shares with remoteID, as
// a genuine sender would.
func sealForPeer(t *testing.T, m *Manager, remoteID RemoteID, frame []byte) []byte {
t.Helper()
psk, ok := m.PSK(remoteID)
require.True(t, ok, "sealing needs an established PSK")
key, err := dataPathMACKey(psk)
require.NoError(t, err)
return sealDataPath(key, frame)
}
func TestDataPathSealOpen(t *testing.T) {
key, err := dataPathMACKey(PSK{1})
require.NoError(t, err)
otherKey, err := dataPathMACKey(PSK{2})
require.NoError(t, err)
frame := []byte("kem frame")
sealed := sealDataPath(key, frame)
got, ok := openDataPath(key, sealed)
require.True(t, ok, "a frame sealed under the key must open")
assert.Equal(t, frame, got, "opening must return the original frame")
_, ok = openDataPath(otherKey, sealed)
assert.False(t, ok, "a different key must not open the frame")
tampered := append([]byte(nil), sealed...)
tampered[0] ^= 0x01
_, ok = openDataPath(key, tampered)
assert.False(t, ok, "a modified frame must not open")
_, ok = openDataPath(key, sealed[:dataPathTagSize-1])
assert.False(t, ok, "a datagram shorter than a tag must not open")
}
// TestManager_DataPathRejectsAuthenticatedBootstrap verifies a bootstrap is never taken
// from the data path, even with a valid MAC: the data path only carries rotations of an
// exchange that signalling already established.
func TestManager_DataPathRejectsAuthenticatedBootstrap(t *testing.T) {
dA, dB, wgA, _, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
psk := wgA.psk("bbbb")
require.NoError(t, dA.OnDataPathMessage("bbbb", sealForPeer(t, dA, "bbbb", forgedOffer(t, ExchangeID{}))))
assert.Equal(t, psk, wgA.psk("bbbb"), "a bootstrap offer over the data path must be dropped")
}
// TestManager_DataPathRetransmitAfterCommitIsAnswered covers a lost answer. The responder
// commits the rotated PSK when it answers, so the initiator's retransmission, still sealed
// under the previous PSK, must be accepted under that key and answered, or the two peers
// never converge.
func TestManager_DataPathRetransmitAfterCommitIsAnswered(t *testing.T) {
sw := newSwitch()
wgA, wgB := newFakeWG(), newFakeWG()
dA := NewManager("aaaa", wgA, nil) // responder
dB := NewManager("bbbb", wgB, nil) // initiator
dB.retryInterval = 20 * time.Millisecond
lbA := &loopback{ep: epA, sw: sw}
dA.Start(lbA)
dB.Start(&loopback{ep: epB, sw: sw})
dA.AddPeer("bbbb", epB)
dB.AddPeer("aaaa", epA)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
boot := wgB.psk("aaaa")
lbA.drop.Store(true) // lose the responder's answers
dB.OnDataPathRekeyed("aaaa", 0)
require.Eventually(t, func() bool { return wgA.psk("bbbb") != boot }, time.Second, 5*time.Millisecond,
"the responder must commit the rotated PSK")
lbA.drop.Store(false)
require.Eventually(t, func() bool { return wgB.psk("aaaa") == wgA.psk("bbbb") }, 2*time.Second, 5*time.Millisecond,
"the retransmitted offer must be answered so both peers converge")
}
// forgedOffer builds a well-formed KEM offer that no peer authenticated: fresh KEM
// material, the given AckID, and no data-path MAC.
func forgedOffer(t *testing.T, ack ExchangeID) []byte {
t.Helper()
ini, err := NewInitiator()
require.NoError(t, err)
id, err := newExchangeID()
require.NoError(t, err)
raw, err := (&OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: ini.Offer()}).Encode()
require.NoError(t, err)
return raw
}
// TestManager_DataPathRejectsForgedOffer covers spoofed data-path datagrams. The data-path
// socket only knows the sender by source IP:port, and on a host-bound socket a datagram
// with a spoofed source can arrive from a physical interface rather than the tunnel. An
// unauthenticated offer must not re-key the responder: a forged bootstrap (zero AckID)
// used to be adopted, desynchronising the peers' PSKs. A guessed AckID is covered too.
func TestManager_DataPathRejectsForgedOffer(t *testing.T) {
guessed, err := newExchangeID()
require.NoError(t, err)
cases := map[string]ExchangeID{
"forged bootstrap (zero AckID)": {},
"forged rotation (guessed AckID)": guessed,
}
for name, ack := range cases {
t.Run(name, func(t *testing.T) {
dA, dB, wgA, _, _ := pair(t) // dB ("bbbb") initiator, dA ("aaaa") responder
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
psk := wgA.psk("bbbb")
require.NotEqual(t, PSK{}, psk, "bootstrap must establish a PSK")
// Spoofed: it arrives from the initiator's registered data-path endpoint.
dA.onDataPathInbound(epB, forgedOffer(t, ack))
assert.Equal(t, psk, wgA.psk("bbbb"), "a forged data-path offer must not re-key the responder")
current, ok := dA.PSK("bbbb")
require.True(t, ok)
assert.Equal(t, psk, current, "the responder's stored PSK must be unchanged")
})
}
}
+68 -8
View File
@@ -114,6 +114,7 @@ type Manager struct {
established map[RemoteID]bool // peer has completed at least one exchange
failures map[RemoteID]int // consecutive rekey failures per peer
psks map[RemoteID]PSK // latest derived PSK per peer (pulled at WG peer-config time)
prevPSKs map[RemoteID]PSK // PSK before the latest commit; still authenticates data-path messages sealed under it
capable map[RemoteID]bool // peer runs the KEM (advertised a PQ port); false = known non-capable
peerAddrs map[RemoteID]netip.AddrPort // remoteID -> data-path endpoint (send routing)
peersByAddr map[netip.AddrPort]RemoteID // reverse: source endpoint -> remoteID (inbound)
@@ -164,6 +165,7 @@ func NewManager(localID LocalID, h CallbackHandler, logger *slog.Logger, opts ..
established: make(map[RemoteID]bool),
failures: make(map[RemoteID]int),
psks: make(map[RemoteID]PSK),
prevPSKs: make(map[RemoteID]PSK),
capable: make(map[RemoteID]bool),
peerAddrs: make(map[RemoteID]netip.AddrPort),
peersByAddr: make(map[netip.AddrPort]RemoteID),
@@ -297,6 +299,7 @@ func (m *Manager) RemovePeer(remoteID RemoteID) {
delete(m.established, remoteID)
delete(m.failures, remoteID)
delete(m.psks, remoteID)
delete(m.prevPSKs, remoteID)
delete(m.capable, remoteID)
if ep, ok := m.peerAddrs[remoteID]; ok {
delete(m.peersByAddr, ep)
@@ -479,22 +482,37 @@ func (m *Manager) onDataPathInbound(src netip.AddrPort, msg []byte) {
}
// OnDataPathMessage handles a KEM message received over the data path from remoteID
// and pushes any reply back over the data path.
// and pushes any reply back over the data path. The sender is only known by its source
// address, which can be spoofed, so a message that does not carry a valid MAC under a PSK
// shared with remoteID is rejected.
func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
typ, msg, err := Decode(raw)
frame, key, err := m.openDataPathFrom(remoteID, raw)
if err != nil {
return err
}
typ, msg, err := Decode(frame)
if err != nil {
return fmt.Errorf("decode data-path msg from %s: %w", remoteID, err)
}
switch typ {
case MsgOffer:
answer, err := m.processOffer(remoteID, msg.(*OfferMsg), viaDataPathLabel)
o := msg.(*OfferMsg)
// The data path only carries rotations of an established exchange. A bootstrap
// starts from scratch, so it must come over the authenticated signalling channel.
if o.AckID == (ExchangeID{}) {
m.debug("pqkem: dropping a bootstrap offer received over the data path", "peer", remoteID, "exchange", idHex(o.ExchangeID))
return nil
}
answer, err := m.processOffer(remoteID, o, viaDataPathLabel)
if err != nil {
return err
}
if answer == nil {
return nil
}
return m.pushDataPath(remoteID, answer)
// Seal the reply under the key that verified the offer. processOffer has just
// committed the new PSK, which the initiator only derives once it reads this answer.
return m.sendDataPath(remoteID, sealDataPath(key, answer))
case MsgAnswer:
return m.processAnswer(remoteID, msg.(*AnswerMsg), viaDataPathLabel)
case MsgError:
@@ -567,15 +585,57 @@ func (m *Manager) OnDataPathDown(remoteID RemoteID) {}
// ---- internals ----
// commitPSKLocked records a freshly derived PSK as the peer's current one. Assumes m.mu
// is held.
// commitPSKLocked records a freshly derived PSK as the peer's current one, keeping the one
// it replaces: messages sealed under it may still be in flight (see openDataPathFrom).
// Assumes m.mu is held.
func (m *Manager) commitPSKLocked(remoteID RemoteID, psk PSK) {
if old, ok := m.psks[remoteID]; ok {
m.prevPSKs[remoteID] = old
}
m.psks[remoteID] = psk
}
// pushDataPath sends a message to the peer over the data-path transport.
// pushDataPath sends a message to the peer over the data-path transport, sealed under
// the PSK the two peers currently share.
func (m *Manager) pushDataPath(remoteID RemoteID, msg []byte) error {
return m.sendDataPath(remoteID, msg)
m.mu.Lock()
psk, ok := m.psks[remoteID]
m.mu.Unlock()
if !ok {
return fmt.Errorf("no PSK to authenticate a data-path message to %s", remoteID)
}
key, err := dataPathMACKey(psk)
if err != nil {
return err
}
return m.sendDataPath(remoteID, sealDataPath(key, msg))
}
// openDataPathFrom authenticates a data-path message from a peer under its current PSK,
// or failing that the previous one: an offer retransmitted after this side committed the
// PSK it derived was still sealed under the old key. It returns the frame and the key
// that verified it.
func (m *Manager) openDataPathFrom(remoteID RemoteID, raw []byte) ([]byte, []byte, error) {
m.mu.Lock()
candidates := make([]PSK, 0, 2)
if psk, ok := m.psks[remoteID]; ok {
candidates = append(candidates, psk)
}
if psk, ok := m.prevPSKs[remoteID]; ok {
candidates = append(candidates, psk)
}
m.mu.Unlock()
for _, psk := range candidates {
key, err := dataPathMACKey(psk)
if err != nil {
return nil, nil, err
}
if frame, ok := openDataPath(key, raw); ok {
return frame, key, nil
}
}
return nil, nil, fmt.Errorf("unauthenticated data-path message from %s", remoteID)
}
// sendDataPath resolves the peer's endpoint and sends over the data-path transport,
+9 -2
View File
@@ -55,12 +55,19 @@ func TestManager_PresharedSecretBindsPSK(t *testing.T) {
require.NotEqual(t, PSK{}, bootB, "initiator must derive a bootstrap PSK")
assert.Equal(t, tc.match, bootA == bootB, "bootstrap PSKs must match only when the secrets match")
// Rotation over the data path re-derives the PSK; it must honour the secret too.
dB.OnDataPathRekeyed("aaaa", 0)
if !tc.match {
// The peers share no PSK, so the responder cannot authenticate the data-path
// rotation offer and keeps its key: a rotation cannot bridge the secret either.
assert.Never(t, func() bool { return wgA.psk("bbbb") != bootA }, 200*time.Millisecond, 10*time.Millisecond,
"a rotation between peers with different secrets must not re-key the responder")
return
}
// Rotation over the data path re-derives the PSK; it must honour the secret too.
require.Eventually(t, func() bool {
return wgA.psk("bbbb") != bootA && wgB.psk("aaaa") != bootB
}, time.Second, 5*time.Millisecond, "the rotation must re-key both peers")
assert.Equal(t, tc.match, wgA.psk("bbbb") == wgB.psk("aaaa"), "rotated PSKs must match only when the secrets match")
assert.Equal(t, wgA.psk("bbbb"), wgB.psk("aaaa"), "rotated PSKs must match when the secrets match")
})
}
}