Files
netbird/client/internal/pqkem_adapter.go
T
riccardom 2a2f7a9bbc [client] pqkem: report a peer as quantum resistant once its PSK lands
The per-peer "Quantum resistance" flag was computed only when the relay or
ICE connection came up. When the KEM answer lands after the WireGuard
endpoint is configured (the controller answering a crossing responder offer,
the common case under lazy connections), the peer kept being reported as not
converged while the PSK was programmed and traffic flowed. Reproduced in 4 of
7 fresh lab runs.

Mark the peer in the status recorder once OnNewPSKReady has programmed the
PSK, and OR that mark into every relay/ICE status update under the recorder's
lock. The connection path reads the PQ state before configuring WireGuard and
writes the status afterwards, so without the mark its stale "false" would
clear the flag again. The mark is dropped with the peer, matching the PSK's
lifetime, and the update is best-effort: a peer missing from the recorder
never fails the PSK apply. Status-only change; PSK programming and strict
gating are unaffected.
2026-10-19 15:08:34 +02:00

215 lines
8.8 KiB
Go

package internal
import (
"net/netip"
"sync"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/internal/pqkem"
)
// appliedGenerations records the newest PSK generation applied per peer, so an
// out-of-order OnNewPSKReady (two exchanges deriving concurrently) can't restore an
// older PSK over a newer one.
type appliedGenerations struct {
mu sync.Mutex
last map[string]uint64
}
func newAppliedGenerations() *appliedGenerations {
return &appliedGenerations{last: make(map[string]uint64)}
}
// claim reports whether gen is newer than the last applied for the peer; when it is, it
// records gen as the newest and returns true. The check and record are atomic, so the
// slow SetPresharedKey call runs outside this lock.
func (a *appliedGenerations) claim(peer string, gen uint64) bool {
a.mu.Lock()
defer a.mu.Unlock()
if gen <= a.last[peer] {
return false
}
a.last[peer] = gen
return true
}
// pqPresharedKeySetter is the subset of the WireGuard interface the ML-KEM callback
// needs: programming a peer's preshared key. *iface.WGIface satisfies it.
type pqPresharedKeySetter interface {
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
}
// pqStatusMarker is the subset of the status recorder the ML-KEM callback needs: flagging
// a peer as post-quantum protected once its PSK is programmed. *peer.Status satisfies it.
type pqStatusMarker interface {
MarkPeerQuantumResistant(peerPubKey string) error
}
// pqCallbackHandler programs the derived PQ PSK onto the WireGuard peer. It is the
// engine-side implementation of pqkem.CallbackHandler.
type pqCallbackHandler struct {
wg pqPresharedKeySetter
// status reflects a newly programmed PSK in the peer's "Quantum resistance" flag.
// Nil disables it.
status pqStatusMarker
// applied drops an out-of-order PSK apply so a stale callback can't overwrite a newer
// key. Nil disables the guard.
applied *appliedGenerations
// 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
// if the peer is not present, mirroring Rosenpass). A callback whose generation is not
// newer than the last applied for the peer is dropped, so a reordered apply can't
// restore an older PSK over a newer one.
func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, gen uint64, psk pqkem.PSK) error {
if h.applied != nil && !h.applied.claim(string(remoteID), gen) {
log.Tracef("pqkem: dropping stale PSK apply for peer %s (gen %d)", remoteID, gen)
return nil
}
// updateOnly: applies to an already-configured peer (rotation). At bootstrap the
// peer is not configured yet, so this is a no-op there and the PSK is instead
// pulled at peer-config time (pqHandshaker.PSK / conn.presharedKey).
log.Tracef("pqkem: programming PSK for peer %s (gen %d)", remoteID, gen)
if err := h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true); err != nil {
return err
}
// The status flag is computed when the connection comes up; a PSK landing afterwards
// would otherwise leave the peer reported as not quantum resistant.
if h.status != nil {
if err := h.status.MarkPeerQuantumResistant(string(remoteID)); err != nil {
log.Debugf("pqkem: mark peer %s quantum resistant in status: %v", remoteID, err)
}
}
return nil
}
// 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, re-bootstrapping over signal", remoteID)
if h.reoffer != nil {
h.reoffer(string(remoteID))
}
return nil
}
// pqHandshaker adapts the pqkem manager to peer.PQHandshaker (string peer keys),
// wiring the host's signalling offers/answers to the KEM exchange.
type pqHandshaker struct {
mgr *pqkem.Manager
}
// announcedPort is the PQ data-path port to advertise to peers. It is omitted (0) when
// the manager is on DefaultPort, since peers assume the default when no port is sent;
// only a non-default (collision-forced) port is announced explicitly.
func (p pqHandshaker) announcedPort() uint16 {
if port := p.mgr.LocalPort(); port != DefaultPort {
return uint16(port)
}
return 0
}
// OfferPayload builds the KEM offer to attach to an outgoing signalling offer for the
// peer, plus the data-path port to announce (0 when on DefaultPort). Payload is nil when
// this side has no offer to send.
func (p pqHandshaker) OfferPayload(remoteKey string) ([]byte, uint16) {
payload, err := p.mgr.SignalOffer(pqkem.RemoteID(remoteKey))
if err != nil {
log.Warnf("pqkem: build offer for %s: %v", remoteKey, err)
}
return payload, p.announcedPort()
}
// ShouldSendBootstrapOffer reports whether the controller should reply to the peer's
// KEM-less offer with its own bootstrap offer instead of an answer.
func (p pqHandshaker) ShouldSendBootstrapOffer(remoteKey string) bool {
return p.mgr.ShouldSendBootstrapOffer(pqkem.RemoteID(remoteKey))
}
// AnswerPayload processes a received KEM offer (nil when absent) and returns the KEM
// answer to attach to the outgoing signalling answer, plus the data-path port to announce
// (0 when on DefaultPort). An empty offer is treated as a capability signal.
func (p pqHandshaker) AnswerPayload(remoteKey string, recvOffer []byte) ([]byte, uint16) {
if len(recvOffer) == 0 {
// Capability signal (responder side): the KEM offer flows initiator->responder,
// so if we are the responder for this peer (it is the KEM initiator by role) an
// empty offer means it does not run the KEM. If we are the initiator, an empty
// offer is normal — the peer is the responder and puts its material in the
// answer — so we must not flag it.
if !p.mgr.IsInitiator(pqkem.RemoteID(remoteKey)) {
p.mgr.MarkNonCapable(pqkem.RemoteID(remoteKey))
}
return nil, p.announcedPort()
}
payload, err := p.mgr.SignalOnOffer(pqkem.RemoteID(remoteKey), recvOffer)
if err != nil {
log.Warnf("pqkem: build answer for %s: %v", remoteKey, err)
}
return payload, p.announcedPort()
}
// OnAnswer feeds a received KEM answer (nil when absent) into the exchange. An empty
// answer to our offer is treated as a capability signal on the initiator side.
func (p pqHandshaker) OnAnswer(remoteKey string, recvAnswer []byte) {
if len(recvAnswer) == 0 {
// Capability signal (initiator side): the KEM answer flows responder->initiator,
// so an empty answer to our offer means the peer does not run the KEM — mark it
// non-capable to stop offering (no failure/reoffer storm). Only meaningful when
// we are the initiator: as the responder we also receive an (empty) answer to
// our own non-KEM offer from a perfectly capable peer, which must not be flagged.
if p.mgr.IsInitiator(pqkem.RemoteID(remoteKey)) {
p.mgr.MarkNonCapable(pqkem.RemoteID(remoteKey))
}
return
}
if err := p.mgr.SignalOnAnswer(pqkem.RemoteID(remoteKey), recvAnswer); err != nil {
log.Warnf("pqkem: process answer from %s: %v", remoteKey, err)
}
}
// PSK exposes the peer's derived PSK for the conn to program at WG peer-config time.
func (p pqHandshaker) PSK(remoteKey string) (wgtypes.Key, bool) {
psk, ok := p.mgr.PSK(pqkem.RemoteID(remoteKey))
if !ok {
return wgtypes.Key{}, false
}
return wgtypes.Key(psk), true
}
// SetRemoteAddr registers the peer's data-path endpoint learned from signalling. A
// zero port means the peer omitted it (it is on DefaultPort), so we resolve it here —
// DefaultPort lives in this package, not in peer. Sends only ever fire once the tunnel
// is up (clocked by OnDataPathRekeyed), so registering here is safe even before
// connection-up.
func (p pqHandshaker) SetRemoteAddr(remoteKey string, addr netip.AddrPort) {
if !addr.Addr().IsValid() {
return
}
port := addr.Port()
if port == 0 {
port = DefaultPort
}
p.mgr.AddPeer(pqkem.RemoteID(remoteKey), netip.AddrPortFrom(addr.Addr(), port))
}
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh WG handshake.
// sinceActivity is how long ago the peer last exchanged real user data; the manager
// skips rotation for idle tunnels.
func (p pqHandshaker) OnDataPathRekeyed(remoteKey string, sinceActivity time.Duration) {
p.mgr.OnDataPathRekeyed(pqkem.RemoteID(remoteKey), sinceActivity)
}
// OnDataPathDown signals the peer's tunnel went down.
func (p pqHandshaker) OnDataPathDown(remoteKey string) {
p.mgr.OnDataPathDown(pqkem.RemoteID(remoteKey))
}