[client] pqkem: address review findings on convergence, strict parsing and status

Bot review (CodeRabbit, cubic) on PR #7098 surfaced several real defects:

- Strict() parsed the raw env value instead of the normalized one, so a
  mixed-case NB_PQ_MLKEM_STRICT such as "tRuE" silently disabled fail-closed
  mode. Parse the lower-cased value, matching Enabled().
- OnDataPathMessage labeled a data-path answer as "signal" in logs, mislabeling
  every rotation answer. Use the data-path label.
- A failed sendAnswer aborted the whole connection setup, skipping the relay/ICE
  listeners; a transient signalling failure now still brings the local transport
  up (the peer retries the answer).
- processOffer left the reserved exchange slot in place when Respond failed, so
  every retransmission of that offer was dropped forever. Clear the reservation
  on a pre-commit error so a retry can derive again.
- Dropped a no-op time.Since that implied a convergence-latency metric that was
  never recorded, and the now-unused startedAt field.
- The strict-mode status line asserted active blocking even for a peer that is
  simply offline; reword it to state that no PSK is established yet.
- conn_test used t.Fatalf for conditions under test; use assert.
This commit is contained in:
riccardom
2026-10-05 16:02:33 +02:00
parent dd07b12f63
commit 89ec00eb20
6 changed files with 20 additions and 17 deletions
+2 -6
View File
@@ -296,16 +296,12 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) {
// When Rosenpass has already initialized the PSK for this peer, // When Rosenpass has already initialized the PSK for this peer,
// presharedKey must return nil to avoid UpdatePeer overwriting it. // presharedKey must return nil to avoid UpdatePeer overwriting it.
conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return true } conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return true }
if k := conn.presharedKey([]byte("remote"), nil); k != nil { assert.Nil(t, conn.presharedKey([]byte("remote"), nil), "expected nil presharedKey when Rosenpass manages PSK")
t.Fatalf("expected nil presharedKey when Rosenpass manages PSK, got %v", k)
}
// When Rosenpass hasn't taken over yet, presharedKey should provide // When Rosenpass hasn't taken over yet, presharedKey should provide
// a non-nil initial key (deterministic or from NetBird PSK). // a non-nil initial key (deterministic or from NetBird PSK).
conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return false } conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return false }
if k := conn.presharedKey([]byte("remote"), nil); k == nil { assert.NotNil(t, conn.presharedKey([]byte("remote"), nil), "expected non-nil presharedKey before Rosenpass manages PSK")
t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK")
}
} }
func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn { func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn {
+3 -2
View File
@@ -171,10 +171,11 @@ func (h *Handshaker) handleRemoteOffer(remoteOfferAnswer OfferAnswer) {
// Derive+store the KEM PSK (inside sendAnswer's AnswerPayload) BEFORE bringing up the // 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 // connection: the relay/ICE workers configure the WG endpoint, which pulls the PSK
// for the first handshake. Notifying them first would race the KEM exchange and hand // for the first handshake. Notifying them first would race the KEM exchange and hand
// the first handshake a not-yet-derived key. // the first handshake a not-yet-derived key. A failure to signal the answer is
// transient (the peer retries), so still bring the local transport up over relay/ICE
// instead of aborting the whole setup.
if err := h.sendAnswer(&remoteOfferAnswer); err != nil { if err := h.sendAnswer(&remoteOfferAnswer); err != nil {
h.log.Errorf("failed to send remote offer confirmation: %s", err) h.log.Errorf("failed to send remote offer confirmation: %s", err)
return
} }
h.notifyListeners(&remoteOfferAnswer) h.notifyListeners(&remoteOfferAnswer)
} }
+9 -3
View File
@@ -67,7 +67,6 @@ func (m *Manager) startExchangeLocked(remoteID RemoteID, viaSignal bool, ackID E
m.exchanges[remoteID] = &exchangeCtl{ m.exchanges[remoteID] = &exchangeCtl{
id: id, id: id,
state: stateAwaitingAnswer, state: stateAwaitingAnswer,
startedAt: time.Now(),
cancel: cancel, cancel: cancel,
lastSent: raw, lastSent: raw,
initiator: init, initiator: init,
@@ -118,11 +117,19 @@ func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg, via string) ([]by
return last, nil return last, nil
} }
// Reserve the slot so a concurrent duplicate offer bails. // Reserve the slot so a concurrent duplicate offer bails.
m.exchanges[remoteID] = &exchangeCtl{id: o.ExchangeID, state: stateReserved, startedAt: time.Now()} m.exchanges[remoteID] = &exchangeCtl{id: o.ExchangeID, state: stateReserved}
m.mu.Unlock() m.mu.Unlock()
answerBytes, psk, err := Respond(o.KEMOffer, m.binding(remoteID)) answerBytes, psk, err := Respond(o.KEMOffer, m.binding(remoteID))
if err != nil { if err != nil {
// Respond failed before the PSK was committed: clear the reservation so a
// retransmission of this offer (a transient or malformed first packet) can retry
// instead of hitting the stuck reserved slot forever.
m.mu.Lock()
if cur := m.exchanges[remoteID]; cur != nil && cur.id == o.ExchangeID && cur.state == stateReserved {
delete(m.exchanges, remoteID)
}
m.mu.Unlock()
return nil, err return nil, err
} }
raw, err := (&AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answerBytes}).Encode() raw, err := (&AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answerBytes}).Encode()
@@ -235,7 +242,6 @@ func (m *Manager) ackConverged(remoteID RemoteID, ackID ExchangeID) {
delete(m.exchanges, remoteID) delete(m.exchanges, remoteID)
m.established[remoteID] = true m.established[remoteID] = true
m.failures[remoteID] = 0 m.failures[remoteID] = 0
_ = time.Since(ex.startedAt) // convergence latency (metrics hook, later step)
m.mu.Unlock() m.mu.Unlock()
m.trace("pqkem: previous exchange confirmed by ack", "peer", remoteID, "exchange", idHex(ackID)) m.trace("pqkem: previous exchange confirmed by ack", "peer", remoteID, "exchange", idHex(ackID))
+4 -3
View File
@@ -43,15 +43,16 @@ const EnvStrict = "NB_PQ_MLKEM_STRICT"
// Strict reports whether strict (fail-closed) mode is enabled via the environment. // Strict reports whether strict (fail-closed) mode is enabled via the environment.
// An empty or unrecognized value is treated as disabled (opportunistic). // An empty or unrecognized value is treated as disabled (opportunistic).
func Strict() bool { func Strict() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvStrict))) { raw := strings.ToLower(strings.TrimSpace(os.Getenv(EnvStrict)))
switch raw {
case "on": case "on":
return true return true
case "", "off": case "", "off":
return false return false
} }
enabled, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(EnvStrict))) enabled, err := strconv.ParseBool(raw)
if err != nil { if err != nil {
log.Warnf("failed to parse %s value %q: %v", EnvStrict, os.Getenv(EnvStrict), err) log.Warnf("failed to parse %s value %q: %v", EnvStrict, raw, err)
return false return false
} }
return enabled return enabled
+1 -2
View File
@@ -80,7 +80,6 @@ const (
type exchangeCtl struct { type exchangeCtl struct {
id ExchangeID id ExchangeID
state exchangeState state exchangeState
startedAt time.Time
cancel context.CancelFunc cancel context.CancelFunc
lastSent []byte lastSent []byte
initiator *Initiator initiator *Initiator
@@ -402,7 +401,7 @@ func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
} }
return m.pushDataPath(remoteID, answer) return m.pushDataPath(remoteID, answer)
case MsgAnswer: case MsgAnswer:
return m.processAnswer(remoteID, msg.(*AnswerMsg), viaSignalLabel) return m.processAnswer(remoteID, msg.(*AnswerMsg), viaDataPathLabel)
default: default:
return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID) return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID)
} }
+1 -1
View File
@@ -793,7 +793,7 @@ func peerQuantumResistanceStatus(established, rosenpassEnabled, rosenpassPermiss
} }
switch { switch {
case mlkemEnabled && mlkemStrict: case mlkemEnabled && mlkemStrict:
return "false (ML-KEM strict: blocking peer traffic until the exchange converges)" return "false (ML-KEM strict: no post-quantum PSK established for this peer yet)"
case mlkemEnabled: case mlkemEnabled:
return "false (ML-KEM: not converged yet, or peer does not run the exchange)" return "false (ML-KEM: not converged yet, or peer does not run the exchange)"
case rosenpassEnabled && rosenpassPermissive: case rosenpassEnabled && rosenpassPermissive: