mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-26 22:39:07 +01:00
Pure mechanics of manager
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
package pqkem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager drives the PQ-KEM exchange per remote peer. It is a pure state machine:
|
||||||
|
// it consumes and produces messages and returns the derived PSK at the point each
|
||||||
|
// side must commit it (initiator on the answer, responder on the confirm). It does
|
||||||
|
// no I/O — transport (Signal / tunnel) and the WireGuard SetPSK live in the caller
|
||||||
|
// (network module + wiring), so this stays extraction-ready as a standalone library.
|
||||||
|
//
|
||||||
|
// Commit ordering (see ConfirmMsg): the responder derives the PSK on the offer but
|
||||||
|
// must NOT commit it until the confirm arrives, otherwise a lost answer would leave
|
||||||
|
// it on a new PSK the initiator never got, breaking the tunnel at the next rekey.
|
||||||
|
|
||||||
|
type sessionState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateIdle sessionState = iota
|
||||||
|
stateAwaitingAnswer // initiator: sent offer, waiting for answer
|
||||||
|
stateAwaitingConfirm // responder: sent answer, holding pending PSK until confirm
|
||||||
|
stateEstablished
|
||||||
|
)
|
||||||
|
|
||||||
|
type peerSession struct {
|
||||||
|
state sessionState
|
||||||
|
exchangeID ExchangeID
|
||||||
|
|
||||||
|
// initiator side: in-flight ephemeral keypair between offer and answer.
|
||||||
|
initiator *Initiator
|
||||||
|
// responder side: PSK derived on the offer, committed only on the confirm.
|
||||||
|
pendingPSK PSK
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
localWgKey string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*peerSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a KEM manager for the local peer identified by its WireGuard
|
||||||
|
// public key (base64), used both for the deterministic initiator role and the
|
||||||
|
// identity binding of the derived PSK.
|
||||||
|
func NewManager(localWgKey string) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
localWgKey: localWgKey,
|
||||||
|
sessions: make(map[string]*peerSession),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInitiator reports whether the local peer drives the exchange for this remote
|
||||||
|
// peer. Roles are deterministic (lexicographic WG-key compare) so exactly one side
|
||||||
|
// initiates, mirroring how Rosenpass picks its handshake initiator.
|
||||||
|
func (m *Manager) IsInitiator(remoteWgKey string) bool {
|
||||||
|
return m.localWgKey > remoteWgKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartExchange begins (or restarts, on rotation) an exchange as the initiator and
|
||||||
|
// returns the offer to send. Any previous in-flight exchange for the peer is dropped.
|
||||||
|
func (m *Manager) StartExchange(remoteWgKey string) (*OfferMsg, error) {
|
||||||
|
init, err := NewInitiator()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, err := newExchangeID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.sessions[remoteWgKey] = &peerSession{
|
||||||
|
state: stateAwaitingAnswer,
|
||||||
|
exchangeID: id,
|
||||||
|
initiator: init,
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return &OfferMsg{ExchangeID: id, KEMOffer: init.Offer()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleOffer processes a received offer as the responder, derives the PSK, and
|
||||||
|
// returns the answer to send. The PSK is held pending and NOT returned: the caller
|
||||||
|
// must not program it until HandleConfirm.
|
||||||
|
func (m *Manager) HandleOffer(remoteWgKey string, o *OfferMsg) (*AnswerMsg, error) {
|
||||||
|
answer, psk, err := Respond(o.KEMOffer, m.binding(remoteWgKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.sessions[remoteWgKey] = &peerSession{
|
||||||
|
state: stateAwaitingConfirm,
|
||||||
|
exchangeID: o.ExchangeID,
|
||||||
|
pendingPSK: psk,
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return &AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answer}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAnswer processes a received answer as the initiator. On success it returns
|
||||||
|
// the derived PSK (which the caller commits now) and the confirm to send.
|
||||||
|
func (m *Manager) HandleAnswer(remoteWgKey string, a *AnswerMsg) (PSK, *ConfirmMsg, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
s, ok := m.sessions[remoteWgKey]
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok || s.state != stateAwaitingAnswer {
|
||||||
|
return PSK{}, nil, fmt.Errorf("no pending offer for peer %s", remoteWgKey)
|
||||||
|
}
|
||||||
|
if s.exchangeID != a.ExchangeID {
|
||||||
|
// stale answer (e.g. to a pre-restart offer) — drop, keep waiting.
|
||||||
|
return PSK{}, nil, fmt.Errorf("answer exchangeID mismatch for peer %s", remoteWgKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
psk, err := s.initiator.Finish(a.KEMAnswer, m.binding(remoteWgKey))
|
||||||
|
if err != nil {
|
||||||
|
return PSK{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
s.state = stateEstablished
|
||||||
|
s.initiator = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return psk, &ConfirmMsg{ExchangeID: a.ExchangeID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleConfirm processes a received confirm as the responder and returns the PSK
|
||||||
|
// to commit now. It errors on a stale/unknown confirm so the caller ignores it.
|
||||||
|
func (m *Manager) HandleConfirm(remoteWgKey string, c *ConfirmMsg) (PSK, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
s, ok := m.sessions[remoteWgKey]
|
||||||
|
if !ok || s.state != stateAwaitingConfirm {
|
||||||
|
return PSK{}, fmt.Errorf("no pending answer for peer %s", remoteWgKey)
|
||||||
|
}
|
||||||
|
if s.exchangeID != c.ExchangeID {
|
||||||
|
return PSK{}, fmt.Errorf("confirm exchangeID mismatch for peer %s", remoteWgKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.state = stateEstablished
|
||||||
|
psk := s.pendingPSK
|
||||||
|
s.pendingPSK = PSK{}
|
||||||
|
return psk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) binding(remoteWgKey string) Binding {
|
||||||
|
return Binding{LocalWgPub: []byte(m.localWgKey), RemoteWgPub: []byte(remoteWgKey)}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package pqkem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManager_FullExchange(t *testing.T) {
|
||||||
|
// deterministic roles: "bbbb" > "aaaa" -> B is initiator
|
||||||
|
a := NewManager("aaaa")
|
||||||
|
b := NewManager("bbbb")
|
||||||
|
require.True(t, b.IsInitiator("aaaa"))
|
||||||
|
require.False(t, a.IsInitiator("bbbb"))
|
||||||
|
|
||||||
|
// B (initiator) -> A
|
||||||
|
offer, err := b.StartExchange("aaaa")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// A (responder) derives PSK but holds it pending
|
||||||
|
answer, err := a.HandleOffer("bbbb", offer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// B derives PSK on the answer, commits now, produces confirm
|
||||||
|
pskB, confirm, err := b.HandleAnswer("aaaa", answer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// A commits only on the confirm
|
||||||
|
pskA, err := a.HandleConfirm("bbbb", confirm)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, pskB, pskA, "both sides converge on the same PSK")
|
||||||
|
require.NotEqual(t, PSK{}, pskA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_StaleAnswerDropped(t *testing.T) {
|
||||||
|
b := NewManager("bbbb")
|
||||||
|
|
||||||
|
_, err := b.StartExchange("aaaa")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// craft an answer with a wrong exchangeID
|
||||||
|
stale := &AnswerMsg{ExchangeID: ExchangeID{0xFF}, KEMAnswer: make([]byte, AnswerSize)}
|
||||||
|
_, _, err = b.HandleAnswer("aaaa", stale)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_RestartResyncsViaNewExchange(t *testing.T) {
|
||||||
|
// A restarts (fresh manager) mid-flight; a new exchange from scratch converges.
|
||||||
|
a := NewManager("aaaa")
|
||||||
|
b := NewManager("bbbb")
|
||||||
|
|
||||||
|
off1, err := b.StartExchange("aaaa")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// B "restarts": new manager, old in-flight state gone
|
||||||
|
b = NewManager("bbbb")
|
||||||
|
off2, err := b.StartExchange("aaaa")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, off1.ExchangeID, off2.ExchangeID)
|
||||||
|
|
||||||
|
ans, err := a.HandleOffer("bbbb", off2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pskB, conf, err := b.HandleAnswer("aaaa", ans)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pskA, err := a.HandleConfirm("bbbb", conf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, pskB, pskA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_UnknownPeerErrors(t *testing.T) {
|
||||||
|
a := NewManager("aaaa")
|
||||||
|
_, err := a.HandleConfirm("zzzz", &ConfirmMsg{})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user