From 96f23277a40c8c80526e0efb0db44dd877c23709 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