mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add desired config fingerprint metadata
This commit is contained in:
+23
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user