mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-22 05:19:08 +02:00
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.
138 lines
4.9 KiB
Go
138 lines
4.9 KiB
Go
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")
|
|
})
|
|
}
|
|
}
|