mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 06:09:07 +02:00
Introduces a forced WG handshake on initial MLKEM bootstrap.
To ensure two peers agree on a key, we need asymmetry. one peer is
the controller ("initiator") the other is the "responder".
Otherwise imagine two offers in parallel driving two answers at the same time
A B
| <----B-OFFER----- |
| -----A-OFFER----> |
| |
| |
---------------------------------
|****** ICE + WG Handshake ****** |
---------------------------------
| |
| <----B-ANSWER---- |
| -----A-ANSWER---> |
PSK is derived on receive of offer, so A and B derive different PSKs.
When WG handshake takes place it picks misaligned PSKs.
So we impair the two nodes and only the offer of one of the two (the controller/initiator)
carries the KEM material.
This means that if the responder OFFER/ANSWER comes first, when the controller/initiator's one
completes (and the genuine PSK is shared between A and B, we need to force a new WG handshake with
the proper keys.
This commit is contained in:
@@ -670,6 +670,14 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
|||||||
conn.RequestReoffer()
|
conn.RequestReoffer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// On a freshly bootstrapped PSK, force the peer's WireGuard session to
|
||||||
|
// re-handshake so it adopts the PSK even if the tunnel already came up on
|
||||||
|
// a pre-PQ key (KEM-vs-endpoint-config race).
|
||||||
|
rehandshake: func(remoteKey string) {
|
||||||
|
if conn, ok := e.peerStore.PeerConn(remoteKey); ok {
|
||||||
|
conn.ForcePQRehandshake()
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), cbHandler, pqkem.NewLogger())
|
e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), cbHandler, pqkem.NewLogger())
|
||||||
e.pqkemManager.Start(tr)
|
e.pqkemManager.Start(tr)
|
||||||
|
|||||||
@@ -736,6 +736,35 @@ func (conn *Conn) RequestReoffer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForcePQRehandshake makes WireGuard adopt a freshly bootstrapped post-quantum PSK on
|
||||||
|
// an already-up session. The KEM exchange can complete after the WG endpoint was
|
||||||
|
// configured (a race between the KEM and the relay/ICE connection coming up), so the
|
||||||
|
// live session may be keyed with a pre-PQ key (the strict sentinel, or the ordinary
|
||||||
|
// key in permissive mode); SetPresharedKey only updates config, not the running
|
||||||
|
// session. Removing and re-adding the peer forces a fresh handshake that uses the real
|
||||||
|
// PSK. No-op if not connected yet (the upcoming endpoint config will pull the PSK) or
|
||||||
|
// if no PSK exists (a non-PQ peer stays fail-closed).
|
||||||
|
func (conn *Conn) ForcePQRehandshake() {
|
||||||
|
conn.mu.Lock()
|
||||||
|
defer conn.mu.Unlock()
|
||||||
|
|
||||||
|
if conn.ctx.Err() != nil || conn.config.PQ == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
psk, ok := conn.config.PQ.PSK(conn.config.Key)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conn.currentConnPriority == conntype.None {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Log.Debugf("pqkem: bootstrap PSK ready, forcing WireGuard re-handshake to adopt it")
|
||||||
|
wgPsk := wgtypes.Key(psk)
|
||||||
|
if err := conn.endpointUpdater.ForceRehandshake(&wgPsk); err != nil {
|
||||||
|
conn.Log.Warnf("pqkem: force re-handshake failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
||||||
conn.mu.Lock()
|
conn.mu.Lock()
|
||||||
defer conn.mu.Unlock()
|
defer conn.mu.Unlock()
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ type EndpointUpdater struct {
|
|||||||
wgConfig WgConfig
|
wgConfig WgConfig
|
||||||
initiator bool
|
initiator bool
|
||||||
|
|
||||||
// mu protects cancelFunc
|
// mu protects cancelFunc and lastEndpoint
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cancelFunc func()
|
cancelFunc func()
|
||||||
updateWg sync.WaitGroup
|
updateWg sync.WaitGroup
|
||||||
|
// lastEndpoint is the most recent non-nil endpoint applied to the peer, used to
|
||||||
|
// re-add it on a forced re-handshake (ForceRehandshake).
|
||||||
|
lastEndpoint *net.UDPAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEndpointUpdater(log *logrus.Entry, wgConfig WgConfig, initiator bool) *EndpointUpdater {
|
func NewEndpointUpdater(log *logrus.Entry, wgConfig WgConfig, initiator bool) *EndpointUpdater {
|
||||||
@@ -38,6 +41,10 @@ func (e *EndpointUpdater) ConfigureWGEndpoint(addr *net.UDPAddr, presharedKey *w
|
|||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
if addr != nil {
|
||||||
|
e.lastEndpoint = addr
|
||||||
|
}
|
||||||
|
|
||||||
if e.initiator {
|
if e.initiator {
|
||||||
e.log.Debugf("configure up WireGuard as initiator")
|
e.log.Debugf("configure up WireGuard as initiator")
|
||||||
return e.configureAsInitiator(addr, presharedKey)
|
return e.configureAsInitiator(addr, presharedKey)
|
||||||
@@ -51,12 +58,36 @@ func (e *EndpointUpdater) SwitchWGEndpoint(addr *net.UDPAddr, presharedKey *wgty
|
|||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
if addr != nil {
|
||||||
|
e.lastEndpoint = addr
|
||||||
|
}
|
||||||
|
|
||||||
// prevent to run new update while cancel the previous update
|
// prevent to run new update while cancel the previous update
|
||||||
e.waitForCloseTheDelayedUpdate()
|
e.waitForCloseTheDelayedUpdate()
|
||||||
|
|
||||||
return e.updateWireGuardPeer(addr, presharedKey)
|
return e.updateWireGuardPeer(addr, presharedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceRehandshake removes and re-adds the peer so WireGuard drops the current session
|
||||||
|
// and negotiates a fresh one with the given PSK. Used when a post-quantum PSK is
|
||||||
|
// bootstrapped after the session already came up on a pre-PQ key. No-op if no endpoint
|
||||||
|
// has been applied yet (the pending config will pull the PSK itself).
|
||||||
|
func (e *EndpointUpdater) ForceRehandshake(presharedKey *wgtypes.Key) error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
if e.lastEndpoint == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Cancel any pending delayed responder update: it carries the stale pre-PQ key and
|
||||||
|
// would otherwise re-poison the session after we reset it.
|
||||||
|
e.waitForCloseTheDelayedUpdate()
|
||||||
|
if err := e.wgConfig.WgInterface.RemovePeer(e.wgConfig.RemoteKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.updateWireGuardPeer(e.lastEndpoint, presharedKey)
|
||||||
|
}
|
||||||
|
|
||||||
func (e *EndpointUpdater) RemoveWgPeer() error {
|
func (e *EndpointUpdater) RemoveWgPeer() error {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ type CallbackHandler interface {
|
|||||||
// and must be programmed into the consumer's secure channel. It is invoked at
|
// and must be programmed into the consumer's secure channel. It is invoked at
|
||||||
// the commit point of each side: the initiator on receiving the answer, the
|
// the commit point of each side: the initiator on receiving the answer, the
|
||||||
// responder on receiving the confirm.
|
// responder on receiving the confirm.
|
||||||
OnNewPSKReady(remoteID RemoteID, psk PSK) error
|
//
|
||||||
|
// bootstrap is true when the PSK came from a signalling exchange (initial setup or
|
||||||
|
// a wake re-negotiation) rather than a data-path rotation. On bootstrap the consumer
|
||||||
|
// must ensure the live channel actually adopts the PSK now (e.g. force a WireGuard
|
||||||
|
// re-handshake) — the channel may already be up on a pre-PQ key. A rotation
|
||||||
|
// (bootstrap=false) is adopted at the channel's next natural rekey.
|
||||||
|
OnNewPSKReady(remoteID RemoteID, psk PSK, bootstrap bool) error
|
||||||
|
|
||||||
// OnRekeyFailed fires when an exchange fails to converge within the allotted
|
// OnRekeyFailed fires when an exchange fails to converge within the allotted
|
||||||
// time. The host should tear the peer connection down so it re-establishes, and
|
// time. The host should tear the peer connection down so it re-establishes, and
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (m *Manager) startExchange(remoteID RemoteID, viaSignal bool, ackID Exchang
|
|||||||
// (that offer riding the data path under the freshly adopted key proves it worked),
|
// (that offer riding the data path under the freshly adopted key proves it worked),
|
||||||
// then derives the PSK for the new offer, commits it optimistically, and returns the
|
// then derives the PSK for the new offer, commits it optimistically, and returns the
|
||||||
// framed answer. A duplicate offer returns the cached answer without re-deriving.
|
// framed answer. A duplicate offer returns the cached answer without re-deriving.
|
||||||
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
|
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg, viaSignal bool) ([]byte, error) {
|
||||||
m.trace("pqkem: offer received", "peer", remoteID, "exchange", idHex(o.ExchangeID), "acks", idHex(o.AckID))
|
m.trace("pqkem: offer received", "peer", remoteID, "exchange", idHex(o.ExchangeID), "acks", idHex(o.AckID))
|
||||||
|
|
||||||
if o.AckID != (ExchangeID{}) {
|
if o.AckID != (ExchangeID{}) {
|
||||||
@@ -114,7 +114,7 @@ func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
|
|||||||
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(o.ExchangeID), "role", "responder", "psk_fp", pskFingerprint(psk))
|
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(o.ExchangeID), "role", "responder", "psk_fp", pskFingerprint(psk))
|
||||||
|
|
||||||
// Commit optimistically so our data path can rekey to the new PSK.
|
// Commit optimistically so our data path can rekey to the new PSK.
|
||||||
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
|
if err := m.cbHandler.OnNewPSKReady(remoteID, psk, viaSignal); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.trace("pqkem: answer sent", "peer", remoteID, "exchange", idHex(o.ExchangeID))
|
m.trace("pqkem: answer sent", "peer", remoteID, "exchange", idHex(o.ExchangeID))
|
||||||
@@ -125,7 +125,7 @@ func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
|
|||||||
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
|
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
|
||||||
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
|
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
|
||||||
// lock makes a concurrent/duplicate answer bail.
|
// lock makes a concurrent/duplicate answer bail.
|
||||||
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
|
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg, viaSignal bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
ex := m.exchanges[remoteID]
|
ex := m.exchanges[remoteID]
|
||||||
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
|
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
|
||||||
@@ -159,7 +159,7 @@ func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
|
|||||||
|
|
||||||
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(a.ExchangeID), "role", "initiator", "psk_fp", pskFingerprint(psk))
|
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(a.ExchangeID), "role", "initiator", "psk_fp", pskFingerprint(psk))
|
||||||
|
|
||||||
return m.cbHandler.OnNewPSKReady(remoteID, psk)
|
return m.cbHandler.OnNewPSKReady(remoteID, psk, viaSignal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ackConverged (responder) records convergence of the exchange named by ackID: a
|
// ackConverged (responder) records convergence of the exchange named by ackID: a
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ func (m *Manager) SignalOnOffer(remoteID RemoteID, offer []byte) ([]byte, error)
|
|||||||
if typ != MsgOffer {
|
if typ != MsgOffer {
|
||||||
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
|
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
|
||||||
}
|
}
|
||||||
return m.processOffer(remoteID, msg.(*OfferMsg))
|
return m.processOffer(remoteID, msg.(*OfferMsg), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
|
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
|
||||||
@@ -318,7 +318,7 @@ func (m *Manager) SignalOnAnswer(remoteID RemoteID, answer []byte) error {
|
|||||||
if typ != MsgAnswer {
|
if typ != MsgAnswer {
|
||||||
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
|
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
|
||||||
}
|
}
|
||||||
return m.processAnswer(remoteID, msg.(*AnswerMsg))
|
return m.processAnswer(remoteID, msg.(*AnswerMsg), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Data path ----
|
// ---- Data path ----
|
||||||
@@ -346,7 +346,7 @@ func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
|
|||||||
}
|
}
|
||||||
switch typ {
|
switch typ {
|
||||||
case MsgOffer:
|
case MsgOffer:
|
||||||
answer, err := m.processOffer(remoteID, msg.(*OfferMsg))
|
answer, err := m.processOffer(remoteID, msg.(*OfferMsg), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,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))
|
return m.processAnswer(remoteID, msg.(*AnswerMsg), false)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ type fakeWG struct {
|
|||||||
|
|
||||||
func newFakeWG() *fakeWG { return &fakeWG{psks: map[RemoteID]PSK{}} }
|
func newFakeWG() *fakeWG { return &fakeWG{psks: map[RemoteID]PSK{}} }
|
||||||
|
|
||||||
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK) error {
|
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK, bootstrap bool) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.psks[remoteID] = psk
|
f.psks[remoteID] = psk
|
||||||
|
|||||||
@@ -23,16 +23,30 @@ type pqCallbackHandler struct {
|
|||||||
// reoffer re-bootstraps the KEM over Signal for a peer (a fresh signalling offer)
|
// 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.
|
// to recover from a persistent data-path rekey failure. Nil disables recovery.
|
||||||
reoffer func(remoteKey string)
|
reoffer func(remoteKey string)
|
||||||
|
// rehandshake forces the peer's WireGuard session to adopt a freshly bootstrapped
|
||||||
|
// PSK (the live session may be up on a pre-PQ key). Nil disables it.
|
||||||
|
rehandshake func(remoteKey string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op
|
// OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op
|
||||||
// if the peer is not present, mirroring Rosenpass).
|
// if the peer is not present, mirroring Rosenpass).
|
||||||
func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, psk pqkem.PSK) error {
|
func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, psk pqkem.PSK, bootstrap bool) error {
|
||||||
// updateOnly: applies to an already-configured peer (rotation). At bootstrap the
|
// 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
|
// 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).
|
// pulled at peer-config time (pqHandshaker.PSK / conn.presharedKey).
|
||||||
log.Tracef("pqkem: programming PSK for peer %s", remoteID)
|
log.Tracef("pqkem: programming PSK for peer %s", remoteID)
|
||||||
return h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true)
|
if err := h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A bootstrap PSK (initial setup or wake re-negotiation) may arrive after the
|
||||||
|
// WireGuard session already came up on a pre-PQ key (a race between the KEM
|
||||||
|
// exchange and the endpoint config). SetPresharedKey only updates config, not the
|
||||||
|
// live session, so force a re-handshake to actually adopt the PSK now. Rotations
|
||||||
|
// are left to WireGuard's next natural rekey.
|
||||||
|
if bootstrap && h.rehandshake != nil {
|
||||||
|
h.rehandshake(string(remoteID))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnRekeyFailed reports a failed PQ (re)key convergence and re-bootstraps the KEM over
|
// OnRekeyFailed reports a failed PQ (re)key convergence and re-bootstraps the KEM over
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
|
|
||||||
type pqNoopHandler struct{}
|
type pqNoopHandler struct{}
|
||||||
|
|
||||||
func (pqNoopHandler) OnNewPSKReady(pqkem.RemoteID, pqkem.PSK) error { return nil }
|
func (pqNoopHandler) OnNewPSKReady(pqkem.RemoteID, pqkem.PSK, bool) error { return nil }
|
||||||
func (pqNoopHandler) OnRekeyFailed(pqkem.RemoteID) error { return nil }
|
func (pqNoopHandler) OnRekeyFailed(pqkem.RemoteID) error { return nil }
|
||||||
|
|
||||||
// TestPQAdapter_CapabilityRoleAware locks the role-aware capability signal: the KEM
|
// TestPQAdapter_CapabilityRoleAware locks the role-aware capability signal: the KEM
|
||||||
// payload only flows initiator-offer -> responder-answer, so an empty message in the
|
// payload only flows initiator-offer -> responder-answer, so an empty message in the
|
||||||
|
|||||||
Reference in New Issue
Block a user