mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-09 23:19:11 +02:00
Capability is signalled in-band by an empty answer: a peer that does not run the KEM answers with no payload. But a responder that runs the KEM and simply fails to build the answer (crypto error, failed PSK program, a future protocol version it cannot parse) also produced an empty payload, so the initiator read it as "peer has no KEM" and marked it non-capable — permanently, since only peer removal clears the flag; in strict mode the peer stayed blocked. Add a payload-less MsgError marker. On a signalling-path failure the responder returns the marker instead of an empty answer, and the initiator treats it as "capable but failed this round": it leaves the exchange to time out and re-bootstrap rather than marking the peer non-capable. A genuinely non-KEM peer still sends no payload at all, so the empty-answer capability signal is unchanged. Found in cubic review on #7098 (client/internal/pqkem_adapter.go:72).
99 lines
3.7 KiB
Go
99 lines
3.7 KiB
Go
package pqkem
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestManager_NonCapablePeerNotOffered: a peer known not to run the KEM (it advertised
|
|
// no PQ port over signalling) is never offered an exchange, and no failure is raised —
|
|
// this is what stops the reoffer 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)
|
|
|
|
// Now we learn the peer is non-PQ: the exchange must be dropped.
|
|
d.MarkNonCapable("aaaa")
|
|
|
|
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"))
|
|
}
|