From 1c47243da85b7afdbcde6a14bc9062387a017e6b Mon Sep 17 00:00:00 2001 From: riccardom Date: Thu, 6 Aug 2026 18:04:07 +0200 Subject: [PATCH] [client] pqkem: make signalling bootstrap idempotent through awaitingRekey The controller sends its KEM offer both on its own guard event and in reply to the responder's offer. SignalOffer was idempotent only while awaiting the answer; once the answer arrived (awaitingRekey) a repeat call started a fresh exchange with a different PSK, desyncing the two peers (one on the old PSK, one on the new) so WireGuard derived misaligned transport keys and dropped all data. Treat awaitingRekey as in-flight too and return the same offer. --- client/internal/pqkem/manager.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/internal/pqkem/manager.go b/client/internal/pqkem/manager.go index fff266f38..8533833a4 100644 --- a/client/internal/pqkem/manager.go +++ b/client/internal/pqkem/manager.go @@ -285,7 +285,13 @@ func (m *Manager) SignalOffer(remoteID RemoteID) ([]byte, error) { m.mu.Unlock() return nil, nil // peer does not run the KEM; do not offer (avoids a failure/reoffer loop) } - if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal && ex.state == stateAwaitingAnswer { + // Idempotent while a signalling bootstrap is in flight OR already derived a PSK but + // not yet chained a rotation (awaitingRekey): return the SAME offer instead of + // starting a new exchange. This matters when the controller both offers on its own + // guard AND re-offers in response to the responder's offer — without this, the + // second call would start a fresh exchange (a different PSK) and desync the peers. + if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal && + (ex.state == stateAwaitingAnswer || ex.state == stateAwaitingRekey) { last := ex.lastSent m.mu.Unlock() return last, nil