mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 18:41:30 +02:00
373 lines
13 KiB
Go
373 lines
13 KiB
Go
package pqkem
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/netip"
|
|
"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
|
|
)
|
|
|
|
// LocalID and RemoteID are peer identity keys (e.g. WireGuard public keys). They are
|
|
// distinct types so the local and a remote identity cannot be mixed up.
|
|
type (
|
|
LocalID string
|
|
RemoteID string
|
|
)
|
|
|
|
// Transport is the data-path socket the Manager drives (the analogue of
|
|
// go-rosenpass's Conn). It is a dumb mover of bytes to/from endpoints: the Manager
|
|
// owns the remoteID<->endpoint routing and hands the transport a resolved endpoint
|
|
// to Send, and reverse-resolves the source of each inbound datagram. Its lifecycle
|
|
// belongs to the Manager (Run at Start, Close at Stop).
|
|
type Transport interface {
|
|
// Send delivers msg to the given data-path endpoint.
|
|
Send(endpoint netip.AddrPort, msg []byte) error
|
|
// LocalPort is the bound local UDP port, announced to peers so they know where
|
|
// to send data-path messages.
|
|
LocalPort() int
|
|
// Run starts delivering inbound datagrams as (source endpoint, msg) to onInbound
|
|
// and returns immediately; it runs until Close.
|
|
Run(onInbound func(src netip.AddrPort, msg []byte))
|
|
// Close stops delivery and releases the socket.
|
|
Close() 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, owns the peer endpoint routing and the data-path
|
|
// transport, and surfaces the derived PSK and convergence to the host via
|
|
// CallbackHandler. It is event-driven: the bootstrap is triggered by the host
|
|
// (SignalOffer) and each rotation is clocked by OnDataPathRekeyed. The cryptography is
|
|
// the pure kem.go primitives; all state lives here under one lock.
|
|
type Manager struct {
|
|
localID LocalID
|
|
cbHandler CallbackHandler
|
|
logger *slog.Logger
|
|
|
|
retryInterval time.Duration
|
|
maxRetries int
|
|
maxRekeyFailures int
|
|
|
|
rootCtx context.Context
|
|
rootCancel context.CancelFunc
|
|
|
|
mu sync.Mutex
|
|
transport Transport
|
|
exchanges map[RemoteID]*exchangeCtl // in-flight exchange per peer
|
|
established map[RemoteID]bool // peer has completed at least one exchange
|
|
failures map[RemoteID]int // consecutive rekey failures per peer
|
|
psks map[RemoteID]PSK // latest derived PSK per peer (pulled at WG peer-config time)
|
|
peerAddrs map[RemoteID]netip.AddrPort // remoteID -> data-path endpoint (send routing)
|
|
peersByAddr map[netip.AddrPort]RemoteID // reverse: source endpoint -> remoteID (inbound)
|
|
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(). Install the data-path transport with Start.
|
|
func NewManager(localID LocalID, h CallbackHandler, logger *slog.Logger) *Manager {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Manager{
|
|
localID: localID,
|
|
cbHandler: h,
|
|
logger: logger,
|
|
retryInterval: DefaultRetryInterval,
|
|
maxRetries: DefaultMaxRetries,
|
|
maxRekeyFailures: DefaultMaxRekeyFailures,
|
|
rootCtx: ctx,
|
|
rootCancel: cancel,
|
|
exchanges: make(map[RemoteID]*exchangeCtl),
|
|
established: make(map[RemoteID]bool),
|
|
failures: make(map[RemoteID]int),
|
|
psks: make(map[RemoteID]PSK),
|
|
peerAddrs: make(map[RemoteID]netip.AddrPort),
|
|
peersByAddr: make(map[netip.AddrPort]RemoteID),
|
|
}
|
|
}
|
|
|
|
// Start installs the data-path transport and begins its inbound delivery. The Manager
|
|
// owns it from here; Stop closes it. Start/Stop are the transport lifecycle pair.
|
|
func (m *Manager) Start(t Transport) {
|
|
m.mu.Lock()
|
|
m.transport = t
|
|
m.mu.Unlock()
|
|
if t != nil {
|
|
t.Run(m.onDataPathInbound)
|
|
}
|
|
}
|
|
|
|
// LocalPort is the data-path transport's bound UDP port (0 if no transport), to be
|
|
// announced to peers.
|
|
func (m *Manager) LocalPort() int {
|
|
m.mu.Lock()
|
|
t := m.transport
|
|
m.mu.Unlock()
|
|
if t == nil {
|
|
return 0
|
|
}
|
|
return t.LocalPort()
|
|
}
|
|
|
|
// 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 RemoteID) bool {
|
|
return string(m.localID) > string(remoteID)
|
|
}
|
|
|
|
// PSK returns the latest PSK derived for the peer, for the host to program at WG
|
|
// peer-config time (the pull path). ok is false until an exchange has derived one.
|
|
func (m *Manager) PSK(remoteID RemoteID) (PSK, bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
psk, ok := m.psks[remoteID]
|
|
return psk, ok
|
|
}
|
|
|
|
// AddPeer registers where a peer's data-path messages are sent and received: its
|
|
// overlay endpoint (IP:port). Re-adding updates the endpoint.
|
|
func (m *Manager) AddPeer(remoteID RemoteID, endpoint netip.AddrPort) {
|
|
if !endpoint.IsValid() {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
if old, ok := m.peerAddrs[remoteID]; ok {
|
|
delete(m.peersByAddr, old)
|
|
}
|
|
m.peerAddrs[remoteID] = endpoint
|
|
m.peersByAddr[endpoint] = remoteID
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// RemovePeer stops any in-flight exchange for a peer and drops its state and routing.
|
|
func (m *Manager) RemovePeer(remoteID RemoteID) {
|
|
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.psks, remoteID)
|
|
if ep, ok := m.peerAddrs[remoteID]; ok {
|
|
delete(m.peersByAddr, ep)
|
|
delete(m.peerAddrs, remoteID)
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Stop cancels all in-flight exchanges, closes the transport, and waits for the
|
|
// exchange goroutines to exit.
|
|
func (m *Manager) Stop() {
|
|
m.rootCancel()
|
|
m.wait.Wait()
|
|
m.mu.Lock()
|
|
t := m.transport
|
|
m.transport = nil
|
|
m.exchanges = make(map[RemoteID]*exchangeCtl)
|
|
m.psks = make(map[RemoteID]PSK)
|
|
m.mu.Unlock()
|
|
if t != nil {
|
|
if err := t.Close(); err != nil {
|
|
m.logger.Warn("pqkem: closing data-path transport", "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 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 RemoteID) ([]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 RemoteID, 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 RemoteID, 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 ----
|
|
|
|
// onDataPathInbound is the transport's inbound handler: it reverse-resolves the
|
|
// source endpoint to a peer and dispatches. Unknown sources are dropped.
|
|
func (m *Manager) onDataPathInbound(src netip.AddrPort, msg []byte) {
|
|
m.mu.Lock()
|
|
remoteID, ok := m.peersByAddr[src]
|
|
m.mu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := m.OnDataPathMessage(remoteID, msg); err != nil {
|
|
m.logger.Debug("pqkem: inbound", "peer", remoteID, "err", err)
|
|
}
|
|
}
|
|
|
|
// OnDataPathMessage handles a KEM message received over the data path from remoteID
|
|
// and pushes any reply back over the data path.
|
|
func (m *Manager) OnDataPathMessage(remoteID RemoteID, 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). If we are the
|
|
// initiator that just derived a PSK, it 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 RemoteID) {
|
|
m.mu.Lock()
|
|
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 resume once
|
|
// the host re-bootstraps over signalling on reconnect; in-flight data-path sends will
|
|
// simply fail until then. Reserved as an explicit hook.
|
|
func (m *Manager) OnDataPathDown(remoteID RemoteID) {}
|
|
|
|
// ---- internals ----
|
|
|
|
// pushDataPath resolves the peer's endpoint and sends over the data-path transport,
|
|
// erroring if the peer is unknown or no transport is set.
|
|
func (m *Manager) pushDataPath(remoteID RemoteID, msg []byte) error {
|
|
m.mu.Lock()
|
|
ep, ok := m.peerAddrs[remoteID]
|
|
t := m.transport
|
|
m.mu.Unlock()
|
|
if !ok {
|
|
return fmt.Errorf("no data-path endpoint for peer %s", remoteID)
|
|
}
|
|
if t == nil {
|
|
return fmt.Errorf("no data-path transport")
|
|
}
|
|
return t.Send(ep, msg)
|
|
}
|
|
|
|
func (m *Manager) binding(remoteID RemoteID) 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
|
|
}
|