mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-19 13:19:06 +02:00
Define OnDataPathRekeyed event to transition from control plane path to data plane path over the WG tunnel. Keep confirm ALWAYS on NEW established WG tunnel (posthandshake with rekeying). We keep an active method irrelevant of the WG handshake (we might decide that the indirect wg handshake is sufficient in the future). Optimistic commit on responder(when sending answer), while on initiator we set it on getting the answer
310 lines
10 KiB
Go
310 lines
10 KiB
Go
package pqkem
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// DefaultRekeyInterval is the default PSK rotation cadence (~2 min), chosen so a
|
|
// rotated PSK is adopted by the consumer's next transport handshake without
|
|
// forcing one.
|
|
DefaultRekeyInterval = 2 * time.Minute
|
|
// DefaultRetryInterval is how often the initiator retransmits its outstanding
|
|
// message (offer, then confirm) while an exchange is in flight.
|
|
DefaultRetryInterval = 2 * time.Second
|
|
// DefaultMaxRetries bounds how many ticks an exchange may run before the offer
|
|
// or the wait for the data-path rekey is declared failed. The convergence
|
|
// deadline is thus derived as MaxRetries * RetryInterval — no separate timer.
|
|
DefaultMaxRetries = 10
|
|
// DefaultMaxRekeyFailures is how many consecutive rekey (non-initial) failures
|
|
// are tolerated before OnRekeyFailed. The initial exchange fails immediately.
|
|
DefaultMaxRekeyFailures = 3
|
|
// confirmRetransmits is how many times the initiator best-effort resends the
|
|
// confirm over the data path, to cover its loss without a dedicated goroutine.
|
|
confirmRetransmits = 3
|
|
)
|
|
|
|
// Transport carries exchange messages over the two available channels; the library
|
|
// picks which. It is the analogue of go-rosenpass's Conn seam.
|
|
//
|
|
// Model: a message rides the channel keyed with the CURRENTLY valid key. Before a
|
|
// data path exists (initial bootstrap) that is the out-of-band signalling channel;
|
|
// once the data path is up (after OnDataPathRekeyed) offers/answers ride it, and the
|
|
// confirm ALWAYS rides the data path (so its arrival proves the new key works).
|
|
type Transport interface {
|
|
// SendDataPath sends over the peer's established data path (e.g. a WireGuard
|
|
// tunnel). The consumer routes it accordingly.
|
|
SendDataPath(remoteID string, msg []byte) error
|
|
// SendSignal sends over the out-of-band signalling channel, handled outside the
|
|
// library by the consumer.
|
|
SendSignal(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 send the confirm
|
|
stateConfirming // initiator: confirm sent over the data path, best-effort retransmit
|
|
stateAwaitingConfirm // responder: answer sent, awaiting the confirm over the data path
|
|
)
|
|
|
|
// 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
|
|
// retransmit payload (offer, then confirm). initiator is the ephemeral handle used
|
|
// at Finish; pendingPSK is the responder's derived key. 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
|
|
}
|
|
|
|
// Manager is the stateful orchestrator — the analogue of go-rosenpass's Server. It
|
|
// runs the per-peer rekey timer, drives the X25519MLKEM768 exchange over the
|
|
// Transport, and surfaces the derived PSK and convergence to the host via
|
|
// CallbackHandler. 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
|
|
|
|
rekeyInterval time.Duration
|
|
retryInterval time.Duration
|
|
maxRetries int
|
|
maxRekeyFailures int
|
|
|
|
rootCtx context.Context
|
|
rootCancel context.CancelFunc
|
|
|
|
mu sync.Mutex
|
|
peers map[string]context.CancelFunc // per-peer rekey loop
|
|
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
|
|
dataPathUp map[string]bool // peer's data path is up (offers/answers may ride it)
|
|
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 zero
|
|
// interval falls back to DefaultRekeyInterval; a nil logger to slog.Default().
|
|
func NewManager(localID string, t Transport, h CallbackHandler, interval time.Duration, logger *slog.Logger) *Manager {
|
|
if interval <= 0 {
|
|
interval = DefaultRekeyInterval
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Manager{
|
|
localID: localID,
|
|
transport: t,
|
|
cbHandler: h,
|
|
logger: logger,
|
|
rekeyInterval: interval,
|
|
retryInterval: DefaultRetryInterval,
|
|
maxRetries: DefaultMaxRetries,
|
|
maxRekeyFailures: DefaultMaxRekeyFailures,
|
|
rootCtx: ctx,
|
|
rootCancel: cancel,
|
|
peers: make(map[string]context.CancelFunc),
|
|
exchanges: make(map[string]*exchangeCtl),
|
|
established: make(map[string]bool),
|
|
failures: make(map[string]int),
|
|
dataPathUp: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// AddPeer registers a remote peer and starts its rekey timer. Re-adding is a no-op.
|
|
func (m *Manager) AddPeer(remoteID string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.peers[remoteID]; ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(m.rootCtx)
|
|
m.peers[remoteID] = cancel
|
|
m.wait.Add(1)
|
|
go m.rekeyLoop(ctx, remoteID)
|
|
}
|
|
|
|
// RemovePeer stops a peer's rekey timer and any in-flight exchange, and drops state.
|
|
func (m *Manager) RemovePeer(remoteID string) {
|
|
m.mu.Lock()
|
|
if cancel, ok := m.peers[remoteID]; ok {
|
|
cancel()
|
|
delete(m.peers, remoteID)
|
|
}
|
|
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.dataPathUp, remoteID)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Stop cancels all timers and in-flight exchanges and waits for goroutines to exit.
|
|
func (m *Manager) Stop() {
|
|
m.rootCancel()
|
|
m.wait.Wait()
|
|
m.mu.Lock()
|
|
m.peers = make(map[string]context.CancelFunc)
|
|
m.exchanges = make(map[string]*exchangeCtl)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// OnDataPathRekeyed notifies the library that the peer's data path is up and freshly
|
|
// keyed with the latest PSK (fired on first establishment AND every rekey — the same
|
|
// event). It marks the data path usable and, if we are the initiator waiting to
|
|
// confirm, sends the confirm over the data path (its arrival proves to the responder
|
|
// that we operate on the new key, correlated by exchangeID).
|
|
func (m *Manager) OnDataPathRekeyed(remoteID string) {
|
|
m.mu.Lock()
|
|
m.dataPathUp[remoteID] = true
|
|
ex := m.exchanges[remoteID]
|
|
if ex == nil || ex.state != stateAwaitingRekey {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
confirm, err := (&ConfirmMsg{ExchangeID: ex.id}).Encode()
|
|
if err != nil {
|
|
m.mu.Unlock()
|
|
m.logger.Error("pqkem encode confirm", "peer", remoteID, "err", err)
|
|
return
|
|
}
|
|
ex.state = stateConfirming
|
|
ex.lastSent = confirm
|
|
m.established[remoteID] = true
|
|
m.failures[remoteID] = 0
|
|
_ = time.Since(ex.startedAt) // convergence latency (metrics hook, later step)
|
|
m.mu.Unlock()
|
|
|
|
if err := m.transport.SendDataPath(remoteID, confirm); err != nil {
|
|
m.logger.Warn("pqkem send confirm failed", "peer", remoteID, "err", err)
|
|
}
|
|
}
|
|
|
|
// HandleInbound decodes an incoming message and drives the exchange.
|
|
func (m *Manager) HandleInbound(remoteID string, raw []byte) error {
|
|
typ, msg, err := Decode(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("decode from %s: %w", remoteID, err)
|
|
}
|
|
switch typ {
|
|
case MsgOffer:
|
|
return m.handleOffer(remoteID, msg.(*OfferMsg))
|
|
case MsgAnswer:
|
|
return m.handleAnswer(remoteID, msg.(*AnswerMsg))
|
|
case MsgConfirm:
|
|
return m.handleConfirm(remoteID, msg.(*ConfirmMsg))
|
|
default:
|
|
return fmt.Errorf("unhandled message type %d from %s", typ, remoteID)
|
|
}
|
|
}
|
|
|
|
// initiateRekey starts a fresh exchange when the local peer is the initiator. The
|
|
// offer rides the data path if it is up (rekey) or the signalling channel otherwise
|
|
// (initial bootstrap).
|
|
func (m *Manager) initiateRekey(remoteID string) error {
|
|
if !m.IsInitiator(remoteID) {
|
|
return nil
|
|
}
|
|
init, err := NewInitiator()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id, err := newExchangeID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := (&OfferMsg{ExchangeID: id, KEMOffer: init.Offer()}).Encode()
|
|
if err != nil {
|
|
return 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,
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.wait.Add(1)
|
|
go m.initiatorLoop(ctx, remoteID, id)
|
|
|
|
return m.send(remoteID, raw)
|
|
}
|
|
|
|
func (m *Manager) rekeyLoop(ctx context.Context, remoteID string) {
|
|
defer m.wait.Done()
|
|
t := time.NewTicker(m.rekeyInterval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := m.initiateRekey(remoteID); err != nil {
|
|
m.logger.Error("pqkem rekey failed to start", "peer", remoteID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// send routes offer/answer over the data path when it is up, else the signalling
|
|
// channel. The confirm never goes through here — it always uses SendDataPath.
|
|
func (m *Manager) send(remoteID string, msg []byte) error {
|
|
m.mu.Lock()
|
|
viaDataPath := m.dataPathUp[remoteID]
|
|
m.mu.Unlock()
|
|
if viaDataPath {
|
|
return m.transport.SendDataPath(remoteID, msg)
|
|
}
|
|
return m.transport.SendSignal(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
|
|
}
|