mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 13:49:07 +02:00
We clock the next Offer initiation to the OnDataPathRekeyed, so we have 2 minutes ahead of us to do our attempts and stuff before to give up. On failure, we will know because we will not receive a new answer.. but more importantly the wg handshake will fail :D
232 lines
6.7 KiB
Go
232 lines
6.7 KiB
Go
package pqkem
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
// startExchange creates a fresh initiator exchange (acknowledging ackID, zero for a
|
|
// bootstrap) and returns the framed offer for the caller to send — pushed over the
|
|
// data path for a chained rekey, or handed to the host for signalling when viaSignal
|
|
// is set. Any previous in-flight exchange for the peer is cancelled.
|
|
func (m *Manager) startExchange(remoteID string, viaSignal bool, ackID ExchangeID) ([]byte, error) {
|
|
init, err := NewInitiator()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := newExchangeID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := (&OfferMsg{ExchangeID: id, AckID: ackID, KEMOffer: init.Offer()}).Encode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(m.rootCtx)
|
|
m.mu.Lock()
|
|
if old := m.exchanges[remoteID]; old != nil && old.cancel != nil {
|
|
old.cancel()
|
|
}
|
|
m.exchanges[remoteID] = &exchangeCtl{
|
|
id: id,
|
|
state: stateAwaitingAnswer,
|
|
startedAt: time.Now(),
|
|
cancel: cancel,
|
|
lastSent: raw,
|
|
initiator: init,
|
|
viaSignal: viaSignal,
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.wait.Add(1)
|
|
go m.initiatorLoop(ctx, remoteID, id)
|
|
return raw, nil
|
|
}
|
|
|
|
// processOffer (responder) first acknowledges the previous exchange the offer names
|
|
// (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
|
|
// framed answer. A duplicate offer returns the cached answer without re-deriving.
|
|
func (m *Manager) processOffer(remoteID string, o *OfferMsg) ([]byte, error) {
|
|
if o.AckID != (ExchangeID{}) {
|
|
m.ackConverged(remoteID, o.AckID)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if ex := m.exchanges[remoteID]; ex != nil && ex.id == o.ExchangeID {
|
|
state, last := ex.state, ex.lastSent
|
|
m.mu.Unlock()
|
|
if state == stateReserved {
|
|
return nil, nil
|
|
}
|
|
return last, nil
|
|
}
|
|
// Reserve the slot so a concurrent duplicate offer bails.
|
|
m.exchanges[remoteID] = &exchangeCtl{id: o.ExchangeID, state: stateReserved, startedAt: time.Now()}
|
|
m.mu.Unlock()
|
|
|
|
answerBytes, psk, err := Respond(o.KEMOffer, m.binding(remoteID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := (&AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answerBytes}).Encode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
ex := m.exchanges[remoteID]
|
|
if ex == nil || ex.id != o.ExchangeID {
|
|
m.mu.Unlock()
|
|
return nil, nil
|
|
}
|
|
ex.state = stateAwaitingAck
|
|
ex.lastSent = raw
|
|
ex.pendingPSK = psk
|
|
m.mu.Unlock()
|
|
|
|
// Commit optimistically so our data path can rekey to the new PSK.
|
|
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
|
|
return nil, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// processAnswer (initiator) derives and commits the PSK and parks in
|
|
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
|
|
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
|
|
// lock makes a concurrent/duplicate answer bail.
|
|
func (m *Manager) processAnswer(remoteID string, a *AnswerMsg) error {
|
|
m.mu.Lock()
|
|
ex := m.exchanges[remoteID]
|
|
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
ex.state = stateAwaitingRekey
|
|
init := ex.initiator
|
|
ex.initiator = nil
|
|
m.mu.Unlock()
|
|
|
|
psk, err := init.Finish(a.KEMAnswer, m.binding(remoteID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The initiator has converged: the responder must have derived the key to answer.
|
|
m.mu.Lock()
|
|
m.established[remoteID] = true
|
|
m.failures[remoteID] = 0
|
|
m.mu.Unlock()
|
|
|
|
return m.cbHandler.OnNewPSKReady(remoteID, psk)
|
|
}
|
|
|
|
// ackConverged (responder) records convergence of the exchange named by ackID: a
|
|
// later offer acknowledging it proves both sides operate on that exchange's key. Only
|
|
// acts on a matching stateAwaitingAck exchange; anything else is ignored.
|
|
func (m *Manager) ackConverged(remoteID string, ackID ExchangeID) {
|
|
m.mu.Lock()
|
|
ex := m.exchanges[remoteID]
|
|
if ex == nil || ex.id != ackID || ex.state != stateAwaitingAck {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
delete(m.exchanges, remoteID)
|
|
m.established[remoteID] = true
|
|
m.failures[remoteID] = 0
|
|
_ = time.Since(ex.startedAt) // convergence latency (metrics hook, later step)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// initiatorLoop enforces the convergence deadline and retransmits the initiator's
|
|
// outstanding data-path offer while awaiting the answer (a signalling-bootstrapped
|
|
// offer is retransmitted by the host, so it is not resent here). It then waits,
|
|
// counting toward the deadline, in stateAwaitingRekey until OnDataPathRekeyed chains
|
|
// the next exchange (which supersedes and cancels this loop). Exhausting the deadline
|
|
// is a failure.
|
|
func (m *Manager) initiatorLoop(ctx context.Context, remoteID string, id ExchangeID) {
|
|
defer m.wait.Done()
|
|
t := time.NewTicker(m.retryInterval)
|
|
defer t.Stop()
|
|
|
|
attempts := 0
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
m.mu.Lock()
|
|
ex := m.exchanges[remoteID]
|
|
if ex == nil || ex.id != id {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
switch ex.state {
|
|
case stateAwaitingAnswer:
|
|
if attempts >= m.maxRetries {
|
|
delete(m.exchanges, remoteID)
|
|
fail := m.registerFailureLocked(remoteID)
|
|
m.mu.Unlock()
|
|
m.raiseFailure(remoteID, fail)
|
|
return
|
|
}
|
|
viaSignal := ex.viaSignal
|
|
msg := ex.lastSent
|
|
attempts++
|
|
m.mu.Unlock()
|
|
if !viaSignal {
|
|
if err := m.pushDataPath(remoteID, msg); err != nil {
|
|
m.logger.Warn("pqkem offer retransmit failed", "peer", remoteID, "err", err)
|
|
}
|
|
}
|
|
|
|
case stateAwaitingRekey:
|
|
// Waiting for OnDataPathRekeyed to chain the next exchange; the
|
|
// deadline still applies (the data path may never adopt the new key).
|
|
if attempts >= m.maxRetries {
|
|
delete(m.exchanges, remoteID)
|
|
fail := m.registerFailureLocked(remoteID)
|
|
m.mu.Unlock()
|
|
m.raiseFailure(remoteID, fail)
|
|
return
|
|
}
|
|
attempts++
|
|
m.mu.Unlock()
|
|
|
|
default:
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// registerFailureLocked applies policy B and reports whether OnRekeyFailed is due:
|
|
// an initial exchange (peer never established) fails immediately; a rekey tolerates
|
|
// up to maxRekeyFailures consecutive misses (we stay on the still-valid previous
|
|
// PSK) before failing. Assumes m.mu is held.
|
|
func (m *Manager) registerFailureLocked(remoteID string) bool {
|
|
if !m.established[remoteID] {
|
|
return true
|
|
}
|
|
m.failures[remoteID]++
|
|
if m.failures[remoteID] >= m.maxRekeyFailures {
|
|
m.failures[remoteID] = 0
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *Manager) raiseFailure(remoteID string, fail bool) {
|
|
if !fail {
|
|
m.logger.Warn("pqkem rekey attempt timed out, will retry next cycle", "peer", remoteID)
|
|
return
|
|
}
|
|
if err := m.cbHandler.OnRekeyFailed(remoteID); err != nil {
|
|
m.logger.Error("pqkem OnRekeyFailed handler error", "peer", remoteID, "err", err)
|
|
}
|
|
}
|