Files
netbird/client/internal/pqkem/capability_test.go
T
riccardom 50afdff228 [client] pqkem: answer an unreadable offer with an error marker
An empty answer tells the initiator the peer does not run the KEM, and it
stops offering for good. A decode or version error is not that: a newer
client talking to an older one would silently stay classical.
2026-10-22 12:14:21 +02:00

190 lines
6.7 KiB
Go

package pqkem
import (
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestManager_NonCapablePeerNotOffered: a peer known not to run the KEM (it answered our
// offer with an empty KEM payload over signalling) is never offered an exchange, and no
// failure is raised — this is what stops the recovery storm against non-PQ peers (e.g.
// Rosenpass peers).
func TestManager_NonCapablePeerNotOffered(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil) // initiator vs "aaaa"
d.Start(&loopback{ep: epB, sw: newSwitch()})
defer d.Stop()
d.MarkNonCapable("aaaa")
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
assert.Nil(t, offer, "a non-capable peer must not be offered a KEM exchange")
assert.Empty(t, wg.failed, "a non-capable peer must not raise a rekey failure")
}
// TestManager_MarkNonCapableCancelsInFlight: if we start an exchange with a peer whose
// capability is not yet known and then learn it does not run the KEM, the in-flight
// exchange is cancelled and no further offer is produced (no timeout -> no failure).
func TestManager_MarkNonCapableCancelsInFlight(t *testing.T) {
wg := newFakeWG()
d := NewManager("bbbb", wg, nil)
d.Start(&loopback{ep: epB, sw: newSwitch()})
defer d.Stop()
// Capability unknown -> the bootstrap offer goes out optimistically.
offer, err := d.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
// Record the cancellation of the in-flight exchange's loop. Without it the loop would
// time out and raise OnRekeyFailed, the re-bootstrap storm MarkNonCapable prevents.
var cancelled atomic.Bool
d.mu.Lock()
ex := d.exchanges["aaaa"]
if ex != nil {
loopCancel := ex.cancel
ex.cancel = func() {
cancelled.Store(true)
loopCancel()
}
}
d.mu.Unlock()
// Asserted after unlocking: a failing require stops the test, and the deferred Stop
// would then block on the lock.
require.NotNil(t, ex, "precondition: the bootstrap exchange is in flight")
// Now we learn the peer is non-PQ: the exchange must be dropped.
d.MarkNonCapable("aaaa")
assert.True(t, cancelled.Load(), "the in-flight exchange's loop must be cancelled")
d.mu.Lock()
_, stillTracked := d.exchanges["aaaa"]
d.mu.Unlock()
assert.False(t, stillTracked, "the in-flight exchange must be dropped")
next, err := d.SignalOffer("aaaa")
require.NoError(t, err)
assert.Nil(t, next, "after learning non-capability the peer is no longer offered")
assert.Empty(t, wg.failed, "cancelling an in-flight exchange must not raise a failure")
}
// TestManager_EstablishedPeerNotDowngraded: a stray zero-port observation must not tear
// down a peer we already have a working PQ session with.
func TestManager_EstablishedPeerNotDowngraded(t *testing.T) {
dA, dB, _, wgB, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
bootstrap(t, dA, dB)
require.NotEqual(t, PSK{}, wgB.psk("aaaa"), "established a PSK")
dB.MarkNonCapable("aaaa") // stray zero after establishment
// The peer keeps its derived PSK (MarkNonCapable is a no-op once established).
psk, ok := dB.PSK("aaaa")
assert.True(t, ok, "an established peer must keep its PSK despite a stray zero")
assert.NotEqual(t, PSK{}, psk)
}
// TestManager_ErrorMarkerIsBenign verifies finding F's wire half: a responder failure is
// signalled with an error marker (not an empty answer), so the initiator does not read it
// as "peer has no KEM". The marker must be benign — it must not disturb the in-flight
// exchange, which still converges when the real answer arrives.
func TestManager_ErrorMarkerIsBenign(t *testing.T) {
dA, dB, wgA, wgB, _ := pair(t) // dB ("bbbb") initiator, dA ("aaaa") responder
defer dA.Stop()
defer dB.Stop()
offer, err := dB.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
_, decoded, err := Decode(offer)
require.NoError(t, err)
offerID := decoded.(*OfferMsg).ExchangeID
// An error marker for the in-flight offer must be accepted without error and must not
// tear the exchange down (unlike an empty answer, which signals non-capability).
marker := (&ErrorMsg{ExchangeID: offerID}).Encode()
require.NoError(t, dB.SignalOnAnswer("aaaa", marker))
// The real answer still converges both sides on the same PSK.
answer, err := dA.SignalOnOffer("bbbb", offer)
require.NoError(t, err)
require.NotNil(t, answer)
require.NoError(t, dB.SignalOnAnswer("aaaa", answer))
assert.Equal(t, wgB.psk("aaaa"), wgA.psk("bbbb"), "the exchange must still converge after a benign error marker")
assert.NotEqual(t, PSK{}, wgB.psk("aaaa"))
}
// TestManager_OfferErrorMarkerAnsweredWithMarker covers the other direction: the initiator
// failed to build its offer and sent a marker. The responder must answer with a marker of
// its own, never an empty answer, which the initiator would read as "peer has no KEM".
func TestManager_OfferErrorMarkerAnsweredWithMarker(t *testing.T) {
dA, dB, _, _, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
reply, err := dA.SignalOnOffer("bbbb", (&ErrorMsg{}).Encode())
require.NoError(t, err)
require.NotEmpty(t, reply, "an empty reply would mark the responder non-capable")
typ, _, err := Decode(reply)
require.NoError(t, err)
assert.Equal(t, MsgError, typ, "the reply to an offer marker must be a marker")
_, established := dA.PSK("bbbb")
assert.False(t, established, "a marker must not derive a PSK")
}
// TestManager_UnreadableOfferAnsweredWithMarker: an offer the responder cannot process, such
// as one from a newer protocol version, must get an error marker. An empty answer would make
// the initiator mark the responder non-capable for good and stay classical with it.
func TestManager_UnreadableOfferAnsweredWithMarker(t *testing.T) {
dA, dB, _, _, _ := pair(t)
defer dB.Stop()
offer, err := dB.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer)
newerVersion := append([]byte(nil), offer...)
newerVersion[1] = ProtocolVersion + 1
answerType := (&ErrorMsg{}).Encode()
answerType[0] = byte(MsgAnswer)
cases := map[string][]byte{
"garbage": {1, 2, 3},
"newer version": newerVersion,
"not an offer": answerType,
"truncated body": offer[:len(offer)-1],
}
for name, in := range cases {
t.Run(name, func(t *testing.T) {
reply, err := dA.SignalOnOffer("bbbb", in)
assert.Error(t, err)
assertErrorMarker(t, reply)
})
}
t.Run("manager stopping", func(t *testing.T) {
dA.Stop()
reply, err := dA.SignalOnOffer("bbbb", offer)
assert.Error(t, err)
assertErrorMarker(t, reply)
})
}
func assertErrorMarker(t *testing.T, reply []byte) {
t.Helper()
require.NotEmpty(t, reply, "an empty reply would mark the responder non-capable")
typ, _, err := Decode(reply)
require.NoError(t, err)
assert.Equal(t, MsgError, typ, "the reply must be an error marker")
}