Files
Redflag/aggregator-server/internal/services/config_service.go
jpetree331 f97d4845af feat(security): A-1 Ed25519 key rotation + A-2 replay attack fixes
Complete RedFlag codebase with two major security audit implementations.

== A-1: Ed25519 Key Rotation Support ==

Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management

Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing

== A-2: Replay Attack Fixes (F-1 through F-7) ==

F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH     - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH     - Migration 026: expires_at column with partial index
F-6 HIGH     - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH     - Agent-side executedIDs dedup map with cleanup
F-4 HIGH     - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt

Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.

All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:25:47 -04:00

207 lines
6.5 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ConfigService manages agent configuration generation and validation
type ConfigService struct {
db *sqlx.DB
config *config.Config
logger *log.Logger
subsystemQueries *queries.SubsystemQueries
}
// NewConfigService creates a new configuration service
func NewConfigService(db *sqlx.DB, cfg *config.Config, logger *log.Logger) *ConfigService {
return &ConfigService{
db: db,
config: cfg,
logger: logger,
subsystemQueries: queries.NewSubsystemQueries(db),
}
}
// getDB returns the database connection (for access to refresh token queries)
func (s *ConfigService) getDB() *sqlx.DB {
return s.db
}
// AgentConfigData represents agent configuration structure
type AgentConfigData struct {
AgentID string `json:"agent_id"`
Version string `json:"version"`
Platform string `json:"platform"`
ServerURL string `json:"server_url"`
LogLevel string `json:"log_level"`
Intervals map[string]int `json:"intervals"`
Subsystems map[string]interface{} `json:"subsystems"`
MaxRetries int `json:"max_retries"`
TimeoutSeconds int `json:"timeout_seconds"`
MachineID string `json:"machine_id"`
AgentType string `json:"agent_type"`
ConfigPath string `json:"config_path"`
StatePath string `json:"state_path"`
LogPath string `json:"log_path"`
ServiceName string `json:"service_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GenerateNewConfig creates configuration for a new agent
func (s *ConfigService) GenerateNewConfig(agentCfg *AgentConfig) ([]byte, error) {
// Base configuration
serverURL := fmt.Sprintf("http://%s:%d", s.config.Server.Host, s.config.Server.Port)
if s.config.Server.PublicURL != "" {
serverURL = s.config.Server.PublicURL
}
// Get subsystems from database (not hardcoded!)
agentID := uuid.MustParse(agentCfg.AgentID)
subsystems, err := s.subsystemQueries.GetSubsystems(agentID)
if err != nil || len(subsystems) == 0 {
// If not found, create defaults
if err := s.subsystemQueries.CreateDefaultSubsystems(agentID); err != nil {
return nil, fmt.Errorf("failed to create default subsystems: %w", err)
}
subsystems, _ = s.subsystemQueries.GetSubsystems(agentID)
}
// Convert to map format for JSON
subsystemMap := make(map[string]interface{})
for _, sub := range subsystems {
subsystemMap[sub.Subsystem] = map[string]interface{}{
"enabled": sub.Enabled,
"auto_run": sub.AutoRun,
"interval": sub.IntervalMinutes,
}
}
cfg := &AgentConfigData{
AgentID: agentCfg.AgentID,
Version: agentCfg.Version,
Platform: agentCfg.Platform,
ServerURL: serverURL,
LogLevel: "info",
Intervals: map[string]int{
"metrics": 300, // 5 minutes
"updates": 3600, // 1 hour
"commands": 30, // 30 seconds
},
Subsystems: subsystemMap, // ← USE DATABASE VALUES!
MaxRetries: 3,
TimeoutSeconds: 30,
MachineID: agentCfg.MachineID,
AgentType: agentCfg.AgentType,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Platform-specific customizations
s.applyPlatformDefaults(cfg)
// Validate configuration
if err := s.validateConfig(cfg); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
// Marshal to JSON
configJSON, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal failed: %w", err)
}
return configJSON, nil
}
// LoadExistingConfig retrieves and updates existing agent configuration
func (s *ConfigService) LoadExistingConfig(agentID string) ([]byte, error) {
// Get existing agent from database
var agent models.Agent
query := `SELECT * FROM agents WHERE id = $1`
err := s.db.Get(&agent, query, agentID)
if err != nil {
return nil, fmt.Errorf("agent not found: %w", err)
}
// For existing registered agents, generate proper config with auth tokens
s.logger.Printf("[DEBUG] Generating config for existing agent %s", agentID)
machineID := ""
if agent.MachineID != nil {
machineID = *agent.MachineID
}
agentCfg := &AgentConfig{
AgentID: agentID,
Version: agent.CurrentVersion,
Platform: agent.OSType,
Architecture: agent.OSArchitecture,
MachineID: machineID,
AgentType: "", // Could be stored in metadata
Hostname: agent.Hostname,
}
return s.GenerateNewConfig(agentCfg)
}
// applyPlatformDefaults applies platform-specific configuration
func (s *ConfigService) applyPlatformDefaults(cfg *AgentConfigData) {
switch cfg.Platform {
case "windows-amd64", "windows-arm64", "windows-386":
// Windows-specific paths
cfg.ConfigPath = "C:\\ProgramData\\RedFlag\\config.json"
cfg.StatePath = "C:\\ProgramData\\RedFlag\\state\\"
cfg.LogPath = "C:\\ProgramData\\RedFlag\\logs\\"
cfg.ServiceName = "RedFlagAgent"
// Windows-specific subsystems
cfg.Subsystems["windows"] = map[string]interface{}{"enabled": true, "auto_run": true, "timeout": 300}
cfg.Subsystems["winget"] = map[string]interface{}{"enabled": true, "auto_run": true, "timeout": 180}
default:
// Linux defaults
cfg.ConfigPath = "/etc/redflag/config.json"
cfg.StatePath = "/var/lib/redflag/"
cfg.LogPath = "/var/log/redflag/"
cfg.ServiceName = "redflag-agent"
}
}
// validateConfig validates configuration
func (s *ConfigService) validateConfig(cfg *AgentConfigData) error {
if cfg.AgentID == "" {
return fmt.Errorf("agent_id is required")
}
if cfg.Version == "" {
return fmt.Errorf("version is required")
}
if cfg.Platform == "" {
return fmt.Errorf("platform is required")
}
if cfg.ServerURL == "" {
return fmt.Errorf("server_url is required")
}
return nil
}
// SaveConfig saves agent configuration to database
func (s *ConfigService) SaveConfig(ctx context.Context, agentID uuid.UUID, configJSON []byte) error {
query := `
UPDATE agents SET config = $1, updated_at = $2
WHERE id = $3
`
_, err := s.db.ExecContext(ctx, query, configJSON, time.Now(), agentID)
return err
}