diff --git a/docs/API.md b/docs/API.md index 1ecb123c0..42ce37c97 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1164,6 +1164,29 @@ Removes an agent from state. ### Agent Remote Config `GET /api/agents/agent/{agent_id}/config` Returns the server-side config payload for an agent (used by remote config and debugging). Requires `agent:config:read`. +The `config` object includes the merged desired settings, command enablement +decision, and desired-config metadata. When signing is configured, the +signature covers that metadata with the rest of the config payload: + +```json +{ + "success": true, + "agentId": "agent-123", + "config": { + "commandsEnabled": true, + "settings": { + "enable_docker": true + }, + "desiredConfig": { + "version": "host-agent-config/v1", + "hash": "sha256:..." + }, + "issuedAt": "2026-05-13T17:00:00Z", + "expiresAt": "2026-05-13T17:15:00Z", + "signature": "..." + } +} +``` `PATCH /api/agents/agent/{agent_id}/config` (admin, `agent:manage`) Updates server-side config for an agent (e.g., `commandsEnabled`). diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d75c87d5a..cc7240034 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1916,6 +1916,12 @@ the primary runtime surface is the Unified Agent report/config boundary, while the `/api/agents/host/*` routes remain compatibility aliases only and may not re-emerge as the primary lifecycle concept in router state, handlers, or proofs. +The remote-config side of that same Unified Agent boundary now also carries a +backend-owned desired-config fingerprint. `Monitor.GetHostAgentConfig` must +compute the metadata after profile settings and command enablement decisions +have been merged, and `/api/agents/agent/{id}/config` may sign that metadata +with the rest of the config payload. Broader applied-state reporting and +connections-ledger rollout presentation remain outside this backend foundation. That same canonical /api/auto-register path must also complete the live post-registration contract after persistence: it must trigger discovery refresh and emit the canonical `node_auto_registered` WebSocket payload instead of diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8b7808acf..d09ef509e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3948,6 +3948,12 @@ requirements without manual token replacement after upgrade. That canonicalization may live only at request-ingress and persistence/migration boundaries; live token records, runtime scope checks, and API payloads may not preserve or re-emit `host-agent:*` aliases. +`GET /api/agents/agent/{id}/config` now also owns desired-config metadata in +the backend API contract. The response `config.desiredConfig` carries a +non-secret versioned hash computed after profile settings and command +enablement decisions have been merged, and config signatures cover that +metadata together with `commandsEnabled`, `settings`, `issuedAt`, and +`expiresAt`. Agent profile delete and unassign clients must now also route canonical `204` success handling through shared allowed-status helpers in `frontend-modern/src/api/responseUtils.ts` instead of open-coding local diff --git a/internal/api/agent_ingest.go b/internal/api/agent_ingest.go index c5a6047d2..b46b2dd78 100644 --- a/internal/api/agent_ingest.go +++ b/internal/api/agent_ingest.go @@ -378,6 +378,12 @@ func (h *UnifiedAgentHandlers) resolveConfigAgent(ctx context.Context, agentID s } func (h *UnifiedAgentHandlers) signAgentConfig(agentID string, cfg monitoring.HostAgentConfig) (monitoring.HostAgentConfig, error) { + var err error + cfg, err = ensureDesiredAgentConfigMetadata(cfg) + if err != nil { + return cfg, err + } + signatureRequired := isConfigSignatureRequired() key, err := getConfigSigningKey() if err != nil { @@ -403,6 +409,7 @@ func (h *UnifiedAgentHandlers) signAgentConfig(agentID string, cfg monitoring.Ho ExpiresAt: expiresAt, CommandsEnabled: cfg.CommandsEnabled, Settings: cfg.Settings, + DesiredConfig: cfg.DesiredConfig, } signature, err := remoteconfig.SignConfigPayload(payload, key) @@ -420,6 +427,15 @@ func (h *UnifiedAgentHandlers) signAgentConfig(agentID string, cfg monitoring.Ho return cfg, nil } +func ensureDesiredAgentConfigMetadata(cfg monitoring.HostAgentConfig) (monitoring.HostAgentConfig, error) { + metadata, err := remoteconfig.BuildDesiredConfigMetadata(cfg.CommandsEnabled, cfg.Settings) + if err != nil { + return cfg, fmt.Errorf("failed to build desired config metadata: %w", err) + } + cfg.DesiredConfig = &metadata + return cfg, nil +} + func getConfigSigningKey() (ed25519.PrivateKey, error) { configSigningState.once.Do(func() { raw := utils.GetenvTrim("PULSE_AGENT_CONFIG_SIGNING_KEY") diff --git a/internal/api/unified_agent_handlers_additional_test.go b/internal/api/unified_agent_handlers_additional_test.go index e86e60576..652e2eb33 100644 --- a/internal/api/unified_agent_handlers_additional_test.go +++ b/internal/api/unified_agent_handlers_additional_test.go @@ -14,6 +14,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig" agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" ) @@ -28,11 +29,18 @@ func resetConfigSigningStateForTests() { func generateSigningKey(t *testing.T) string { t.Helper() + _, priv := generateSigningKeyPair(t) + return priv +} + +func generateSigningKeyPair(t *testing.T) (string, string) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } - return base64.StdEncoding.EncodeToString(priv) + return base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)), base64.StdEncoding.EncodeToString(priv) } func decodeErrorCode(t *testing.T, rec *httptest.ResponseRecorder) string { @@ -332,6 +340,67 @@ func TestUnifiedAgentHandlers_HandleConfigSigningSuccess(t *testing.T) { } } +func TestUnifiedAgentHandlers_HandleConfigSignsDesiredMetadata(t *testing.T) { + handler, monitor := newUnifiedAgentHandlers(t, nil) + hostID := seedUnifiedAgentHost(t, monitor) + + commandsEnabled := true + if err := monitor.UpdateHostAgentConfig(hostID, &commandsEnabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + publicKey, privateKey := generateSigningKeyPair(t) + t.Setenv("PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED", "true") + t.Setenv("PULSE_AGENT_CONFIG_SIGNING_KEY", privateKey) + t.Setenv("PULSE_AGENT_CONFIG_PUBLIC_KEYS", publicKey) + resetConfigSigningStateForTests() + t.Cleanup(resetConfigSigningStateForTests) + + req := httptest.NewRequest(http.MethodGet, "/api/agents/agent/"+hostID+"/config", nil) + rec := httptest.NewRecorder() + handler.HandleConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + AgentID string `json:"agentId"` + Config monitoring.HostAgentConfig `json:"config"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.AgentID != hostID { + t.Fatalf("expected agentId %q, got %q", hostID, resp.AgentID) + } + if resp.Config.DesiredConfig == nil { + t.Fatalf("expected desired config metadata") + } + expected, err := remoteconfig.BuildDesiredConfigMetadata(resp.Config.CommandsEnabled, resp.Config.Settings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata: %v", err) + } + if *resp.Config.DesiredConfig != expected { + t.Fatalf("desired config metadata = %#v, want %#v", *resp.Config.DesiredConfig, expected) + } + if resp.Config.Signature == "" || resp.Config.IssuedAt == nil || resp.Config.ExpiresAt == nil { + t.Fatalf("expected signature and timestamps") + } + + payload := remoteconfig.SignedConfigPayload{ + AgentID: resp.AgentID, + IssuedAt: *resp.Config.IssuedAt, + ExpiresAt: *resp.Config.ExpiresAt, + CommandsEnabled: resp.Config.CommandsEnabled, + Settings: resp.Config.Settings, + DesiredConfig: resp.Config.DesiredConfig, + } + if err := remoteconfig.VerifyConfigPayloadSignature(payload, resp.Config.Signature); err != nil { + t.Fatalf("VerifyConfigPayloadSignature: %v", err) + } +} + func TestUnifiedAgentHandlers_HandleConfigInvalidKeyAllowed(t *testing.T) { handler := newUnifiedAgentHandlerForTests(t, models.Host{ID: "host-1"}) diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index ea23cf8dd..0ccde4ade 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -10,6 +10,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/logging" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig" "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" @@ -343,11 +344,12 @@ func (m *Monitor) UnlinkHostAgent(hostID string) error { // HostAgentConfig represents server-side configuration for a host agent. type HostAgentConfig struct { - CommandsEnabled *bool `json:"commandsEnabled,omitempty"` // nil = use agent default - Settings map[string]interface{} `json:"settings,omitempty"` // Merged profile settings - IssuedAt *time.Time `json:"issuedAt,omitempty"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` - Signature string `json:"signature,omitempty"` + CommandsEnabled *bool `json:"commandsEnabled,omitempty"` // nil = use agent default + Settings map[string]interface{} `json:"settings,omitempty"` // Merged profile settings + DesiredConfig *remoteconfig.DesiredConfigMetadata `json:"desiredConfig,omitempty"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Signature string `json:"signature,omitempty"` } // GetHostAgentConfig returns the server-side configuration for a host agent. @@ -390,6 +392,16 @@ func (m *Monitor) GetHostAgentConfig(hostID string) HostAgentConfig { } } + return attachDesiredConfigMetadata(cfg) +} + +func attachDesiredConfigMetadata(cfg HostAgentConfig) HostAgentConfig { + metadata, err := remoteconfig.BuildDesiredConfigMetadata(cfg.CommandsEnabled, cfg.Settings) + if err != nil { + log.Warn().Err(err).Msg("failed to build host agent desired config metadata") + return cfg + } + cfg.DesiredConfig = &metadata return cfg } diff --git a/internal/monitoring/monitor_profiles_test.go b/internal/monitoring/monitor_profiles_test.go index 348e5b96a..44bd1720a 100644 --- a/internal/monitoring/monitor_profiles_test.go +++ b/internal/monitoring/monitor_profiles_test.go @@ -6,6 +6,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig" ) func TestGetHostAgentConfig_WithProfiles(t *testing.T) { @@ -63,6 +64,8 @@ func TestGetHostAgentConfig_WithProfiles(t *testing.T) { if val, ok := cfg.Settings["log_level"]; !ok || val != "debug" { t.Errorf("Expected log_level='debug', got %v", val) } + + assertDesiredConfigMetadata(t, cfg) }) // Test Case 2: Agent without assignment @@ -72,6 +75,7 @@ func TestGetHostAgentConfig_WithProfiles(t *testing.T) { if len(cfg.Settings) != 0 { t.Errorf("Expected empty Settings for unassigned agent, got %v", cfg.Settings) } + assertDesiredConfigMetadata(t, cfg) }) // Test Case 3: Agent assigned to non-existent profile @@ -89,5 +93,50 @@ func TestGetHostAgentConfig_WithProfiles(t *testing.T) { if len(cfg.Settings) != 0 { t.Errorf("Expected empty Settings for missing profile, got %v", cfg.Settings) } + assertDesiredConfigMetadata(t, cfg) }) } + +func TestGetHostAgentConfig_FingerprintIncludesCommandDecision(t *testing.T) { + m := &Monitor{ + hostMetadataStore: config.NewHostMetadataStore(t.TempDir(), nil), + config: &config.Config{}, + state: models.NewState(), + } + + hostID := "agent-command-decision" + before := m.GetHostAgentConfig(hostID) + assertDesiredConfigMetadata(t, before) + + enabled := true + if err := m.UpdateHostAgentConfig(hostID, &enabled); err != nil { + t.Fatalf("UpdateHostAgentConfig: %v", err) + } + + after := m.GetHostAgentConfig(hostID) + assertDesiredConfigMetadata(t, after) + if after.CommandsEnabled == nil || !*after.CommandsEnabled { + t.Fatalf("expected commandsEnabled=true, got %#v", after.CommandsEnabled) + } + if before.DesiredConfig == nil || after.DesiredConfig == nil { + t.Fatalf("expected desired config metadata before and after command decision") + } + if before.DesiredConfig.Hash == after.DesiredConfig.Hash { + t.Fatalf("expected command decision to change desired config hash") + } +} + +func assertDesiredConfigMetadata(t *testing.T, cfg HostAgentConfig) { + t.Helper() + + if cfg.DesiredConfig == nil { + t.Fatalf("expected desired config metadata") + } + expected, err := remoteconfig.BuildDesiredConfigMetadata(cfg.CommandsEnabled, cfg.Settings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata: %v", err) + } + if *cfg.DesiredConfig != expected { + t.Fatalf("desired config metadata = %#v, want %#v", *cfg.DesiredConfig, expected) + } +} diff --git a/internal/remoteconfig/client.go b/internal/remoteconfig/client.go index 53413f0e1..9420fa147 100644 --- a/internal/remoteconfig/client.go +++ b/internal/remoteconfig/client.go @@ -59,6 +59,7 @@ type Response struct { Config struct { CommandsEnabled *bool `json:"commandsEnabled,omitempty"` Settings map[string]interface{} `json:"settings,omitempty"` + DesiredConfig *DesiredConfigMetadata `json:"desiredConfig,omitempty"` IssuedAt time.Time `json:"issuedAt,omitempty"` ExpiresAt time.Time `json:"expiresAt,omitempty"` Signature string `json:"signature,omitempty"` @@ -179,6 +180,18 @@ func (c *Client) Fetch(ctx context.Context) (map[string]interface{}, *bool, erro responseAgentID := strings.TrimSpace(configResp.AgentID) + if configResp.Config.DesiredConfig != nil { + if err := ValidateDesiredConfigMetadata(*configResp.Config.DesiredConfig, configResp.Config.CommandsEnabled, configResp.Config.Settings); err != nil { + logger.Warn(). + Err(err). + Str("action", "desired_config_metadata_invalid"). + Str("agent_id", agentID). + Str("response_agent_id", responseAgentID). + Msg("Remote config desired metadata did not match config payload") + return nil, nil, fmt.Errorf("config desired metadata invalid: %w", err) + } + } + if configResp.Config.Signature != "" { if responseAgentID == "" { return nil, nil, fmt.Errorf("config signature missing agent metadata") @@ -223,6 +236,7 @@ func (c *Client) Fetch(ctx context.Context) (map[string]interface{}, *bool, erro ExpiresAt: configResp.Config.ExpiresAt, CommandsEnabled: configResp.Config.CommandsEnabled, Settings: configResp.Config.Settings, + DesiredConfig: configResp.Config.DesiredConfig, } if err := VerifyConfigPayloadSignature(payload, configResp.Config.Signature); err != nil { logger.Warn(). diff --git a/internal/remoteconfig/client_additional_test.go b/internal/remoteconfig/client_additional_test.go index 49336a7ad..4407d2db7 100644 --- a/internal/remoteconfig/client_additional_test.go +++ b/internal/remoteconfig/client_additional_test.go @@ -28,6 +28,10 @@ func TestClientFetchWithSignature(t *testing.T) { expiresAt := issuedAt.Add(5 * time.Minute) commands := true settings := map[string]interface{}{"interval": "1m"} + desired, err := BuildDesiredConfigMetadata(&commands, settings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata: %v", err) + } payload := SignedConfigPayload{ AgentID: "agent-1", @@ -35,6 +39,7 @@ func TestClientFetchWithSignature(t *testing.T) { ExpiresAt: expiresAt, CommandsEnabled: &commands, Settings: settings, + DesiredConfig: &desired, } signature, err := SignConfigPayload(payload, priv) if err != nil { @@ -52,6 +57,7 @@ func TestClientFetchWithSignature(t *testing.T) { } resp.Config.CommandsEnabled = &commands resp.Config.Settings = settings + resp.Config.DesiredConfig = &desired resp.Config.IssuedAt = issuedAt resp.Config.ExpiresAt = expiresAt resp.Config.Signature = signature @@ -77,6 +83,63 @@ func TestClientFetchWithSignature(t *testing.T) { } } +func TestClientFetchValidatesDesiredConfigMetadata(t *testing.T) { + commands := true + settings := map[string]interface{}{"interval": "1m"} + desired, err := BuildDesiredConfigMetadata(&commands, settings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata: %v", err) + } + + for _, tt := range []struct { + name string + metadata DesiredConfigMetadata + wantErr bool + wantErrMsg string + }{ + {name: "matches", metadata: desired}, + {name: "mismatched hash", metadata: DesiredConfigMetadata{Version: desired.Version, Hash: "sha256:0000"}, wantErr: true, wantErrMsg: "desired config fingerprint mismatch"}, + {name: "missing version", metadata: DesiredConfigMetadata{Hash: desired.Hash}, wantErr: true, wantErrMsg: "metadata is incomplete"}, + } { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := Response{ + Success: true, + AgentID: "agent-1", + } + resp.Config.CommandsEnabled = &commands + resp.Config.Settings = settings + resp.Config.DesiredConfig = &tt.metadata + _ = json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + client := New(Config{ + PulseURL: ts.URL, + APIToken: "token-123", + AgentID: "agent-1", + }) + + gotSettings, gotCommands, err := client.Fetch(context.Background()) + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Fatalf("expected error containing %q, got %v", tt.wantErrMsg, err) + } + return + } + if err != nil { + t.Fatalf("Fetch error: %v", err) + } + if gotCommands == nil || *gotCommands != true { + t.Fatalf("expected commands enabled, got %v", gotCommands) + } + if gotSettings["interval"] != "1m" { + t.Fatalf("unexpected settings: %#v", gotSettings) + } + }) + } +} + func TestClientFetchSignatureFailures(t *testing.T) { pub, priv, err := ed25519.GenerateKey(nil) if err != nil { diff --git a/internal/remoteconfig/signature.go b/internal/remoteconfig/signature.go index 8115ffbd8..780419eac 100644 --- a/internal/remoteconfig/signature.go +++ b/internal/remoteconfig/signature.go @@ -2,8 +2,10 @@ package remoteconfig import ( "crypto/ed25519" + "crypto/sha256" "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/json" "encoding/pem" "errors" @@ -15,6 +17,8 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/utils" ) +const desiredConfigFingerprintVersion = "host-agent-config/v1" + // trustedConfigPublicKeysPEM contains trusted Ed25519 public keys for config verification. // In production builds, inject keys via ldflags to support rotation. var trustedConfigPublicKeysPEM = strings.TrimSpace(` @@ -23,6 +27,12 @@ MCowBQYDK2VwAyEAlbXZQRx8jgMzwpXbbjOGcnA+9TG0lms/auxbPzY+Tdo= -----END PUBLIC KEY----- `) +// DesiredConfigMetadata identifies the normalized desired config without exposing raw config values. +type DesiredConfigMetadata struct { + Version string `json:"version"` + Hash string `json:"hash"` +} + // SignedConfigPayload is the canonical payload used for config signing. type SignedConfigPayload struct { AgentID string @@ -30,6 +40,7 @@ type SignedConfigPayload struct { ExpiresAt time.Time CommandsEnabled *bool Settings map[string]interface{} + DesiredConfig *DesiredConfigMetadata } // DecodeEd25519PrivateKey decodes a base64-encoded Ed25519 private key or seed. @@ -69,6 +80,40 @@ func SignConfigPayload(payload SignedConfigPayload, privateKey ed25519.PrivateKe return base64.StdEncoding.EncodeToString(signature), nil } +// BuildDesiredConfigMetadata returns a deterministic, non-secret fingerprint for desired config. +func BuildDesiredConfigMetadata(commandsEnabled *bool, settings map[string]interface{}) (DesiredConfigMetadata, error) { + canonical, err := canonicalDesiredConfigPayload(commandsEnabled, settings) + if err != nil { + return DesiredConfigMetadata{}, err + } + + sum := sha256.Sum256(canonical) + return DesiredConfigMetadata{ + Version: desiredConfigFingerprintVersion, + Hash: "sha256:" + hex.EncodeToString(sum[:]), + }, nil +} + +// ValidateDesiredConfigMetadata verifies that metadata matches the desired config payload. +func ValidateDesiredConfigMetadata(metadata DesiredConfigMetadata, commandsEnabled *bool, settings map[string]interface{}) error { + metadata = normalizeDesiredConfigMetadata(metadata) + if metadata.Version == "" || metadata.Hash == "" { + return errors.New("desired config metadata is incomplete") + } + + expected, err := BuildDesiredConfigMetadata(commandsEnabled, settings) + if err != nil { + return fmt.Errorf("build desired config metadata: %w", err) + } + if metadata.Version != expected.Version { + return fmt.Errorf("desired config version mismatch: expected %q, got %q", expected.Version, metadata.Version) + } + if metadata.Hash != expected.Hash { + return fmt.Errorf("desired config fingerprint mismatch: expected %q, got %q", expected.Hash, metadata.Hash) + } + return nil +} + // VerifyConfigPayloadSignature verifies a base64 signature against the trusted public keys. func VerifyConfigPayloadSignature(payload SignedConfigPayload, signatureBase64 string) error { if signatureBase64 == "" { @@ -106,6 +151,10 @@ func canonicalConfigPayload(payload SignedConfigPayload) ([]byte, error) { ExpiresAt string `json:"expiresAt"` CommandsEnabled *bool `json:"commandsEnabled,omitempty"` Settings json.RawMessage `json:"settings,omitempty"` + DesiredConfig *struct { + Version string `json:"version"` + Hash string `json:"hash"` + } `json:"desiredConfig,omitempty"` } var settings json.RawMessage @@ -117,12 +166,30 @@ func canonicalConfigPayload(payload SignedConfigPayload) ([]byte, error) { settings = data } + var desiredConfig *struct { + Version string `json:"version"` + Hash string `json:"hash"` + } + if payload.DesiredConfig != nil { + normalized := normalizeDesiredConfigMetadata(*payload.DesiredConfig) + if normalized.Version != "" || normalized.Hash != "" { + desiredConfig = &struct { + Version string `json:"version"` + Hash string `json:"hash"` + }{ + Version: normalized.Version, + Hash: normalized.Hash, + } + } + } + canonical := canonicalPayload{ AgentID: strings.TrimSpace(payload.AgentID), IssuedAt: payload.IssuedAt.UTC().Format(time.RFC3339Nano), ExpiresAt: payload.ExpiresAt.UTC().Format(time.RFC3339Nano), CommandsEnabled: payload.CommandsEnabled, Settings: settings, + DesiredConfig: desiredConfig, } data, err := json.Marshal(canonical) @@ -132,6 +199,42 @@ func canonicalConfigPayload(payload SignedConfigPayload) ([]byte, error) { return data, nil } +func canonicalDesiredConfigPayload(commandsEnabled *bool, settings map[string]interface{}) ([]byte, error) { + type canonicalDesiredConfig struct { + Version string `json:"version"` + CommandsEnabled *bool `json:"commandsEnabled,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` + } + + var rawSettings json.RawMessage + if len(settings) > 0 { + data, err := marshalSortedMap(settings) + if err != nil { + return nil, fmt.Errorf("marshal canonical settings: %w", err) + } + rawSettings = data + } + + payload := canonicalDesiredConfig{ + Version: desiredConfigFingerprintVersion, + CommandsEnabled: commandsEnabled, + Settings: rawSettings, + } + + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal canonical desired config payload: %w", err) + } + return data, nil +} + +func normalizeDesiredConfigMetadata(metadata DesiredConfigMetadata) DesiredConfigMetadata { + return DesiredConfigMetadata{ + Version: strings.TrimSpace(metadata.Version), + Hash: strings.TrimSpace(metadata.Hash), + } +} + func trustedConfigPublicKeys() ([]ed25519.PublicKey, error) { raw := utils.GetenvTrim("PULSE_AGENT_CONFIG_PUBLIC_KEYS") if raw == "" { diff --git a/internal/remoteconfig/signature_test.go b/internal/remoteconfig/signature_test.go index c11945868..f3656f2c0 100644 --- a/internal/remoteconfig/signature_test.go +++ b/internal/remoteconfig/signature_test.go @@ -38,6 +38,108 @@ func TestVerifyConfigPayloadSignature_WithEnvKey(t *testing.T) { } } +func TestBuildDesiredConfigMetadataStableAndSensitiveToDecisions(t *testing.T) { + commandsEnabled := true + firstSettings := map[string]interface{}{ + "b": 2, + "a": map[string]interface{}{ + "d": "x", + "c": []interface{}{"y", float64(3)}, + }, + } + secondSettings := map[string]interface{}{ + "a": map[string]interface{}{ + "c": []interface{}{"y", float64(3)}, + "d": "x", + }, + "b": 2, + } + + first, err := BuildDesiredConfigMetadata(&commandsEnabled, firstSettings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata first: %v", err) + } + second, err := BuildDesiredConfigMetadata(&commandsEnabled, secondSettings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata second: %v", err) + } + if first.Version != desiredConfigFingerprintVersion { + t.Fatalf("unexpected version: %q", first.Version) + } + if first.Hash == "" { + t.Fatalf("expected non-empty hash") + } + if first != second { + t.Fatalf("expected stable metadata for reordered settings, got %#v and %#v", first, second) + } + + disabled := false + withDifferentCommandDecision, err := BuildDesiredConfigMetadata(&disabled, firstSettings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata disabled: %v", err) + } + if withDifferentCommandDecision.Hash == first.Hash { + t.Fatalf("expected command decision to affect desired config fingerprint") + } +} + +func TestValidateDesiredConfigMetadata(t *testing.T) { + commandsEnabled := true + settings := map[string]interface{}{"interval": "1m"} + metadata, err := BuildDesiredConfigMetadata(&commandsEnabled, settings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata: %v", err) + } + + if err := ValidateDesiredConfigMetadata(metadata, &commandsEnabled, settings); err != nil { + t.Fatalf("ValidateDesiredConfigMetadata: %v", err) + } + + tampered := metadata + tampered.Hash = "sha256:0000" + if err := ValidateDesiredConfigMetadata(tampered, &commandsEnabled, settings); err == nil { + t.Fatalf("expected tampered desired config metadata to fail") + } +} + +func TestVerifyConfigPayloadSignatureCoversDesiredConfigMetadata(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + t.Setenv("PULSE_AGENT_CONFIG_PUBLIC_KEYS", base64.StdEncoding.EncodeToString(pub)) + + commandsEnabled := true + settings := map[string]interface{}{"interval": "1m"} + metadata, err := BuildDesiredConfigMetadata(&commandsEnabled, settings) + if err != nil { + t.Fatalf("BuildDesiredConfigMetadata: %v", err) + } + payload := SignedConfigPayload{ + AgentID: "host-1", + IssuedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Minute), + CommandsEnabled: &commandsEnabled, + Settings: settings, + DesiredConfig: &metadata, + } + + sig, err := SignConfigPayload(payload, priv) + if err != nil { + t.Fatalf("SignConfigPayload: %v", err) + } + if err := VerifyConfigPayloadSignature(payload, sig); err != nil { + t.Fatalf("VerifyConfigPayloadSignature: %v", err) + } + + tampered := metadata + tampered.Hash = "sha256:0000" + payload.DesiredConfig = &tampered + if err := VerifyConfigPayloadSignature(payload, sig); err == nil { + t.Fatalf("expected signature verification to fail after desired metadata tamper") + } +} + func TestDecodeEd25519PrivateKey(t *testing.T) { if _, err := DecodeEd25519PrivateKey(""); err == nil { t.Fatal("expected error for empty key")