[client] persist Rosenpass static keypair across restarts

The Rosenpass static keypair was regenerated on every engine start
(rp.GenerateKeyPair in NewManager), so the local ~512KB public key —
and its fingerprint — changed on each client restart.

Persist the keypair to <StateDir>/rosenpass_key.json with 0600
permissions (same protection tier as the WireGuard private key), and
reload it on start so the public key stays stable across restarts. A
missing, corrupt, or version-incompatible file degrades gracefully to
generating a fresh ephemeral keypair (previous behaviour); an empty
StateDir keeps the ephemeral path for callers without a state dir.

This is the foundation for fingerprint-based RP pubkey caching over
signalling (NET-1407): a stable local key lets remote peers keep their
cached copy valid across our restart.
This commit is contained in:
riccardom
2026-07-16 10:18:18 +02:00
parent e1a24376ab
commit e3e8dd8cb0
5 changed files with 172 additions and 5 deletions

View File

@@ -551,7 +551,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
} else {
log.Infof("running rosenpass in strict mode")
}
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey)
e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey, e.config.StateDir)
if err != nil {
return fmt.Errorf("create rosenpass manager: %w", err)
}

View File

@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -53,9 +54,17 @@ type Manager struct {
}
// 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()
// WireGuard public key, used to derive the per-peer rendezvous key. When stateDir
// is non-empty the static keypair is persisted under it and reused across
// restarts, keeping the public key (and the fingerprint peers cache) stable;
// an empty stateDir keeps the previous behaviour of an ephemeral per-run keypair.
func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key, stateDir string) (*Manager, error) {
var keyPath string
if stateDir != "" {
keyPath = filepath.Join(stateDir, keypairFileName)
}
public, secret, err := loadOrGenerateKeypair(keyPath)
if err != nil {
return nil, err
}

View File

@@ -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", wgtypes.Key{0x01})
m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01}, "")
require.NoError(t, err)
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
}

View File

@@ -0,0 +1,92 @@
package rosenpass
import (
"context"
"fmt"
"os"
rp "cunicu.li/go-rosenpass"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/util"
)
const (
// keypairFileName is the file, relative to the state directory, that holds
// the persisted local Rosenpass static keypair.
keypairFileName = "rosenpass_key.json"
// rpStaticPublicKeySize is the byte length of a Rosenpass (Classic McEliece)
// static public key as produced by the pinned go-rosenpass version. Used as a
// version-compatibility guard: a persisted key of any other size is treated as
// stale and regenerated instead of being fed to go-rosenpass (which would fail).
rpStaticPublicKeySize = 524160
// keypairFormatVersion is bumped whenever the on-disk representation changes so
// old files are discarded and regenerated rather than misparsed.
keypairFormatVersion = 1
)
// persistedKeypair is the on-disk representation of the local Rosenpass static
// keypair. Keys are stored raw (base64 via JSON) with the same restricted 0600
// permission as the WireGuard private key and other client secrets.
type persistedKeypair struct {
Version int `json:"version"`
PublicKey []byte `json:"public_key"`
SecretKey []byte `json:"secret_key"`
}
// loadOrGenerateKeypair returns a Rosenpass static keypair. When keyPath is set
// and holds a valid persisted keypair it is reused, so the local public key —
// and therefore the fingerprint advertised to remote peers over signalling —
// stays stable across restarts. Otherwise a fresh keypair is generated and, when
// keyPath is set, persisted for subsequent runs. A missing or corrupt file is not
// fatal: it degrades to generating an ephemeral keypair, matching the pre-persistence
// behaviour.
func loadOrGenerateKeypair(keyPath string) (public []byte, secret []byte, err error) {
if keyPath != "" {
public, secret, err = loadKeypair(keyPath)
switch {
case err == nil:
return public, secret, nil
case os.IsNotExist(err):
// first run for this state dir; fall through to generate
default:
log.Warnf("failed to load persisted rosenpass keypair, generating a new one: %v", err)
}
}
pub, sec, err := rp.GenerateKeyPair()
if err != nil {
return nil, nil, fmt.Errorf("generate rosenpass key pair: %w", err)
}
if keyPath != "" {
if err := saveKeypair(keyPath, pub, sec); err != nil {
log.Warnf("failed to persist rosenpass keypair, key will be regenerated on next restart: %v", err)
}
}
return pub, sec, nil
}
func loadKeypair(keyPath string) ([]byte, []byte, error) {
var kp persistedKeypair
if _, err := util.ReadJson(keyPath, &kp); err != nil {
return nil, nil, err
}
if kp.Version != keypairFormatVersion || len(kp.PublicKey) != rpStaticPublicKeySize || len(kp.SecretKey) == 0 {
return nil, nil, fmt.Errorf("persisted rosenpass keypair is incompatible (version %d, public %d bytes, secret %d bytes)", kp.Version, len(kp.PublicKey), len(kp.SecretKey))
}
return kp.PublicKey, kp.SecretKey, nil
}
func saveKeypair(keyPath string, public, secret []byte) error {
return util.WriteJsonWithRestrictedPermission(context.Background(), keyPath, persistedKeypair{
Version: keypairFormatVersion,
PublicKey: public,
SecretKey: secret,
})
}

View File

@@ -0,0 +1,66 @@
package rosenpass
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestLoadOrGenerateKeypair_EphemeralWhenNoPath(t *testing.T) {
pub, sec, err := loadOrGenerateKeypair("")
require.NoError(t, err)
require.Len(t, pub, rpStaticPublicKeySize)
require.NotEmpty(t, sec)
}
func TestLoadOrGenerateKeypair_PersistsAndReloads(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), keypairFileName)
pub1, sec1, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
info, err := os.Stat(keyPath)
require.NoError(t, err, "keypair file must be written")
require.Equal(t, os.FileMode(0600), info.Mode().Perm(), "keypair file must be 0600")
pub2, sec2, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.True(t, bytes.Equal(pub1, pub2), "public key must be stable across reloads")
require.True(t, bytes.Equal(sec1, sec2), "secret key must be stable across reloads")
}
func TestLoadOrGenerateKeypair_RegeneratesOnCorruptFile(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), keypairFileName)
require.NoError(t, os.WriteFile(keyPath, []byte("not json"), 0600))
pub, sec, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.Len(t, pub, rpStaticPublicKeySize)
require.NotEmpty(t, sec)
// the corrupt file must have been overwritten with a valid, reloadable keypair
pub2, _, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.True(t, bytes.Equal(pub, pub2))
}
func TestLoadOrGenerateKeypair_RegeneratesOnVersionMismatch(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), keypairFileName)
pub1, _, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
// rewrite with a bumped/unknown format version -> must be discarded
bs, err := json.Marshal(persistedKeypair{Version: keypairFormatVersion + 1, PublicKey: pub1, SecretKey: []byte{0x01}})
require.NoError(t, err)
require.NoError(t, os.WriteFile(keyPath, bs, 0600))
pub2, sec2, err := loadOrGenerateKeypair(keyPath)
require.NoError(t, err)
require.Len(t, pub2, rpStaticPublicKeySize)
require.NotEmpty(t, sec2)
}