From 508d9dd9995e516389ef45c86d0e322eb722b23f Mon Sep 17 00:00:00 2001 From: Brad Higgins Date: Wed, 12 Nov 2025 16:01:34 -0500 Subject: [PATCH] do config update --- apiutil.go | 64 ++++++++++++++++++++++++ apiutil_test.go | 27 ++++++++-- client.go | 111 +++++++++++++++++++++++++++++++++++++++++ dnapitest/dnapitest.go | 28 +++++++++++ message/message.go | 19 +++++++ 5 files changed, 246 insertions(+), 3 deletions(-) diff --git a/apiutil.go b/apiutil.go index ea6980a..a4e6175 100644 --- a/apiutil.go +++ b/apiutil.go @@ -28,3 +28,67 @@ func InsertConfigPrivateKey(config []byte, privkey []byte) ([]byte, error) { return yaml.Marshal(y) } + +// InsertConfigCert takes a Nebula YAML and a Nebula PEM-formatted host certifiate, and inserts the certificate into +// the config, overwriting any previous value stored. +func InsertConfigCert(config []byte, cert []byte) ([]byte, error) { + var y map[any]any + if err := yaml.Unmarshal(config, &y); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %s", err) + } + + _, ok := y["pki"] + if !ok { + return nil, fmt.Errorf("config is missing expected pki section") + } + + _, ok = y["pki"].(map[any]any) + if !ok { + return nil, fmt.Errorf("config has unexpected value for pki section") + } + + y["pki"].(map[any]any)["cert"] = string(cert) + + return yaml.Marshal(y) +} + +// FetchConfigPrivateKey takes a Nebula YAML, finds and returns its contained Nebula PEM-formatted private key, +// the Nebula PEM-formatted host cert, or an error. +func FetchConfigPrivateKeyAndCert(config []byte) ([]byte, []byte, error) { + var y map[any]any + if err := yaml.Unmarshal(config, &y); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal config: %s", err) + } + + _, ok := y["pki"] + if !ok { + return nil, nil, fmt.Errorf("config is missing expected pki section") + } + + pki, ok := y["pki"].(map[any]any) + if !ok { + return nil, nil, fmt.Errorf("config has unexpected value for pki section") + } + + configKey, ok := pki["key"] + if !ok { + return nil, nil, fmt.Errorf("(%s) config is missing section 'key'", config) + } + + existingKey, ok := configKey.(string) + if !ok { + return nil, nil, fmt.Errorf("config section 'key' found but has unexpected type: %T", configKey) + } + + configCert, ok := pki["cert"] + if !ok { + return nil, nil, fmt.Errorf("config is missing 'cert' section") + } + + existingCert, ok := configCert.(string) + if !ok { + return nil, nil, fmt.Errorf("config section 'cert' found but has unexpected type: %T", configCert) + } + + return []byte(existingKey), []byte(existingCert), nil +} diff --git a/apiutil_test.go b/apiutil_test.go index 06fe421..de6a2e5 100644 --- a/apiutil_test.go +++ b/apiutil_test.go @@ -1,8 +1,10 @@ package dnapi import ( + "fmt" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) @@ -13,13 +15,32 @@ pki: {} `), []byte("foobar")) require.NoError(t, err) - var y map[string]interface{} + var y map[string]any err = yaml.Unmarshal(cfg, &y) require.NoError(t, err) - require.Equal(t, "foobar", y["pki"].(map[interface{}]interface{})["key"]) + require.Equal(t, "foobar", y["pki"].(map[any]any)["key"]) - cfg, err = InsertConfigPrivateKey([]byte(``), []byte("foobar")) + _, err = InsertConfigPrivateKey([]byte(``), []byte("foobar")) require.Error(t, err) } + +func TestFetchConfigPrivateKey(t *testing.T) { + keyValue := []byte("foobar") + certValue := []byte("lolwat") + + configValue := fmt.Sprintf(`pki: { cert: %s }`, certValue) + cfg, err := InsertConfigPrivateKey([]byte(configValue), keyValue) + require.NoError(t, err) + + var y map[string]any + err = yaml.Unmarshal(cfg, &y) + require.NoError(t, err) + require.Equal(t, keyValue, []byte(y["pki"].(map[any]any)["key"].(string))) + + fetchedVal, fetchedCert, err := FetchConfigPrivateKeyAndCert(cfg) + require.NoError(t, err) + assert.Equal(t, certValue, fetchedCert) + assert.Equal(t, keyValue, fetchedVal) +} diff --git a/client.go b/client.go index f06ca1e..c363d73 100644 --- a/client.go +++ b/client.go @@ -376,6 +376,117 @@ func (c *Client) DoUpdate(ctx context.Context, creds keys.Credentials) ([]byte, return result.Config, nebulaPrivkeyPEM, newCreds, meta, nil } +// DoConfigUpdate sends a signed message to the DNClient API to fetch the new configuration update. During this call new keys +// are generated for DNClient API communication. If the API response is successful, the new configuration +// is returned along with the new DNClient API credentials and a meta object. +// +// See dnapi.InsertConfigPrivateKey and dnapi.InsertConfigCert for how to insert the old Nebula cert/private key into the configuration. +func (c *Client) DoConfigUpdate(ctx context.Context, creds keys.Credentials) ([]byte, *keys.Credentials, *ConfigMeta, error) { + // Rotate key + var hostPrivkey keys.PrivateKey // ECDSA + + newKeys, err := keys.New() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to generate new keys: %s", err) + } + + msg := message.DoConfigUpdateRequest{ + Nonce: nonce(), + } + + // Set the correct keypair based on the current private key type + switch creds.PrivateKey.Unwrap().(type) { + case ed25519.PrivateKey: + hostPubkeyPEM, err := newKeys.HostEd25519PublicKey.MarshalPEM() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal Ed25519 public key: %s", err) + } + hostPrivkey = newKeys.HostEd25519PrivateKey + msg.HostPubkeyEd25519 = hostPubkeyPEM + case *ecdsa.PrivateKey: + hostPubkeyPEM, err := newKeys.HostP256PublicKey.MarshalPEM() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal P256 public key: %s", err) + } + hostPrivkey = newKeys.HostP256PrivateKey + msg.HostPubkeyP256 = hostPubkeyPEM + } + + blob, err := json.Marshal(msg) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal DNClient message: %s", err) + } + + // Make API call + resp, err := c.postDNClient(ctx, message.DoConfigUpdate, blob, creds.HostID, creds.Counter, creds.PrivateKey) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to make API call to Defined Networking: %w", err) + } + resultWrapper := message.SignedResponseWrapper{} + err = json.Unmarshal(resp, &resultWrapper) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to unmarshal signed response wrapper: %s", err) + } + + // Verify the signature + valid := false + for _, caPubkey := range creds.TrustedKeys { + if caPubkey.Verify(resultWrapper.Data.Message, resultWrapper.Data.Signature) { + valid = true + break + } + } + if !valid { + return nil, nil, nil, fmt.Errorf("failed to verify signed API result") + } + + // Consume the verified message + result := message.DoConfigUpdateResponse{} + err = json.Unmarshal(resultWrapper.Data.Message, &result) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to unmarshal response (%s): %s", resultWrapper.Data.Message, err) + } + + // Verify the nonce + if !bytes.Equal(result.Nonce, msg.Nonce) { + return nil, nil, nil, fmt.Errorf("nonce mismatch between request (%s) and response (%s)", msg.Nonce, result.Nonce) + } + + // Verify the counter + if result.Counter <= creds.Counter { + return nil, nil, nil, fmt.Errorf("counter in request (%d) should be less than counter in response (%d)", creds.Counter, result.Counter) + } + + trustedKeys, err := keys.TrustedKeysFromPEM(result.TrustedKeys) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load trusted keys from bundle: %s", err) + } + + newCreds := &keys.Credentials{ + HostID: creds.HostID, + Counter: result.Counter, + PrivateKey: hostPrivkey, + TrustedKeys: trustedKeys, + } + + meta := &ConfigMeta{ + Org: ConfigOrg{ + ID: result.Organization.ID, + Name: result.Organization.Name, + }, + Network: ConfigNetwork{ + ID: result.Network.ID, + Name: result.Network.Name, + }, + Host: ConfigHost{ + ID: result.Host.ID, + Name: result.Host.Name, + IPAddress: result.Host.IPAddress, + }, + } + + return result.Config, newCreds, meta, nil +} func (c *Client) CommandResponse(ctx context.Context, creds keys.Credentials, responseToken string, response any) error { value, err := json.Marshal(message.CommandResponseRequest{ ResponseToken: responseToken, diff --git a/dnapitest/dnapitest.go b/dnapitest/dnapitest.go index 4cb4677..1591ffd 100644 --- a/dnapitest/dnapitest.go +++ b/dnapitest/dnapitest.go @@ -275,6 +275,34 @@ func (s *Server) handlerDNClient(w http.ResponseWriter, r *http.Request) { return } + case message.DoConfigUpdate: + var updateKeys message.DoConfigUpdateRequest + err = json.Unmarshal(msg.Value, &updateKeys) + if err != nil { + s.errors = append(s.errors, fmt.Errorf("failed to unmarshal DoUpdateRequest: %w", err)) + http.Error(w, "failed to unmarshal DoUpdateRequest", http.StatusInternalServerError) + return + } + + switch s.curve { + case message.NetworkCurve25519: + if err := s.SetEdPubkey(updateKeys.HostPubkeyEd25519); err != nil { + s.errors = append(s.errors, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + case message.NetworkCurveP256: + if err := s.SetP256Pubkey(updateKeys.HostPubkeyP256); err != nil { + s.errors = append(s.errors, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + default: + s.errors = append(s.errors, fmt.Errorf("invalid curve")) + http.Error(w, "invalid curve", http.StatusInternalServerError) + return + } + case message.LongPollWait: var longPoll message.LongPollWaitRequest err = json.Unmarshal(msg.Value, &longPoll) diff --git a/message/message.go b/message/message.go index 99cee3f..4de4a3c 100644 --- a/message/message.go +++ b/message/message.go @@ -11,6 +11,7 @@ import ( const ( CheckForUpdate = "CheckForUpdate" DoUpdate = "DoUpdate" + DoConfigUpdate = "DoConfigUpdate" LongPollWait = "LongPollWait" CommandResponse = "CommandResponse" Reauthenticate = "Reauthenticate" @@ -83,6 +84,24 @@ type DoUpdateResponse struct { EndpointOIDCMeta *HostEndpointOIDCMetadata `json:"endpointOIDC"` } +// DoConfigUpdateRequest is the request sent for a DoConfigUpdate request. +type DoConfigUpdateRequest struct { + HostPubkeyEd25519 []byte `json:"edPubkeyPEM"` // X25519 (used for signing) + HostPubkeyP256 []byte `json:"p256HostPubkeyPEM"` // P256 (used for signing) + Nonce []byte `json:"nonce"` +} + +// DoConfigUpdateResponse is the response generated for a DoConfigUpdate request. +type DoConfigUpdateResponse struct { + Config []byte `json:"config"` + Counter uint `json:"counter"` + Nonce []byte `json:"nonce"` + TrustedKeys []byte `json:"trustedKeys"` + Organization HostOrgMetadata `json:"organization"` + Network HostNetworkMetadata `json:"network"` + Host HostHostMetadata `json:"host"` +} + // LongPollWaitResponseWrapper contains a response to LongPollWait inside "data." type LongPollWaitResponseWrapper struct { Data LongPollWaitResponse `json:"data"`