Files
netbird/client/internal/pqkem_adapter_test.go
T
riccardom e9a622c895 [client] pqkem: apply PSK generations to WireGuard in order
Two derivations for the same peer can overlap, for example a rotation and
a Signal re-bootstrap. The generation guard checked and recorded the
generation under a lock but called SetPresharedKey after releasing it, so an
older apply already inside SetPresharedKey could finish after a newer one
and leave WireGuard on the stale key, splitting the peers until the next
exchange (reported in the security review of #7098).

Hold the lock across the generation check and the SetPresharedKey call. The
lock only serialises PSK applies, which are quick local calls, so this does
not reach into the manager's own locking.
2026-10-19 17:02:15 +02:00

164 lines
5.5 KiB
Go

package internal
import (
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/pqkem"
)
// fakePSKSetter records PSK programming and can be made to fail.
type fakePSKSetter struct {
err error
calls int
}
func (f *fakePSKSetter) SetPresharedKey(string, wgtypes.Key, bool) error {
f.calls++
return f.err
}
const pqTestPeerKey = "eDQmZp9jhQGX7XdYpHGaFSpAouldr3Sbf7MZRcGTGGQ="
func newPQTestStatus(t *testing.T) *peer.Status {
t.Helper()
status := peer.NewRecorder("https://mgm")
require.NoError(t, status.AddPeer(pqTestPeerKey, "peer.netbird.cloud", "100.64.0.2", ""))
return status
}
// TestPQCallbackHandler_MarksPeerQuantumResistant reproduces the stale status flag: when
// the KEM answer lands after the connection was set up, the per-peer "Quantum resistance"
// flag was computed without a PSK and never refreshed. Programming the PSK must flag it.
func TestPQCallbackHandler_MarksPeerQuantumResistant(t *testing.T) {
status := newPQTestStatus(t)
setter := &fakePSKSetter{}
h := pqCallbackHandler{wg: setter, applied: newAppliedGenerations(), status: status}
require.NoError(t, h.OnNewPSKReady(pqkem.RemoteID(pqTestPeerKey), 1, pqkem.PSK{1}))
st, err := status.GetPeer(pqTestPeerKey)
require.NoError(t, err)
assert.Equal(t, 1, setter.calls, "the PSK must be programmed once")
assert.True(t, st.RosenpassEnabled, "a programmed PQ PSK must mark the peer quantum resistant")
}
// TestPQCallbackHandler_FailedApplyKeepsFlag verifies the flag only follows a PSK that was
// actually programmed: a failed SetPresharedKey must leave the peer unflagged.
func TestPQCallbackHandler_FailedApplyKeepsFlag(t *testing.T) {
status := newPQTestStatus(t)
h := pqCallbackHandler{wg: &fakePSKSetter{err: errors.New("boom")}, applied: newAppliedGenerations(), status: status}
require.Error(t, h.OnNewPSKReady(pqkem.RemoteID(pqTestPeerKey), 1, pqkem.PSK{1}))
st, err := status.GetPeer(pqTestPeerKey)
require.NoError(t, err)
assert.False(t, st.RosenpassEnabled, "a PSK that failed to program must not mark the peer quantum resistant")
}
// TestPQCallbackHandler_UnknownStatusPeerDoesNotFailApply verifies the status update is
// best-effort: a peer missing from the status recorder must not fail the PSK apply.
func TestPQCallbackHandler_UnknownStatusPeerDoesNotFailApply(t *testing.T) {
setter := &fakePSKSetter{}
h := pqCallbackHandler{wg: setter, applied: newAppliedGenerations(), status: peer.NewRecorder("https://mgm")}
assert.NoError(t, h.OnNewPSKReady(pqkem.RemoteID(pqTestPeerKey), 1, pqkem.PSK{1}))
assert.Equal(t, 1, setter.calls, "the PSK must still be programmed")
}
// TestAppliedGenerations_DropsStale verifies the out-of-order guard: a generation is
// accepted only when it is strictly newer than the last one applied for that peer, so a
// reordered PSK callback cannot restore an older key over a newer one.
func TestAppliedGenerations_DropsStale(t *testing.T) {
a := newAppliedGenerations()
accept := func(peer string, gen uint64) bool {
ran, err := a.apply(peer, gen, func() error { return nil })
require.NoError(t, err)
return ran
}
if !accept("peerA", 1) {
t.Fatal("first generation must be accepted")
}
if !accept("peerA", 2) {
t.Fatal("a newer generation must be accepted")
}
if accept("peerA", 2) {
t.Fatal("re-applying the same generation must be dropped")
}
if accept("peerA", 1) {
t.Fatal("an older generation arriving late must be dropped")
}
if !accept("peerA", 3) {
t.Fatal("a newer generation after a dropped stale one must still be accepted")
}
// Generations are tracked independently per peer.
if !accept("peerB", 1) {
t.Fatal("a different peer's first generation must be accepted")
}
}
// blockingPSKSetter holds the first SetPresharedKey call until released, recording the
// key WireGuard ends up with.
type blockingPSKSetter struct {
mu sync.Mutex
calls int
last wgtypes.Key
entered chan struct{}
release chan struct{}
}
func (s *blockingPSKSetter) SetPresharedKey(_ string, psk wgtypes.Key, _ bool) error {
s.mu.Lock()
s.calls++
first := s.calls == 1
s.mu.Unlock()
if first {
close(s.entered)
<-s.release
}
s.mu.Lock()
s.last = psk
s.mu.Unlock()
return nil
}
// TestPQCallbackHandler_AppliesGenerationsInOrder covers two PSK callbacks for one peer
// overlapping. The newer generation must be the one WireGuard keeps: with the generation
// checked under a lock but the key set outside it, an older apply that was already
// writing could finish last and restore its stale key.
func TestPQCallbackHandler_AppliesGenerationsInOrder(t *testing.T) {
setter := &blockingPSKSetter{entered: make(chan struct{}), release: make(chan struct{})}
h := pqCallbackHandler{wg: setter, applied: newAppliedGenerations()}
older, newer := pqkem.PSK{3}, pqkem.PSK{4}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
assert.NoError(t, h.OnNewPSKReady(pqkem.RemoteID(pqTestPeerKey), 3, older))
}()
<-setter.entered // the older apply is now inside SetPresharedKey
go func() {
defer wg.Done()
assert.NoError(t, h.OnNewPSKReady(pqkem.RemoteID(pqTestPeerKey), 4, newer))
}()
// Give the newer apply the chance to overtake the blocked older one.
time.Sleep(50 * time.Millisecond)
close(setter.release)
wg.Wait()
setter.mu.Lock()
defer setter.mu.Unlock()
assert.Equal(t, wgtypes.Key(newer), setter.last, "WireGuard must keep the newest generation's PSK")
}