[client] pqkem: signal an offer-build failure with a marker, not an empty offer

The error-vs-"no KEM" ambiguity fixed on the answer path also existed on the
offer path: when SignalOffer failed to build the bootstrap offer (e.g. an entropy
failure in startExchangeLocked), OfferPayload logged it and still sent an empty
MlkemPayload. The responder read that as "peer does not run the KEM", marked the
initiator non-capable, and answered empty — which made the initiator mark the
responder non-capable too, disabling the KEM for the session over one transient
error.

Return the MsgError marker from SignalOffer on failure, and have the responder's
SignalOnOffer answer a received marker with its own marker rather than an empty
answer. A genuinely non-KEM peer still sends no payload at all, so the empty
capability signal is unchanged.

Found in cubic review on #7098 (client/internal/pqkem_adapter.go:130).
This commit is contained in:
riccardom
2026-10-09 09:53:00 +02:00
parent 2bb557cc5c
commit 50a0bfcaaf
+14 -1
View File
@@ -339,7 +339,14 @@ func (m *Manager) SignalOffer(remoteID RemoteID) ([]byte, error) {
// for the same peer can't also start an exchange. bootstrap offer acks nothing.
raw, err := m.startExchangeLocked(remoteID, true, ExchangeID{})
m.mu.Unlock()
return raw, err
if err != nil {
// Return an error marker, not an empty offer: an empty MlkemPayload is the "peer
// does not run the KEM" capability signal, so the responder would wrongly mark us
// non-capable (and answer empty, making us mark it non-capable in turn). The marker
// is benign on the far side; the host retries the offer.
return (&ErrorMsg{}).Encode(), err
}
return raw, nil
}
// ShouldSendBootstrapOffer reports whether we should emit a fresh KEM offer to kick a
@@ -367,6 +374,12 @@ func (m *Manager) SignalOnOffer(remoteID RemoteID, offer []byte) ([]byte, error)
if err != nil {
return nil, fmt.Errorf("decode signal offer from %s: %w", remoteID, err)
}
if typ == MsgError {
// The initiator failed to build its offer and sent a marker. Answer with our own
// marker, not an empty answer, so it is not read as "peer does not run the KEM".
m.trace("pqkem: peer reported an error building its offer", "peer", remoteID)
return (&ErrorMsg{}).Encode(), nil
}
if typ != MsgOffer {
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
}