diff --git a/client/internal/engine.go b/client/internal/engine.go index 1d00ed0d2..82c9d7daa 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -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) } diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index 21dd751df..9d477a28b 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -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 } diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go index 69e18ac88..8e35e7c15 100644 --- a/client/internal/rosenpass/manager_test.go +++ b/client/internal/rosenpass/manager_test.go @@ -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") } diff --git a/client/internal/rosenpass/persistence.go b/client/internal/rosenpass/persistence.go new file mode 100644 index 000000000..eeb7e7f0b --- /dev/null +++ b/client/internal/rosenpass/persistence.go @@ -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, + }) +} diff --git a/client/internal/rosenpass/persistence_test.go b/client/internal/rosenpass/persistence_test.go new file mode 100644 index 000000000..f5c563507 --- /dev/null +++ b/client/internal/rosenpass/persistence_test.go @@ -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) +}