mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 12:59:08 +02:00
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.
70 lines
2.7 KiB
Go
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")
|
|
}
|