mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-20 07:21:27 +02:00
[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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
92
client/internal/rosenpass/persistence.go
Normal file
92
client/internal/rosenpass/persistence.go
Normal 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,
|
||||
})
|
||||
}
|
||||
66
client/internal/rosenpass/persistence_test.go
Normal file
66
client/internal/rosenpass/persistence_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user