Add desired config fingerprint metadata

This commit is contained in:
rcourtman
2026-05-13 18:46:50 +01:00
parent dbe31bd8d6
commit 554158c575
11 changed files with 469 additions and 6 deletions
+23
View File
@@ -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
+16
View File
@@ -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"})
+17 -5
View File
@@ -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)
}
}
+14
View File
@@ -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 {
+103
View File
@@ -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 == "" {
+102
View File
@@ -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")