Files
netbird/client/internal/pqkem/split_test.go
T
riccardom 2853a16297 [client] pqkem: answer every responder offer with a fresh KEM bootstrap
Only the controller's offer carries KEM material, so when the responder
starts a negotiation the controller is meant to reply with its own KEM
offer. It only did so before the very first exchange: once it held a
converged PSK it answered the responder's offer normally and kept that PSK,
on the assumption that a stale key would heal through the WireGuard watcher
reconnecting over Signal.

That assumption fails when the reconnect comes from the responder. If every
answer of a data-path rotation is lost, the responder commits the new PSK
and the controller does not, so WireGuard cannot handshake and no further
rotation runs to escalate. The watcher closes the connection after ~3.5
minutes and ICE reconnects in seconds, but the reconnect offer came from the
responder, the controller kept its stale key, and the tunnel stayed down.
In the lab it took 201 s and a lazy-connection idle/wake cycle to heal; with
lazy connections off, the default, nothing would heal it.

Treat a converged exchange as not in flight: a responder offer then always
gets a fresh bootstrap, so any responder-started renegotiation re-aligns
both PSKs. While the controller's own exchange is still in flight a
crossing offer is answered normally as before, so offers cannot multiply.
The cost is one extra Signal KEM round per responder-started reconnect.
2026-10-20 16:38:00 +02:00

70 lines
2.7 KiB
Go

package pqkem
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestManager_ResponderOfferRebootstrapsSplitKeys reproduces a split PSK and checks that a
// reconnect started by the responder heals it. When every answer of a rotation is lost,
// the responder has committed the new PSK and the initiator has not, so WireGuard can no
// longer handshake. The connection layer then reconnects, and the reconnect offer may come
// from the responder, carrying no KEM. The controller used to answer it normally whenever
// it held a converged exchange, keeping its stale key, so the tunnel stayed down. A
// responder offer must instead be answered with a fresh KEM bootstrap.
func TestManager_ResponderOfferRebootstrapsSplitKeys(t *testing.T) {
sw := newSwitch()
wgA, wgB := newFakeWG(), newFakeWG()
dA := NewManager("aaaa", wgA, nil) // responder
dB := NewManager("bbbb", wgB, nil) // initiator (controller)
dB.retryInterval = 10 * time.Millisecond
dB.maxRetries = 2
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)
// Lose every answer of a rotation: the responder commits, the initiator times out.
lbA.drop.Store(true)
dB.OnDataPathRekeyed("aaaa", 0)
require.Eventually(t, func() bool {
dB.mu.Lock()
defer dB.mu.Unlock()
ex := dB.exchanges["aaaa"]
return ex == nil || ex.state != stateAwaitingAnswer
}, time.Second, 5*time.Millisecond, "the rotation must time out on the initiator")
lbA.drop.Store(false)
require.NotEqual(t, wgA.psk("bbbb"), wgB.psk("aaaa"), "precondition: the two PSKs are split")
// The responder's reconnect offer carries no KEM: the controller must answer it with
// a fresh bootstrap, which brings both peers back to one PSK.
require.True(t, dB.ShouldSendBootstrapOffer("aaaa"),
"a controller holding a converged exchange must still re-bootstrap on a responder offer")
bootstrap(t, dA, dB)
assert.Equal(t, wgA.psk("bbbb"), wgB.psk("aaaa"), "the re-bootstrap must heal the split PSKs")
}
// TestManager_NoRebootstrapWhileExchangeInFlight keeps the glare protection: while the
// controller's own exchange is waiting for its answer, a crossing responder offer must not
// start a second exchange, which would supersede the first and churn the keys.
func TestManager_NoRebootstrapWhileExchangeInFlight(t *testing.T) {
dA, dB, _, _, _ := pair(t)
defer dA.Stop()
defer dB.Stop()
offer, err := dB.SignalOffer("aaaa")
require.NoError(t, err)
require.NotNil(t, offer, "the controller's bootstrap must be in flight")
assert.False(t, dB.ShouldSendBootstrapOffer("aaaa"),
"no second exchange while the controller's own offer awaits its answer")
}