Rework signal protocol to minimize number of exchanged initial RP 512KB keys

This commit is contained in:
riccardom
2026-07-16 17:25:34 +02:00
parent e3e8dd8cb0
commit 945f8809ee
10 changed files with 378 additions and 40 deletions
+31
View File
@@ -1809,6 +1809,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
PubKey: e.getRosenpassPubKey(),
Addr: e.getRosenpassAddr(),
PermissiveMode: e.config.RosenpassPermissive,
KeyResolver: e.rosenpassKeyResolver(),
},
ICEConfig: e.createICEConfig(),
}
@@ -1879,6 +1880,8 @@ func (e *Engine) receiveSignalEvents() error {
log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID)
e.applyRosenpassKeyExchange(msg, offerAnswer)
if msg.Body.Type == sProto.Body_OFFER {
conn.OnRemoteOffer(*offerAnswer)
} else {
@@ -2222,6 +2225,34 @@ func (e *Engine) getRosenpassAddr() string {
return ""
}
// rosenpassKeyResolver returns the Rosenpass manager as the offer/answer key
// resolver, or a true nil interface when Rosenpass is disabled (returning the
// typed-nil *Manager would make the interface non-nil and panic on use).
func (e *Engine) rosenpassKeyResolver() peer.RosenpassKeyResolver {
if e.rpManager == nil {
return nil
}
return e.rpManager
}
// applyRosenpassKeyExchange reconciles the fingerprint/cache fields of an incoming
// offer/answer against the Rosenpass manager's cache: it resolves the remote peer's
// full public key (from the message or the cache) into the OfferAnswer, and records
// whether the peer acknowledged holding our key. No-op when Rosenpass is disabled.
func (e *Engine) applyRosenpassKeyExchange(msg *sProto.Message, oa *peer.OfferAnswer) {
if e.rpManager == nil {
return
}
cfg := msg.GetBody().GetRosenpassConfig()
if cfg == nil {
return
}
remoteWgKey := msg.GetKey()
oa.RosenpassPubKey = e.rpManager.ResolveRemotePubKey(remoteWgKey, cfg.GetRosenpassPubKey(), cfg.GetRosenpassPubKeyHash())
e.rpManager.SetRemoteAck(remoteWgKey, cfg.GetAcknowledgedRosenpassPubKeyHash())
}
// RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services
// and updates the status recorder with the latest states.
//
+18
View File
@@ -65,6 +65,20 @@ type WgConfig struct {
PreSharedKey *wgtypes.Key
}
// RosenpassKeyResolver lets the handshaker fill the fingerprint/cache fields of an
// offer/answer without depending on the Rosenpass manager directly. Implemented by
// rosenpass.Manager and wired in by the engine.
type RosenpassKeyResolver interface {
// LocalPubKeyHash is the SHA256 of our own Rosenpass public key.
LocalPubKeyHash() []byte
// RemotePubKeyAck is the SHA256 of the remote peer's cached key (nil if we do
// not hold it), sent back as an acknowledgement.
RemotePubKeyAck(remoteWgKey string) []byte
// RemoteHasLocalKey reports whether the peer already holds our key, so the full
// key may be omitted.
RemoteHasLocalKey(remoteWgKey string) bool
}
type RosenpassConfig struct {
// RosenpassPubKey is this peer's Rosenpass public key
PubKey []byte
@@ -72,6 +86,10 @@ type RosenpassConfig struct {
Addr string
PermissiveMode bool
// KeyResolver drives fingerprint-based key caching over signalling. Nil when
// Rosenpass is disabled, which makes the handshaker always send the full key.
KeyResolver RosenpassKeyResolver
}
// ConnConfig is a peer Connection configuration
+34 -5
View File
@@ -33,8 +33,13 @@ type OfferAnswer struct {
// Version of NetBird Agent
Version string
// RosenpassPubKey is the Rosenpass public key of the remote peer when receiving this message
// This value is the local Rosenpass server public key when sending the message
// This value is the local Rosenpass server public key when sending the message.
// May be empty on send when the remote peer has acknowledged already holding it (see RosenpassPubKeyAck).
RosenpassPubKey []byte
// RosenpassPubKeyHash is the SHA256 of the sender's own RosenpassPubKey. Always set when Rosenpass is enabled.
RosenpassPubKeyHash []byte
// RosenpassPubKeyAck is the SHA256 of the remote peer's key the sender holds cached; empty means "send it in full".
RosenpassPubKeyAck []byte
// RosenpassAddr is the Rosenpass server address (IP:port) of the remote peer when receiving this message
// This value is the local Rosenpass server address when sending the message
RosenpassAddr string
@@ -209,11 +214,11 @@ func (h *Handshaker) sendAnswer() error {
func (h *Handshaker) buildOfferAnswer() OfferAnswer {
answer := OfferAnswer{
WgListenPort: h.config.LocalWgPort,
Version: version.NetbirdVersion(),
RosenpassPubKey: h.config.RosenpassConfig.PubKey,
RosenpassAddr: h.config.RosenpassConfig.Addr,
WgListenPort: h.config.LocalWgPort,
Version: version.NetbirdVersion(),
RosenpassAddr: h.config.RosenpassConfig.Addr,
}
h.setRosenpassPubKey(&answer)
if h.ice != nil && h.RemoteICESupported() {
uFrag, pwd := h.ice.GetLocalUserCredentials()
@@ -230,6 +235,30 @@ func (h *Handshaker) buildOfferAnswer() OfferAnswer {
return answer
}
// setRosenpassPubKey fills the Rosenpass key fields of an outgoing offer/answer.
// With a resolver wired it advertises our key hash and the ack for the remote key
// we hold, and includes the full public key only when the peer has not yet
// acknowledged holding it. Without a resolver (Rosenpass disabled, or an older
// code path) it always sends the full key, preserving the previous behaviour.
func (h *Handshaker) setRosenpassPubKey(answer *OfferAnswer) {
localKey := h.config.RosenpassConfig.PubKey
if len(localKey) == 0 {
return
}
resolver := h.config.RosenpassConfig.KeyResolver
if resolver == nil {
answer.RosenpassPubKey = localKey
return
}
answer.RosenpassPubKeyHash = resolver.LocalPubKeyHash()
answer.RosenpassPubKeyAck = resolver.RemotePubKeyAck(h.config.Key)
if !resolver.RemoteHasLocalKey(h.config.Key) {
answer.RosenpassPubKey = localKey
}
}
func (h *Handshaker) updateRemoteICEState(offer *OfferAnswer) {
hasICE := offer.hasICECredentials()
prev := h.remoteICESupported.Swap(hasICE)
@@ -0,0 +1,65 @@
package peer
import (
"testing"
"github.com/stretchr/testify/require"
)
type fakeRPResolver struct {
localHash []byte
ack []byte
hasLocal bool
}
func (f fakeRPResolver) LocalPubKeyHash() []byte { return f.localHash }
func (f fakeRPResolver) RemotePubKeyAck(string) []byte { return f.ack }
func (f fakeRPResolver) RemoteHasLocalKey(remote string) bool { return f.hasLocal }
func TestSetRosenpassPubKey_NoResolverAlwaysSendsFullKey(t *testing.T) {
localKey := []byte{1, 2, 3}
h := &Handshaker{config: ConnConfig{RosenpassConfig: RosenpassConfig{PubKey: localKey}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Equal(t, localKey, a.RosenpassPubKey)
require.Nil(t, a.RosenpassPubKeyHash)
require.Nil(t, a.RosenpassPubKeyAck)
}
func TestSetRosenpassPubKey_ResolverIncludesFullKeyUntilAcked(t *testing.T) {
localKey := []byte{1, 2, 3}
res := fakeRPResolver{localHash: []byte{9}, ack: []byte{8}, hasLocal: false}
h := &Handshaker{config: ConnConfig{Key: "peerA", RosenpassConfig: RosenpassConfig{PubKey: localKey, KeyResolver: res}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Equal(t, localKey, a.RosenpassPubKey, "full key must be sent until the peer acks it")
require.Equal(t, []byte{9}, a.RosenpassPubKeyHash)
require.Equal(t, []byte{8}, a.RosenpassPubKeyAck)
}
func TestSetRosenpassPubKey_ResolverOmitsFullKeyOnceAcked(t *testing.T) {
localKey := []byte{1, 2, 3}
res := fakeRPResolver{localHash: []byte{9}, ack: []byte{8}, hasLocal: true}
h := &Handshaker{config: ConnConfig{Key: "peerA", RosenpassConfig: RosenpassConfig{PubKey: localKey, KeyResolver: res}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Nil(t, a.RosenpassPubKey, "full key must be omitted once the peer holds it")
require.Equal(t, []byte{9}, a.RosenpassPubKeyHash)
require.Equal(t, []byte{8}, a.RosenpassPubKeyAck)
}
func TestSetRosenpassPubKey_DisabledSetsNothing(t *testing.T) {
h := &Handshaker{config: ConnConfig{RosenpassConfig: RosenpassConfig{}}}
var a OfferAnswer
h.setRosenpassPubKey(&a)
require.Nil(t, a.RosenpassPubKey)
require.Nil(t, a.RosenpassPubKeyHash)
}
+7 -5
View File
@@ -61,11 +61,13 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
UFrag: offerAnswer.IceCredentials.UFrag,
Pwd: offerAnswer.IceCredentials.Pwd,
},
RosenpassPubKey: offerAnswer.RosenpassPubKey,
RosenpassAddr: offerAnswer.RosenpassAddr,
RelaySrvAddress: offerAnswer.RelaySrvAddress,
RelaySrvIP: offerAnswer.RelaySrvIP,
SessionID: sessionIDBytes,
RosenpassPubKey: offerAnswer.RosenpassPubKey,
RosenpassPubKeyHash: offerAnswer.RosenpassPubKeyHash,
RosenpassPubKeyAck: offerAnswer.RosenpassPubKeyAck,
RosenpassAddr: offerAnswer.RosenpassAddr,
RelaySrvAddress: offerAnswer.RelaySrvAddress,
RelaySrvIP: offerAnswer.RelaySrvIP,
SessionID: sessionIDBytes,
})
if err != nil {
return err
+62
View File
@@ -0,0 +1,62 @@
package rosenpass
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
)
func newCacheTestManager(spk []byte) *Manager {
return &Manager{
spk: spk,
remotePubKeys: make(map[string][]byte),
remoteHasLocalKey: make(map[string]bool),
}
}
func TestResolveRemotePubKey(t *testing.T) {
m := newCacheTestManager([]byte{0x01, 0x02})
full := bytes.Repeat([]byte{0xAB}, 64)
// a received full key is cached and returned
require.Equal(t, full, m.ResolveRemotePubKey("peerA", full, nil))
// a later hash-only message resolves from the cache
require.Equal(t, full, m.ResolveRemotePubKey("peerA", nil, rawRosenpassKeyHash(full)))
// hash mismatch is a cache miss
require.Nil(t, m.ResolveRemotePubKey("peerA", nil, bytes.Repeat([]byte{0x01}, 32)))
// no key and no hash (remote without Rosenpass) resolves to nil
require.Nil(t, m.ResolveRemotePubKey("peerB", nil, nil))
}
func TestRemotePubKeyAck(t *testing.T) {
m := newCacheTestManager([]byte{0x01})
// unknown peer -> no ack (signals "send me the full key")
require.Nil(t, m.RemotePubKeyAck("peerA"))
full := bytes.Repeat([]byte{0x09}, 48)
m.ResolveRemotePubKey("peerA", full, nil)
require.Equal(t, rawRosenpassKeyHash(full), m.RemotePubKeyAck("peerA"))
}
func TestSetRemoteAckAndRemoteHasLocalKey(t *testing.T) {
m := newCacheTestManager(bytes.Repeat([]byte{0x07}, 100))
require.False(t, m.RemoteHasLocalKey("peerA"))
// an ack matching our own key hash marks the peer as holding our key
m.SetRemoteAck("peerA", m.LocalPubKeyHash())
require.True(t, m.RemoteHasLocalKey("peerA"))
// empty ack clears it
m.SetRemoteAck("peerA", nil)
require.False(t, m.RemoteHasLocalKey("peerA"))
// a non-matching ack does not count
m.SetRemoteAck("peerA", bytes.Repeat([]byte{0x01}, 32))
require.False(t, m.RemoteHasLocalKey("peerA"))
}
+80 -2
View File
@@ -29,6 +29,11 @@ func hashRosenpassKey(key []byte) string {
return hex.EncodeToString(hasher.Sum(nil))
}
func rawRosenpassKeyHash(key []byte) []byte {
sum := sha256.Sum256(key)
return sum[:]
}
// rpServer is the subset of rp.Server used by Manager. Defined as an interface
// so tests can substitute a mock without spinning up a real UDP server.
type rpServer interface {
@@ -51,6 +56,15 @@ type Manager struct {
lock sync.Mutex
port int
wgIface PresharedKeySetter
// remotePubKeys caches remote peers' full Rosenpass public keys keyed by their
// WireGuard public key, so a peer that already sent us its (large) key over
// signalling need only send its hash on subsequent offers/answers. RAM only —
// never persisted (1000 peers x ~512KB would be ~512MB on disk).
remotePubKeys map[string][]byte
// remoteHasLocalKey tracks, per remote WireGuard key, whether that peer has
// acknowledged holding our current Rosenpass public key, letting us omit it.
remoteHasLocalKey map[string]bool
}
// NewManager creates a new Rosenpass manager. localWgKey is the local
@@ -85,8 +99,10 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtype
// 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((*[32]byte)(preSharedKey), localWgKey),
lock: sync.Mutex{},
rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
lock: sync.Mutex{},
remotePubKeys: make(map[string][]byte),
remoteHasLocalKey: make(map[string]bool),
}, nil
}
@@ -99,6 +115,68 @@ func (m *Manager) GetAddress() *net.UDPAddr {
return &net.UDPAddr{Port: m.port}
}
// LocalPubKeyHash returns the raw SHA256 of the local Rosenpass public key. It is
// advertised on every offer/answer so the remote peer can tell (via its cache)
// whether it already holds our full key.
func (m *Manager) LocalPubKeyHash() []byte {
return rawRosenpassKeyHash(m.spk)
}
// RemotePubKeyAck returns the SHA256 of the remote peer's cached public key, used
// as the acknowledgement we send back. Nil means we do not hold the peer's key,
// which signals the peer to include its full key next time.
func (m *Manager) RemotePubKeyAck(remoteWgKey string) []byte {
m.lock.Lock()
defer m.lock.Unlock()
key, ok := m.remotePubKeys[remoteWgKey]
if !ok {
return nil
}
return rawRosenpassKeyHash(key)
}
// RemoteHasLocalKey reports whether the remote peer acknowledged holding our
// current public key, so we may omit the full key from the next offer/answer.
func (m *Manager) RemoteHasLocalKey(remoteWgKey string) bool {
m.lock.Lock()
defer m.lock.Unlock()
return m.remoteHasLocalKey[remoteWgKey]
}
// ResolveRemotePubKey reconciles the Rosenpass key material from a received
// offer/answer: it caches a received full key, or — when only a hash was sent —
// returns the cached key matching that hash. It returns nil when the remote peer
// does not use Rosenpass (no key, no hash) or on a cache miss (hash sent but not
// held); a miss self-heals because our resulting empty ack makes the peer resend
// its full key.
func (m *Manager) ResolveRemotePubKey(remoteWgKey string, full, hash []byte) []byte {
m.lock.Lock()
defer m.lock.Unlock()
if len(full) > 0 {
m.remotePubKeys[remoteWgKey] = full
return full
}
if len(hash) == 0 {
return nil
}
if cached, ok := m.remotePubKeys[remoteWgKey]; ok && bytes.Equal(rawRosenpassKeyHash(cached), hash) {
return cached
}
return nil
}
// SetRemoteAck records whether the remote peer's acknowledgement matches our
// current public key hash, i.e. whether it already holds our key.
func (m *Manager) SetRemoteAck(remoteWgKey string, ack []byte) {
m.lock.Lock()
defer m.lock.Unlock()
m.remoteHasLocalKey[remoteWgKey] = len(ack) > 0 && bytes.Equal(ack, rawRosenpassKeyHash(m.spk))
}
// addPeer adds a new peer to the Rosenpass server
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
// Defense in depth against issue #4341 (Android crash): if Run() has not