[client] pqkem: resend the in-flight KEM offer during an offer glare

The offer exchange is installed before the Signal send, so a failed send
leaves it in flight with nothing delivered. Ignoring the responder's offer
then stalled the connection until the exchange timed out. Resending the
same offer delivers it; the responder answers a duplicate from its cache.
This commit is contained in:
riccardom
2026-10-22 13:26:21 +02:00
parent 95a976db88
commit 3a6e634130
2 changed files with 17 additions and 12 deletions
+10 -6
View File
@@ -207,17 +207,21 @@ func (h *Handshaker) handleRemoteAnswer(remoteOfferAnswer OfferAnswer) {
// The re-offer reuses our stable ICE session id, so the peer dedups repeats. // The re-offer reuses our stable ICE session id, so the peer dedups repeats.
// //
// Returns true when the caller must not answer the peer's offer: either a re-offer went // Returns true when the caller must not answer the peer's offer: either a re-offer went
// out, or our own KEM offer is already in flight (an offer glare). In the glare the // out, or our own KEM offer is already in flight (an offer glare). In the glare we send
// peer's credentials reach us in its answer to our offer, and a KEM-less answer from us // that same offer again instead of answering: a KEM-less answer could let the peer bring
// could let the peer bring WireGuard up before it reads our offer, on no PSK at all. // WireGuard up before it reads our offer, on no PSK at all. Resending covers an earlier
// Otherwise, as for a non-capable peer, it returns false and the caller answers normally: // send that failed after the exchange was installed, and the peer answers a duplicate
// dropping the offer then would lose the only copy of the peer's ICE and relay details. // from its cache. Otherwise, as for a non-capable peer, it returns false and the caller
// answers normally: the peer's offer is then the only copy of its ICE and relay details.
func (h *Handshaker) pqControllerReoffer() bool { func (h *Handshaker) pqControllerReoffer() bool {
if h.config.PQ == nil || !isController(h.config) { if h.config.PQ == nil || !isController(h.config) {
return false return false
} }
if h.config.PQ.SignalOfferInFlight(h.config.Key) { if h.config.PQ.SignalOfferInFlight(h.config.Key) {
h.log.Debugf("pqkem: controller received a responder offer while its own KEM offer is in flight, leaving it unanswered") h.log.Debugf("pqkem: controller received a responder offer while its own KEM offer is in flight, resending that offer instead of answering")
if err := h.sendOffer(); err != nil {
h.log.Errorf("failed to resend KEM offer during an offer glare: %s", err)
}
return true return true
} }
if !h.config.PQ.ShouldSendBootstrapOffer(h.config.Key) { if !h.config.PQ.ShouldSendBootstrapOffer(h.config.Key) {
+7 -6
View File
@@ -64,18 +64,19 @@ func TestHandshaker_ControllerReoffersOnResponderOffer(t *testing.T) {
"the controller must reply with its own offer, not an answer") "the controller must reply with its own offer, not an answer")
} }
// TestHandshaker_ControllerIgnoresResponderOfferInGlare covers an offer glare: the // TestHandshaker_ControllerResendsOfferInGlare covers an offer glare: the controller's KEM
// controller's KEM offer and the responder's KEM-less offer cross. The controller must leave // offer and the responder's KEM-less offer cross. The controller must not answer: a
// the responder's offer unanswered. A KEM-less answer could reach the responder before the // KEM-less answer could reach the responder before the KEM offer and let it bring WireGuard
// KEM offer and let it bring WireGuard up with no PSK. // up with no PSK. It sends its in-flight offer again instead, in case the first send failed.
func TestHandshaker_ControllerIgnoresResponderOfferInGlare(t *testing.T) { func TestHandshaker_ControllerResendsOfferInGlare(t *testing.T) {
h, sent := newControllerHandshaker(t, bootstrapPQ{inFlight: true}) h, sent := newControllerHandshaker(t, bootstrapPQ{inFlight: true})
notified := false notified := false
h.AddICEListener(func(*OfferAnswer) { notified = true }) // called synchronously h.AddICEListener(func(*OfferAnswer) { notified = true }) // called synchronously
h.handleRemoteOffer(OfferAnswer{IceCredentials: IceCredentials{UFrag: "ufrag", Pwd: "pwd"}}) h.handleRemoteOffer(OfferAnswer{IceCredentials: IceCredentials{UFrag: "ufrag", Pwd: "pwd"}})
assert.Empty(t, sent(), "in a glare the controller must send neither an answer nor a second offer") assert.Equal(t, []sProto.Body_Type{sProto.Body_OFFER}, sent(),
"in a glare the controller must resend its offer, not answer")
assert.False(t, notified, "in a glare the controller must not bring the connection up from the responder's offer") assert.False(t, notified, "in a glare the controller must not bring the connection up from the responder's offer")
} }