From 03ce49df167af688aa19d12a94da63b303f5ca42 Mon Sep 17 00:00:00 2001 From: riccardom Date: Thu, 6 Aug 2026 15:42:27 +0200 Subject: [PATCH] Introduces a forced imparity on MLKEM bootstrap. To ensure two peers agree on a key, we need asymmetry. one peer is the controller ("initiator") the other is the "responder". Otherwise imagine two offers in parallel driving two answers at the same time A B | <----B-OFFER----- | | -----A-OFFER----> | | | | | --------------------------------- |****** ICE + WG Handshake ****** | --------------------------------- | | | <----B-ANSWER---- | | -----A-ANSWER---> | PSK is derived on receive of offer, so A and B derive different PSKs. When WG handshake takes place it picks misaligned PSKs. So we impair the two nodes and only the offer of one of the two (the controller/initiator) is allowed to progress and drive the answer (and carry the KEM material). If a responder initiates an offer, we redo the offer towards it. This is oK since the ICEworker don't treat offer/answers differently. --- client/internal/peer/handshaker.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index a5035da07..c465f6712 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -137,6 +137,24 @@ func (h *Handshaker) Listen(ctx context.Context) { h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort) + // Post-quantum: the KEM material rides only the controller's offer, so the two + // peers derive a single shared PSK (a bidirectional KEM would yield two + // different PSKs and WireGuard would pick misaligned ones). If we are the + // controller and receive the responder's (KEM-less) offer, we must NOT let + // that KEM-less transaction drive the connection: it would bring WireGuard up + // on a pre-PQ key before the KEM completes (the race). Instead we reply with + // OUR offer, which carries the KEM, so the only transaction that establishes + // the tunnel is the one that also derives the PSK. This also guarantees a + // responder-initiated wake still triggers a KEM offer (no stuck responder). + // The re-offer reuses our stable ICE session id, so the peer dedups repeats. + if h.config.PQ != nil && isController(h.config) { + h.log.Debugf("pqkem: controller received a responder offer, replying with our KEM offer instead of an answer") + if err := h.sendOffer(); err != nil { + h.log.Errorf("failed to send KEM offer in response to peer offer: %s", err) + } + continue + } + // Derive+store the KEM PSK (inside sendAnswer's AnswerPayload) BEFORE bringing // up the connection: the relay/ICE workers configure the WG endpoint, which // pulls the PSK for the first handshake. Notifying them first would race the