mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-22 21:39: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.
101 lines
3.4 KiB
Go
101 lines
3.4 KiB
Go
package pqkem
|
|
|
|
import (
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// dropTransport is a pqkem.Transport that silently discards everything.
|
|
type dropTransport struct{}
|
|
|
|
func (dropTransport) Send(netip.AddrPort, []byte) error { return nil }
|
|
func (dropTransport) LocalPort() int { return 0 }
|
|
func (dropTransport) Run(func(netip.AddrPort, []byte)) {}
|
|
func (dropTransport) Close() error { return nil }
|
|
|
|
func failedCount(f *fakeWG) int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.failed)
|
|
}
|
|
|
|
func TestManager_InitialTimeoutFailsImmediately(t *testing.T) {
|
|
wg := newFakeWG()
|
|
d := NewManager("bbbb", wg, nil) // bbbb > aaaa -> initiator
|
|
d.Start(dropTransport{})
|
|
d.retryInterval = 5 * time.Millisecond
|
|
d.maxRetries = 3
|
|
defer d.Stop()
|
|
|
|
// Bootstrap offer is produced for signalling; no answer ever comes back -> the
|
|
// initial exchange fails fast.
|
|
offer, err := d.SignalOffer("aaaa")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, offer)
|
|
|
|
assert.Eventually(t, func() bool { return failedCount(wg) == 1 }, time.Second, 5*time.Millisecond)
|
|
}
|
|
|
|
func TestManager_RekeyToleratesKFailures(t *testing.T) {
|
|
dA, dB, _, wgB, lbB := pair(t)
|
|
defer dA.Stop()
|
|
defer dB.Stop()
|
|
|
|
// Tighten B's timings before any exchange loop spawns (the loop reads these
|
|
// fields, so writing them after a loop is running would race).
|
|
dB.retryInterval = 5 * time.Millisecond
|
|
dB.maxRetries = 2
|
|
|
|
// Establish: bootstrap + data-path-rekeyed so B becomes established and its data
|
|
// path is usable.
|
|
bootstrap(t, dA, dB)
|
|
dA.OnDataPathRekeyed("bbbb", 0)
|
|
dB.OnDataPathRekeyed("aaaa", 0)
|
|
require.NotEqual(t, PSK{}, wgB.psk("aaaa"))
|
|
|
|
// Drop B's outbound so rekeys can no longer converge.
|
|
lbB.drop.Store(true)
|
|
|
|
// K-1 data-path rekeys must NOT raise OnRekeyFailed.
|
|
for i := 0; i < DefaultMaxRekeyFailures-1; i++ {
|
|
_, err := dB.startExchangeTest("aaaa", false, ExchangeID{})
|
|
require.NoError(t, err)
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
assert.Equal(t, 0, failedCount(wgB), "no failure before K attempts")
|
|
|
|
// The K-th failure raises it once.
|
|
_, err := dB.startExchangeTest("aaaa", false, ExchangeID{})
|
|
require.NoError(t, err)
|
|
assert.Eventually(t, func() bool { return failedCount(wgB) == 1 }, time.Second, 5*time.Millisecond)
|
|
}
|
|
|
|
// TestManager_InitiatorRejectsOfferFromResponderRole verifies the role guard: an offer
|
|
// reaching the peer that is the initiator (here dB) must be dropped, not adopted — else a
|
|
// stray/injected offer would overwrite the live PSK and silently drop any in-flight
|
|
// exchange. dA (the role-responder) crafts an offer and injects it into dB (the initiator).
|
|
func TestManager_InitiatorRejectsOfferFromResponderRole(t *testing.T) {
|
|
dA, dB, _, wgB, _ := pair(t) // dB ("bbbb") is the initiator, dA ("aaaa") the responder
|
|
defer dA.Stop()
|
|
defer dB.Stop()
|
|
|
|
bootstrap(t, dA, dB)
|
|
psk1 := wgB.psk("aaaa")
|
|
require.NotEqual(t, PSK{}, psk1, "bootstrap established a PSK")
|
|
|
|
// 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)
|
|
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")
|
|
}
|