mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-21 12:59:08 +02:00
The recovery that re-runs the ML-KEM bootstrap after a failed exchange was called RequestReoffer (and "reoffer" along the callback chain). The name only says that another offer goes out, so it reads like routine renegotiation or key rotation, while it fires only when the exchange has failed: the initial exchange, or rotations failing repeatedly. Normal rotation never takes this path; it runs over the data path. Rename it RecoverPQKeyOverSignal, and the callback field, engine wiring, log line, comments and test with it. The controller re-offer in the handshaker is a different mechanism and keeps its name. Pure rename, no behaviour change; only the debug log wording follows the new name.
228 lines
9.2 KiB
Go
228 lines
9.2 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)}
|
|
}
|
|
|
|
// apply runs set when gen is newer than the last generation applied for the peer, and
|
|
// reports whether it ran. The check and set run under one lock: with only the check
|
|
// atomic, an older apply already inside set could finish after a newer one and restore
|
|
// its stale key. The generation is recorded before set runs, so a failed newer apply
|
|
// still keeps an older one from landing afterwards. The lock is shared by all peers,
|
|
// which is cheap because set is a quick local interface call.
|
|
func (a *appliedGenerations) apply(peer string, gen uint64, set func() error) (bool, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if gen <= a.last[peer] {
|
|
return false, nil
|
|
}
|
|
a.last[peer] = gen
|
|
return true, set()
|
|
}
|
|
|
|
// 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
|
|
// recoverPQKeyOverSignal re-bootstraps the KEM over Signal for a peer (a fresh
|
|
// signalling offer) after its exchange failed. Nil disables recovery.
|
|
recoverPQKeyOverSignal 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 {
|
|
// 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).
|
|
set := func() error {
|
|
log.Tracef("pqkem: programming PSK for peer %s (gen %d)", remoteID, gen)
|
|
return h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true)
|
|
}
|
|
if h.applied == nil {
|
|
if err := set(); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
applied, err := h.applied.apply(string(remoteID), gen, set)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !applied {
|
|
log.Tracef("pqkem: dropping stale PSK apply for peer %s (gen %d)", remoteID, gen)
|
|
return nil
|
|
}
|
|
}
|
|
// 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.recoverPQKeyOverSignal != nil {
|
|
h.recoverPQKeyOverSignal(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/recovery 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))
|
|
}
|