mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-20 12:29:09 +02:00
[client,management] sync 0.74.4 changes (#6727)
* [management] fix: prevent reverse proxy domain from being pushed as DNS search domain by @blaugrau90 in https://github.com/netbirdio/netbird/pull/6498 * [client] Recover from rosenpass key desync by @lixmal in https://github.com/netbirdio/netbird/pull/6714 * [client] Bump golang.org/x/crypto to v0.54.0 by @lixmal in https://github.com/netbirdio/netbird/pull/6709 * [client] fix MDM managementURL conflict on default-port URL echo by @riccardomanfrin in https://github.com/netbirdio/netbird/pull/6672 * [client] Update gopsutil to v4 by @mlsmaycon in https://github.com/netbirdio/netbird/pull/6688 * [client] Fix hanging status command during relay dial by @theodorsm in https://github.com/netbirdio/netbird/pull/6694 --------- Co-authored-by: Theodor Midtlien <theodor@midtlien.com> Co-authored-by: blaugrau90 <61945343+blaugrau90@users.noreply.github.com> Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
This commit is contained in:
co-authored by
Theodor Midtlien
blaugrau90
Viktor Liu
parent
8e02154bf5
commit
30d15ecc3d
@@ -39,6 +39,7 @@ type rpServer interface {
|
||||
|
||||
type Manager struct {
|
||||
ifaceName string
|
||||
localWgKey wgtypes.Key
|
||||
spk []byte
|
||||
ssk []byte
|
||||
rpKeyHash string
|
||||
@@ -51,8 +52,9 @@ type Manager struct {
|
||||
wgIface PresharedKeySetter
|
||||
}
|
||||
|
||||
// NewManager creates a new Rosenpass manager
|
||||
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) {
|
||||
// NewManager creates a new Rosenpass manager. localWgKey is the local
|
||||
// WireGuard public key, used to derive the per-peer rendezvous key.
|
||||
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) {
|
||||
public, secret, err := rp.GenerateKeyPair()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
|
||||
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
||||
return &Manager{
|
||||
ifaceName: wgIfaceName,
|
||||
localWgKey: localWgKey,
|
||||
rpKeyHash: rpKeyHash,
|
||||
spk: public,
|
||||
ssk: secret,
|
||||
@@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
|
||||
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
|
||||
// replace it with a fresh handler on each Run() to clear stale peer
|
||||
// state from previous engine sessions.
|
||||
rpWgHandler: NewNetbirdHandler(),
|
||||
rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
|
||||
lock: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
@@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) {
|
||||
cfg.Peers = []rp.PeerConfig{}
|
||||
|
||||
m.lock.Lock()
|
||||
m.rpWgHandler = NewNetbirdHandler()
|
||||
m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey)
|
||||
if m.wgIface != nil {
|
||||
m.rpWgHandler.SetInterface(m.wgIface)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
|
||||
ssk: make([]byte, 32),
|
||||
rpKeyHash: "test-hash",
|
||||
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||
rpWgHandler: NewNetbirdHandler(),
|
||||
rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}),
|
||||
server: mock,
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
|
||||
// issue #4341 cannot occur in the window between NewManager and Run().
|
||||
func TestNewManager_PreInitializesHandler(t *testing.T) {
|
||||
psk := wgtypes.Key{}
|
||||
m, err := NewManager(&psk, "wt0")
|
||||
m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
|
||||
}
|
||||
@@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing
|
||||
require.False(t, m.IsPresharedKeyInitialized(wgKey))
|
||||
}
|
||||
|
||||
// --- NetbirdHandler.outputKey ----------------------------------------------
|
||||
// --- NetbirdHandler.applyKey ----------------------------------------------
|
||||
|
||||
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
@@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||
}
|
||||
|
||||
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
@@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
|
||||
}
|
||||
|
||||
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||
// no SetInterface — iface remains nil
|
||||
pid := rp.PeerID{0x03}
|
||||
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
|
||||
@@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||
h.HandshakeCompleted(pid, rp.Key{})
|
||||
}
|
||||
|
||||
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
|
||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
@@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
@@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
|
||||
pid := rp.PeerID{0x05}
|
||||
wgKey := wgtypes.Key{0xEE}
|
||||
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||
|
||||
@@ -18,19 +18,34 @@ type PresharedKeySetter interface {
|
||||
type wireGuardPeer struct {
|
||||
Interface string
|
||||
PublicKey rp.Key
|
||||
// initialized is true once a completed exchange has set a
|
||||
// Rosenpass-managed PSK for this peer.
|
||||
initialized bool
|
||||
// chainKey is the key output by the last completed exchange, advanced by
|
||||
// one ratchet step on expiry. Nil until the first exchange completes and
|
||||
// after the peer has fallen back to the rendezvous key.
|
||||
chainKey *wgtypes.Key
|
||||
// expiries counts failed renewals since the last completed exchange.
|
||||
expiries int
|
||||
}
|
||||
|
||||
type NetbirdHandler struct {
|
||||
mu sync.Mutex
|
||||
iface PresharedKeySetter
|
||||
peers map[rp.PeerID]wireGuardPeer
|
||||
initializedPeers map[rp.PeerID]bool
|
||||
mu sync.Mutex
|
||||
iface PresharedKeySetter
|
||||
// preSharedKey is the account-level preshared key, used as the rendezvous
|
||||
// key when set. Nil means the deterministic seed key is used instead.
|
||||
preSharedKey *[32]byte
|
||||
// localWgKey is the local WireGuard public key, one of the two inputs to
|
||||
// the deterministic seed key.
|
||||
localWgKey wgtypes.Key
|
||||
peers map[rp.PeerID]*wireGuardPeer
|
||||
}
|
||||
|
||||
func NewNetbirdHandler() *NetbirdHandler {
|
||||
func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler {
|
||||
return &NetbirdHandler{
|
||||
peers: map[rp.PeerID]wireGuardPeer{},
|
||||
initializedPeers: map[rp.PeerID]bool{},
|
||||
preSharedKey: preSharedKey,
|
||||
localWgKey: localWgKey,
|
||||
peers: map[rp.PeerID]*wireGuardPeer{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) {
|
||||
h.iface = iface
|
||||
}
|
||||
|
||||
// AddPeer registers a peer with the handler. Re-adding a known peer (every
|
||||
// reconnection does) keeps its key recovery state.
|
||||
func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.peers[pid] = wireGuardPeer{
|
||||
if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk {
|
||||
existing.Interface = intf
|
||||
return
|
||||
}
|
||||
h.peers[pid] = &wireGuardPeer{
|
||||
Interface: intf,
|
||||
PublicKey: pk,
|
||||
}
|
||||
@@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.peers, pid)
|
||||
delete(h.initializedPeers, pid)
|
||||
}
|
||||
|
||||
// IsPeerInitialized returns true if Rosenpass has completed a handshake
|
||||
@@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) {
|
||||
func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.initializedPeers[pid]
|
||||
peer, ok := h.peers[pid]
|
||||
return ok && peer.initialized
|
||||
}
|
||||
|
||||
// HandshakeCompleted programs the freshly exchanged output key and resets the
|
||||
// peer's key recovery state.
|
||||
func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) {
|
||||
h.outputKey(rp.KeyOutputReasonStale, pid, key)
|
||||
}
|
||||
psk := wgtypes.Key(key)
|
||||
|
||||
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
|
||||
key, _ := rp.GeneratePresharedKey()
|
||||
h.outputKey(rp.KeyOutputReasonStale, pid, key)
|
||||
}
|
||||
|
||||
func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) {
|
||||
h.mu.Lock()
|
||||
iface := h.iface
|
||||
wg, ok := h.peers[pid]
|
||||
isInitialized := h.initializedPeers[pid]
|
||||
h.mu.Unlock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if iface == nil {
|
||||
log.Warn("rosenpass: interface not set, cannot update preshared key")
|
||||
peer, ok := h.peers[pid]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if peer.expiries > 0 {
|
||||
log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries)
|
||||
}
|
||||
// chainKey tracks the shared exchange output regardless of the local write
|
||||
// outcome, so both ends still converge on the next expiry.
|
||||
peer.chainKey = &psk
|
||||
peer.expiries = 0
|
||||
if !h.applyKeyLocked(pid, psk, peer.initialized) {
|
||||
return
|
||||
}
|
||||
peer.initialized = true
|
||||
}
|
||||
|
||||
// HandshakeExpired replaces the expired key. The renewal exchange runs over
|
||||
// the tunnel keyed by the PSK itself, so the replacement must be derivable on
|
||||
// both ends without communication: the first expiry ratchets the last shared
|
||||
// key forward, repeated expiries (and expiries without a completed exchange)
|
||||
// fall back to the rendezvous key and drop the peer out of the initialized
|
||||
// state so connection reconfigurations reprogram the rendezvous key as well.
|
||||
func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
peer, ok := h.peers[pid]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
peerKey := wgtypes.Key(wg.PublicKey).String()
|
||||
pskKey := wgtypes.Key(psk)
|
||||
peer.expiries++
|
||||
|
||||
// Use updateOnly=true for later rotations (peer already has Rosenpass PSK)
|
||||
// Use updateOnly=false for first rotation (peer has original/empty PSK)
|
||||
if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil {
|
||||
var psk wgtypes.Key
|
||||
if peer.chainKey != nil && peer.expiries == 1 {
|
||||
log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey))
|
||||
psk = RatchetKey(*peer.chainKey)
|
||||
peer.chainKey = &psk
|
||||
} else {
|
||||
rendezvous, err := h.rendezvousKey(peer)
|
||||
if err != nil {
|
||||
// Fail closed: without a rendezvous key the expired key must
|
||||
// still be rotated out, even if the replacement is unusable.
|
||||
log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err)
|
||||
h.applyRandomKeyLocked(pid)
|
||||
return
|
||||
}
|
||||
log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries)
|
||||
psk = rendezvous
|
||||
peer.chainKey = nil
|
||||
peer.initialized = false
|
||||
}
|
||||
|
||||
h.applyKeyLocked(pid, psk, true)
|
||||
}
|
||||
|
||||
// rendezvousKey returns the key both ends converge on without communication:
|
||||
// the account-level preshared key when configured, the deterministic seed key
|
||||
// otherwise. It mirrors the key that peer connections program when Rosenpass
|
||||
// does not manage the peer yet.
|
||||
func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) {
|
||||
if h.preSharedKey != nil {
|
||||
return *h.preSharedKey, nil
|
||||
}
|
||||
|
||||
seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String())
|
||||
if err != nil {
|
||||
return wgtypes.Key{}, err
|
||||
}
|
||||
return *seed, nil
|
||||
}
|
||||
|
||||
// applyKeyLocked writes the preshared key for the peer to the WireGuard
|
||||
// interface and reports whether the write succeeded. Callers must hold h.mu
|
||||
// for the whole state-mutation-plus-write so that a concurrent completion and
|
||||
// expiry cannot reorder their writes relative to the in-memory chain key.
|
||||
func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool {
|
||||
peer, ok := h.peers[pid]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if h.iface == nil {
|
||||
log.Warn("rosenpass: interface not set, cannot update preshared key")
|
||||
return false
|
||||
}
|
||||
|
||||
peerKey := wgtypes.Key(peer.PublicKey).String()
|
||||
if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil {
|
||||
log.Errorf("Failed to apply rosenpass key: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) {
|
||||
key, err := rp.GeneratePresharedKey()
|
||||
if err != nil {
|
||||
log.Errorf("failed to generate random preshared key: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark peer as isInitialized after the successful first rotation
|
||||
if !isInitialized {
|
||||
h.mu.Lock()
|
||||
if _, exists := h.peers[pid]; exists {
|
||||
h.initializedPeers[pid] = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
h.applyKeyLocked(pid, wgtypes.Key(key), true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package rosenpass
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
rp "cunicu.li/go-rosenpass"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// handlerTestLink wires two NetbirdHandlers as the two ends of a single
|
||||
// tunnel: handler A manages the rosenpass peer B and vice versa, the way two
|
||||
// NetBird clients see each other.
|
||||
type handlerTestLink struct {
|
||||
handlerA, handlerB *NetbirdHandler
|
||||
ifaceA, ifaceB *mockIface
|
||||
pidA, pidB rp.PeerID
|
||||
wgKeyA, wgKeyB wgtypes.Key
|
||||
}
|
||||
|
||||
func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink {
|
||||
t.Helper()
|
||||
|
||||
link := &handlerTestLink{
|
||||
ifaceA: &mockIface{},
|
||||
ifaceB: &mockIface{},
|
||||
}
|
||||
link.pidA[0] = 0xaa
|
||||
link.pidB[0] = 0xbb
|
||||
link.wgKeyA[31] = 1
|
||||
link.wgKeyB[31] = 2
|
||||
|
||||
link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA)
|
||||
link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB)
|
||||
|
||||
link.handlerA.SetInterface(link.ifaceA)
|
||||
link.handlerB.SetInterface(link.ifaceB)
|
||||
|
||||
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
|
||||
link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA))
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
// complete simulates a completed rosenpass exchange: both ends derive the
|
||||
// same output key.
|
||||
func (l *handlerTestLink) complete(osk rp.Key) {
|
||||
l.handlerA.HandshakeCompleted(l.pidB, osk)
|
||||
l.handlerB.HandshakeCompleted(l.pidA, osk)
|
||||
}
|
||||
|
||||
// expire simulates a failed key renewal on both ends.
|
||||
func (l *handlerTestLink) expire() {
|
||||
l.handlerA.HandshakeExpired(l.pidB)
|
||||
l.handlerB.HandshakeExpired(l.pidA)
|
||||
}
|
||||
|
||||
func lastPSK(t *testing.T, m *mockIface) wgtypes.Key {
|
||||
t.Helper()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call")
|
||||
return m.calls[len(m.calls)-1].psk
|
||||
}
|
||||
|
||||
func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
|
||||
require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk")
|
||||
require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only")
|
||||
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange")
|
||||
|
||||
link.complete(osk)
|
||||
require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only")
|
||||
}
|
||||
|
||||
// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant:
|
||||
// rosenpass renewals run over the tunnel that the PSK itself keys, so when a
|
||||
// renewal fails on both ends, both ends must fall back to the same key or the
|
||||
// tunnel can never handshake again.
|
||||
func TestHandshakeExpired_BothSidesConverge(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
|
||||
link.expire()
|
||||
keyA := lastPSK(t, link.ifaceA)
|
||||
keyB := lastPSK(t, link.ifaceB)
|
||||
require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out")
|
||||
require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry")
|
||||
|
||||
link.expire()
|
||||
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
|
||||
"both ends must still converge after repeated expiries")
|
||||
}
|
||||
|
||||
// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap
|
||||
// case: the initial exchange never completed (the tunnel ran on the rendezvous
|
||||
// key), so an expiry must not replace the working key with an unrecoverable
|
||||
// one on either end.
|
||||
func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
link.expire()
|
||||
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
|
||||
"both ends must converge when the exchange never completed")
|
||||
}
|
||||
|
||||
// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep
|
||||
// failing, the peer must drop out of the initialized state so the next
|
||||
// connection reconfiguration reprograms the rendezvous key instead of
|
||||
// preserving a poisoned rosenpass-managed key.
|
||||
func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
|
||||
link.expire()
|
||||
link.expire()
|
||||
|
||||
require.False(t, link.handlerA.IsPeerInitialized(link.pidB),
|
||||
"repeated expiries must clear the initialized state")
|
||||
require.False(t, link.handlerB.IsPeerInitialized(link.pidA),
|
||||
"repeated expiries must clear the initialized state")
|
||||
}
|
||||
|
||||
// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a
|
||||
// desync must fully reset the recovery state.
|
||||
func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk1, osk2 rp.Key
|
||||
osk1[0] = 1
|
||||
osk2[0] = 2
|
||||
|
||||
link.complete(osk1)
|
||||
link.expire()
|
||||
link.expire()
|
||||
|
||||
link.complete(osk2)
|
||||
require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk")
|
||||
require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery")
|
||||
|
||||
link.expire()
|
||||
require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB),
|
||||
"recovered link must converge again on the next expiry")
|
||||
require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out")
|
||||
}
|
||||
|
||||
// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must
|
||||
// derive the replacement from the last shared key, so an attacker who only
|
||||
// blocks the renewal exchange gains nothing over the previous key.
|
||||
func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
|
||||
link.expire()
|
||||
require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA),
|
||||
"first expiry must program the ratcheted key")
|
||||
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
|
||||
"ratchet step must keep the peer initialized so reconfigurations preserve the key")
|
||||
}
|
||||
|
||||
// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key
|
||||
// also fails, both ends must land on the same key that peer connections
|
||||
// program for uninitialized peers, so a reconnect completes the recovery.
|
||||
func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
|
||||
link.expire()
|
||||
link.expire()
|
||||
|
||||
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key")
|
||||
require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key")
|
||||
}
|
||||
|
||||
// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level
|
||||
// preshared key configured, the fallback must be that key, matching what peer
|
||||
// connections program for uninitialized peers.
|
||||
func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) {
|
||||
psk := &[32]byte{0x77}
|
||||
link := newHandlerTestLink(t, psk)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
|
||||
link.expire()
|
||||
link.expire()
|
||||
|
||||
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA),
|
||||
"fallback must be the configured preshared key")
|
||||
require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB),
|
||||
"fallback must be the configured preshared key on both ends")
|
||||
}
|
||||
|
||||
// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must
|
||||
// never create a WireGuard peer that connection management has removed.
|
||||
func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
|
||||
link.expire()
|
||||
link.expire()
|
||||
|
||||
for _, call := range link.ifaceA.calls[1:] {
|
||||
require.True(t, call.updateOnly, "expiry writes must be update-only")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every
|
||||
// OnConnected; that must not reset the expiry chain state.
|
||||
func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) {
|
||||
link := newHandlerTestLink(t, nil)
|
||||
|
||||
var osk rp.Key
|
||||
osk[0] = 0x42
|
||||
link.complete(osk)
|
||||
link.expire()
|
||||
|
||||
link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB))
|
||||
require.True(t, link.handlerA.IsPeerInitialized(link.pidB),
|
||||
"re-adding a known peer must keep its state")
|
||||
|
||||
link.expire()
|
||||
seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *seed, lastPSK(t, link.ifaceA),
|
||||
"second expiry after re-add must continue to the seed fallback")
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
package rosenpass
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// ratchetLabel domain-separates the expiry ratchet from other uses of the
|
||||
// rosenpass output key.
|
||||
const ratchetLabel = "netbird-rosenpass-expiry-ratchet"
|
||||
|
||||
// RatchetKey derives the successor preshared key from the previous Rosenpass
|
||||
// output key. When a key expires without a completed renewal, both peers
|
||||
// advance their last shared key by one ratchet step: the expired key is
|
||||
// rotated out while both ends still converge on an identical, non-public
|
||||
// replacement without communicating.
|
||||
func RatchetKey(prev wgtypes.Key) wgtypes.Key {
|
||||
input := make([]byte, 0, len(ratchetLabel)+len(prev))
|
||||
input = append(input, ratchetLabel...)
|
||||
input = append(input, prev[:]...)
|
||||
return sha256.Sum256(input)
|
||||
}
|
||||
|
||||
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
|
||||
// of peer public keys. Both peers, given the same key pair, produce the same
|
||||
// output regardless of which side runs the function: the inputs are ordered
|
||||
|
||||
Reference in New Issue
Block a user