mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-14 18:59:08 +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
280 lines
9.7 KiB
Go
280 lines
9.7 KiB
Go
package pqkem
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// DefaultRetryInterval is how often the initiator retransmits its outstanding
|
|
// data-path offer while awaiting the answer.
|
|
DefaultRetryInterval = 2 * time.Second
|
|
// DefaultMaxRetries bounds how many ticks an exchange may run before it is
|
|
// declared failed. The convergence deadline is thus MaxRetries * RetryInterval.
|
|
DefaultMaxRetries = 10
|
|
// DefaultMaxRekeyFailures is how many consecutive rekey (non-initial) failures
|
|
// are tolerated before OnRekeyFailed. The initial exchange fails immediately.
|
|
DefaultMaxRekeyFailures = 3
|
|
)
|
|
|
|
// Transport pushes a message over the peer's data path (e.g. a WireGuard tunnel).
|
|
// It is the library's only outbound send: the control-plane (signalling) channel is
|
|
// host-driven — the library hands the host offer/answer payloads to piggyback on the
|
|
// host's own negotiation, it never pushes there itself.
|
|
type Transport interface {
|
|
SendDataPath(remoteID string, msg []byte) error
|
|
}
|
|
|
|
// exchangeState is the single source of truth for an exchange's role and phase.
|
|
type exchangeState uint8
|
|
|
|
const (
|
|
stateReserved exchangeState = iota // responder: deriving the answer
|
|
stateAwaitingAnswer // initiator: offer sent, awaiting the answer
|
|
stateAwaitingRekey // initiator: PSK derived+set, awaiting OnDataPathRekeyed to chain the next offer
|
|
stateAwaitingAck // responder: answer sent, awaiting the next offer that acks this exchange
|
|
)
|
|
|
|
// exchangeCtl holds all state for one in-flight exchange with a peer, under the
|
|
// Manager's single lock. state drives every decision. lastSent is the current
|
|
// data-path retransmit payload (the offer, for the initiator). initiator is the
|
|
// ephemeral handle used at Finish; pendingPSK is the responder's derived key.
|
|
// viaSignal records that the offer went to the host for the signalling channel, so
|
|
// the loop does not retransmit it on the data path. Only the initiator runs a
|
|
// retransmit loop, so only it sets cancel.
|
|
type exchangeCtl struct {
|
|
id ExchangeID
|
|
state exchangeState
|
|
startedAt time.Time
|
|
cancel context.CancelFunc
|
|
lastSent []byte
|
|
initiator *Initiator
|
|
pendingPSK PSK
|
|
viaSignal bool
|
|
}
|
|
|
|
// Manager is the stateful orchestrator — the analogue of go-rosenpass's Server. It
|
|
// drives the X25519MLKEM768 exchange and surfaces the derived PSK and convergence to
|
|
// the host via CallbackHandler. It is fully event-driven: the bootstrap is triggered
|
|
// by the host (SignalOffer) and each rotation is clocked by OnDataPathRekeyed (the
|
|
// consumer's transport rekey). The cryptography is the pure kem.go primitives; all
|
|
// state lives here under one lock.
|
|
type Manager struct {
|
|
localID string
|
|
transport Transport
|
|
cbHandler CallbackHandler
|
|
logger *slog.Logger
|
|
|
|
retryInterval time.Duration
|
|
maxRetries int
|
|
maxRekeyFailures int
|
|
|
|
rootCtx context.Context
|
|
rootCancel context.CancelFunc
|
|
|
|
mu sync.Mutex
|
|
exchanges map[string]*exchangeCtl // in-flight exchange per peer
|
|
established map[string]bool // peer has completed at least one exchange
|
|
failures map[string]int // consecutive rekey failures per peer
|
|
// dataSend holds the peer's data-path sender when the data path is up; nil
|
|
// (absent) means it is down. Toggled by OnDataPathRekeyed / OnDataPathDown.
|
|
dataSend map[string]func(string, []byte) error
|
|
wait sync.WaitGroup
|
|
}
|
|
|
|
// NewManager builds a manager for the local peer identified by its peer identity key
|
|
// (used for the deterministic initiator role and the identity binding). A nil logger
|
|
// falls back to slog.Default(). Retry/retries/K use their defaults and can be
|
|
// overridden before use.
|
|
func NewManager(localID string, t Transport, h CallbackHandler, logger *slog.Logger) *Manager {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Manager{
|
|
localID: localID,
|
|
transport: t,
|
|
cbHandler: h,
|
|
logger: logger,
|
|
retryInterval: DefaultRetryInterval,
|
|
maxRetries: DefaultMaxRetries,
|
|
maxRekeyFailures: DefaultMaxRekeyFailures,
|
|
rootCtx: ctx,
|
|
rootCancel: cancel,
|
|
exchanges: make(map[string]*exchangeCtl),
|
|
established: make(map[string]bool),
|
|
failures: make(map[string]int),
|
|
dataSend: make(map[string]func(string, []byte) error),
|
|
}
|
|
}
|
|
|
|
// IsInitiator reports whether the local peer drives the exchange for this remote
|
|
// peer. Roles are deterministic (lexicographic identity-key compare) so exactly one
|
|
// side initiates, mirroring how Rosenpass picks its handshake initiator.
|
|
func (m *Manager) IsInitiator(remoteID string) bool {
|
|
return m.localID > remoteID
|
|
}
|
|
|
|
// RemovePeer stops any in-flight exchange for a peer and drops its state.
|
|
func (m *Manager) RemovePeer(remoteID string) {
|
|
m.mu.Lock()
|
|
if ex, ok := m.exchanges[remoteID]; ok {
|
|
if ex.cancel != nil {
|
|
ex.cancel()
|
|
}
|
|
delete(m.exchanges, remoteID)
|
|
}
|
|
delete(m.established, remoteID)
|
|
delete(m.failures, remoteID)
|
|
delete(m.dataSend, remoteID)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Stop cancels all in-flight exchanges and waits for their goroutines to exit.
|
|
func (m *Manager) Stop() {
|
|
m.rootCancel()
|
|
m.wait.Wait()
|
|
m.mu.Lock()
|
|
m.exchanges = make(map[string]*exchangeCtl)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// ---- Signalling channel (host-driven; rides the host's negotiation) ----
|
|
|
|
// SignalOffer returns the KEM offer for the host to embed in its outgoing offer to
|
|
// remoteID (bootstrap). It returns (nil, nil) when the local peer is not the
|
|
// initiator. It is idempotent for an in-flight bootstrap: a repeat call returns the
|
|
// same offer rather than starting a new exchange.
|
|
func (m *Manager) SignalOffer(remoteID string) ([]byte, error) {
|
|
if !m.IsInitiator(remoteID) {
|
|
return nil, nil
|
|
}
|
|
m.mu.Lock()
|
|
if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal && ex.state == stateAwaitingAnswer {
|
|
last := ex.lastSent
|
|
m.mu.Unlock()
|
|
return last, nil
|
|
}
|
|
m.mu.Unlock()
|
|
// bootstrap offer acknowledges nothing (zero AckID).
|
|
return m.startExchange(remoteID, true, ExchangeID{})
|
|
}
|
|
|
|
// SignalOnOffer processes a KEM offer the host extracted from an incoming offer and
|
|
// returns the KEM answer for the host to embed in its outgoing answer.
|
|
func (m *Manager) SignalOnOffer(remoteID string, offer []byte) ([]byte, error) {
|
|
typ, msg, err := Decode(offer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode signal offer from %s: %w", remoteID, err)
|
|
}
|
|
if typ != MsgOffer {
|
|
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
|
|
}
|
|
return m.processOffer(remoteID, msg.(*OfferMsg))
|
|
}
|
|
|
|
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
|
|
// There is no reply: the next offer (over the data path) acknowledges this exchange.
|
|
func (m *Manager) SignalOnAnswer(remoteID string, answer []byte) error {
|
|
typ, msg, err := Decode(answer)
|
|
if err != nil {
|
|
return fmt.Errorf("decode signal answer from %s: %w", remoteID, err)
|
|
}
|
|
if typ != MsgAnswer {
|
|
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
|
|
}
|
|
return m.processAnswer(remoteID, msg.(*AnswerMsg))
|
|
}
|
|
|
|
// ---- Data path (library-driven push) ----
|
|
|
|
// OnDataPathMessage feeds a KEM message received over the data path (tunnel) and
|
|
// pushes any reply back over the data path.
|
|
func (m *Manager) OnDataPathMessage(remoteID string, raw []byte) error {
|
|
typ, msg, err := Decode(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("decode data-path msg from %s: %w", remoteID, err)
|
|
}
|
|
switch typ {
|
|
case MsgOffer:
|
|
answer, err := m.processOffer(remoteID, msg.(*OfferMsg))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if answer == nil {
|
|
return nil
|
|
}
|
|
return m.pushDataPath(remoteID, answer)
|
|
case MsgAnswer:
|
|
return m.processAnswer(remoteID, msg.(*AnswerMsg))
|
|
default:
|
|
return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID)
|
|
}
|
|
}
|
|
|
|
// OnDataPathRekeyed notifies that the peer's data path is up and freshly keyed with
|
|
// the latest PSK (fired on first establishment AND every rekey). It marks the data
|
|
// path usable and, if we are the initiator that just derived a PSK, chains the next
|
|
// exchange: a fresh offer over the data path that acknowledges the just-completed one
|
|
// (its arrival under the new key proves to the responder that the key works).
|
|
func (m *Manager) OnDataPathRekeyed(remoteID string) {
|
|
m.mu.Lock()
|
|
m.dataSend[remoteID] = m.transport.SendDataPath
|
|
ex := m.exchanges[remoteID]
|
|
chain := ex != nil && ex.state == stateAwaitingRekey
|
|
var ackID ExchangeID
|
|
if chain {
|
|
ackID = ex.id
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
if !chain {
|
|
return
|
|
}
|
|
offer, err := m.startExchange(remoteID, false, ackID)
|
|
if err != nil {
|
|
m.logger.Error("pqkem chain offer failed to start", "peer", remoteID, "err", err)
|
|
return
|
|
}
|
|
if err := m.pushDataPath(remoteID, offer); err != nil {
|
|
m.logger.Warn("pqkem send chain offer failed", "peer", remoteID, "err", err)
|
|
}
|
|
}
|
|
|
|
// OnDataPathDown notifies that the peer's data path went down; rotations pause until
|
|
// it is up again (the host re-bootstraps over signalling on reconnect).
|
|
func (m *Manager) OnDataPathDown(remoteID string) {
|
|
m.mu.Lock()
|
|
delete(m.dataSend, remoteID)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// ---- internals ----
|
|
|
|
// pushDataPath sends over the peer's data path, erroring if it is down.
|
|
func (m *Manager) pushDataPath(remoteID string, msg []byte) error {
|
|
m.mu.Lock()
|
|
send := m.dataSend[remoteID]
|
|
m.mu.Unlock()
|
|
if send == nil {
|
|
return fmt.Errorf("no data path for peer %s", remoteID)
|
|
}
|
|
return send(remoteID, msg)
|
|
}
|
|
|
|
func (m *Manager) binding(remoteID string) Binding {
|
|
return Binding{LocalID: []byte(m.localID), RemoteID: []byte(remoteID)}
|
|
}
|
|
|
|
func newExchangeID() (ExchangeID, error) {
|
|
var id ExchangeID
|
|
if _, err := rand.Read(id[:]); err != nil {
|
|
return ExchangeID{}, fmt.Errorf("generate exchange id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|