From 1bfda2793bf30bf899f0cf3193a49d7beb9c00c1 Mon Sep 17 00:00:00 2001 From: riccardom Date: Fri, 31 Jul 2026 10:47:07 +0200 Subject: [PATCH] pqkem: recover from persistent rekey failure by re-bootstrapping over signal OnRekeyFailed now re-runs the KEM bootstrap over Signal (conn.RequestReoffer -> handshaker.SendOffer) instead of only logging: a fresh signalling offer starts a new exchange that overwrites the stalled PSK on both sides, resyncing after a persistent data-path desync. Chosen over a responder-side awaitingAck revert (which fights the confirm-less ack timing) and a full tunnel teardown (heavier). The tunnel stays up on the previous PSK meanwhile since Signal is independent of the broken data path. --- client/internal/engine.go | 13 ++++++++++++- client/internal/peer/conn.go | 16 ++++++++++++++++ client/internal/pqkem_adapter.go | 15 ++++++++++++--- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index f60d43743..3fbd8b64c 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -663,7 +663,18 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) if pqErr != nil { log.Errorf("pqkem: transport bind failed, exchange disabled: %v", pqErr) } else { - e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), pqCallbackHandler{wg: e.wgInterface}, pqkem.NewLogger()) + cbHandler := pqCallbackHandler{ + wg: e.wgInterface, + // On a persistent rekey failure, re-bootstrap the KEM over Signal: a + // fresh signalling offer starts a new exchange that overwrites the + // stalled PSK on both sides, recovering from a data-path desync. + reoffer: func(remoteKey string) { + if conn, ok := e.peerStore.PeerConn(remoteKey); ok { + conn.RequestReoffer() + } + }, + } + e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), cbHandler, pqkem.NewLogger()) e.pqkemManager.Start(tr) log.Infof("pqkem: enabled (udp port %d on overlay %s)", e.pqkemManager.LocalPort(), e.config.WgAddr.IP) } diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index ddc57c0d5..1e0904bf3 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -719,6 +719,22 @@ func (conn *Conn) onGuardEvent() { } } +// RequestReoffer sends a fresh signalling offer for the peer, re-running the +// post-quantum bootstrap over Signal. Used to recover from a persistent data-path +// rekey failure: a new exchange overwrites the stalled PSK on both sides. No-op if the +// connection is not open yet. +func (conn *Conn) RequestReoffer() { + conn.mu.Lock() + h := conn.handshaker + conn.mu.Unlock() + if h == nil { + return + } + if err := h.SendOffer(); err != nil { + conn.Log.Debugf("pqkem: recovery re-offer failed: %v", err) + } +} + func (conn *Conn) onWGDisconnected(watcherCtx context.Context) { conn.mu.Lock() defer conn.mu.Unlock() diff --git a/client/internal/pqkem_adapter.go b/client/internal/pqkem_adapter.go index fe41082ad..7a66c8a69 100644 --- a/client/internal/pqkem_adapter.go +++ b/client/internal/pqkem_adapter.go @@ -20,6 +20,9 @@ type pqPresharedKeySetter interface { // engine-side implementation of pqkem.CallbackHandler. type pqCallbackHandler struct { wg pqPresharedKeySetter + // reoffer re-bootstraps the KEM over Signal for a peer (a fresh signalling offer) + // to recover from a persistent data-path rekey failure. Nil disables recovery. + reoffer func(remoteKey string) } // OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op @@ -32,10 +35,16 @@ func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, psk pqkem.PSK) return h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true) } -// OnRekeyFailed reports a failed PQ (re)key convergence. -// TODO(NET-1406): tear the peer connection down / trigger ICE reconnect. +// OnRekeyFailed reports a failed PQ (re)key convergence and re-bootstraps the KEM over +// Signal to recover: a fresh signalling offer starts a new exchange that overwrites the +// stalled PSK on both sides, resyncing after a persistent data-path desync. The tunnel +// stays up on the previous PSK meanwhile (the Signal channel is independent of the +// broken data path). func (h pqCallbackHandler) OnRekeyFailed(remoteID pqkem.RemoteID) error { - log.Warnf("pqkem: post-quantum rekey failed for peer %s", remoteID) + log.Warnf("pqkem: post-quantum rekey failed for peer %s, re-bootstrapping over signal", remoteID) + if h.reoffer != nil { + h.reoffer(string(remoteID)) + } return nil }