Files
Redflag/aggregator-server/internal/services/security_settings_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

469 lines
13 KiB
Go

package services
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/google/uuid"
)
type SecuritySettingsService struct {
settingsQueries *queries.SecuritySettingsQueries
signingService *SigningService
encryptionKey []byte
}
// NewSecuritySettingsService creates a new security settings service
func NewSecuritySettingsService(settingsQueries *queries.SecuritySettingsQueries, signingService *SigningService) (*SecuritySettingsService, error) {
// Get encryption key from environment or generate one
keyStr := os.Getenv("REDFLAG_SETTINGS_ENCRYPTION_KEY")
var key []byte
var err error
if keyStr != "" {
key, err = base64.StdEncoding.DecodeString(keyStr)
if err != nil {
return nil, fmt.Errorf("invalid encryption key format: %w", err)
}
} else {
// Generate a new key (in production, this should be persisted)
key = make([]byte, 32) // AES-256
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
}
}
return &SecuritySettingsService{
settingsQueries: settingsQueries,
signingService: signingService,
encryptionKey: key,
}, nil
}
// GetSetting retrieves a security setting with proper priority resolution
func (s *SecuritySettingsService) GetSetting(category, key string) (interface{}, error) {
// Priority 1: Environment variables
if envValue := s.getEnvironmentValue(category, key); envValue != nil {
return envValue, nil
}
// Priority 2: Config file values (this would be implemented based on your config structure)
if configValue := s.getConfigValue(category, key); configValue != nil {
return configValue, nil
}
// Priority 3: Database settings
if dbSetting, err := s.settingsQueries.GetSetting(category, key); err == nil && dbSetting != nil {
var value interface{}
if dbSetting.IsEncrypted {
decrypted, err := s.decrypt(dbSetting.Value)
if err != nil {
return nil, fmt.Errorf("failed to decrypt setting: %w", err)
}
if err := json.Unmarshal([]byte(decrypted), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal decrypted setting: %w", err)
}
} else {
if err := json.Unmarshal([]byte(dbSetting.Value), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal setting: %w", err)
}
}
return value, nil
}
// Priority 4: Hardcoded defaults
if defaultValue := s.getDefaultValue(category, key); defaultValue != nil {
return defaultValue, nil
}
return nil, fmt.Errorf("setting not found: %s.%s", category, key)
}
// SetSetting updates a security setting with validation and audit logging
func (s *SecuritySettingsService) SetSetting(category, key string, value interface{}, userID uuid.UUID, reason string) error {
// Validate the setting
if err := s.ValidateSetting(category, key, value); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Check if setting is sensitive and should be encrypted
isEncrypted := s.isSensitiveSetting(category, key)
// Check if setting exists
existing, err := s.settingsQueries.GetSetting(category, key)
if err != nil {
return fmt.Errorf("failed to check existing setting: %w", err)
}
var oldValue *string
var settingID uuid.UUID
if existing != nil {
// Update existing setting
updated, oldVal, err := s.settingsQueries.UpdateSetting(category, key, value, &userID)
if err != nil {
return fmt.Errorf("failed to update setting: %w", err)
}
oldValue = oldVal
settingID = updated.ID
} else {
// Create new setting
created, err := s.settingsQueries.CreateSetting(category, key, value, isEncrypted, &userID)
if err != nil {
return fmt.Errorf("failed to create setting: %w", err)
}
settingID = created.ID
}
// Create audit log
valueJSON, _ := json.Marshal(value)
if err := s.settingsQueries.CreateAuditLog(
settingID,
userID,
"update",
stringOrNil(oldValue),
string(valueJSON),
reason,
); err != nil {
// Log error but don't fail the operation
fmt.Printf("Warning: failed to create audit log: %v\n", err)
}
return nil
}
// GetAllSettings retrieves all security settings organized by category
func (s *SecuritySettingsService) GetAllSettings() (map[string]map[string]interface{}, error) {
// Get all default values first
result := s.getDefaultSettings()
// Override with database settings
dbSettings, err := s.settingsQueries.GetAllSettings()
if err != nil {
return nil, fmt.Errorf("failed to get database settings: %w", err)
}
for _, setting := range dbSettings {
var value interface{}
if setting.IsEncrypted {
decrypted, err := s.decrypt(setting.Value)
if err != nil {
return nil, fmt.Errorf("failed to decrypt setting %s.%s: %w", setting.Category, setting.Key, err)
}
if err := json.Unmarshal([]byte(decrypted), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal decrypted setting %s.%s: %w", setting.Category, setting.Key, err)
}
} else {
if err := json.Unmarshal([]byte(setting.Value), &value); err != nil {
return nil, fmt.Errorf("failed to unmarshal setting %s.%s: %w", setting.Category, setting.Key, err)
}
}
if result[setting.Category] == nil {
result[setting.Category] = make(map[string]interface{})
}
result[setting.Category][setting.Key] = value
}
// Override with config file settings
for category, settings := range result {
for key := range settings {
if configValue := s.getConfigValue(category, key); configValue != nil {
result[category][key] = configValue
}
}
}
// Override with environment variables
for category, settings := range result {
for key := range settings {
if envValue := s.getEnvironmentValue(category, key); envValue != nil {
result[category][key] = envValue
}
}
}
return result, nil
}
// GetSettingsByCategory retrieves all settings for a specific category
func (s *SecuritySettingsService) GetSettingsByCategory(category string) (map[string]interface{}, error) {
allSettings, err := s.GetAllSettings()
if err != nil {
return nil, err
}
if categorySettings, exists := allSettings[category]; exists {
return categorySettings, nil
}
return nil, fmt.Errorf("category not found: %s", category)
}
// ValidateSetting validates a security setting value
func (s *SecuritySettingsService) ValidateSetting(category, key string, value interface{}) error {
switch fmt.Sprintf("%s.%s", category, key) {
case "nonce_validation.timeout_seconds":
if timeout, ok := value.(float64); ok {
if timeout < 60 || timeout > 3600 {
return fmt.Errorf("nonce timeout must be between 60 and 3600 seconds")
}
} else {
return fmt.Errorf("nonce timeout must be a number")
}
case "command_signing.enforcement_mode", "update_signing.enforcement_mode", "machine_binding.enforcement_mode":
if mode, ok := value.(string); ok {
validModes := []string{"strict", "warning", "disabled"}
valid := false
for _, m := range validModes {
if mode == m {
valid = true
break
}
}
if !valid {
return fmt.Errorf("enforcement mode must be one of: strict, warning, disabled")
}
} else {
return fmt.Errorf("enforcement mode must be a string")
}
case "signature_verification.log_retention_days":
if days, ok := value.(float64); ok {
if days < 1 || days > 365 {
return fmt.Errorf("log retention must be between 1 and 365 days")
}
} else {
return fmt.Errorf("log retention must be a number")
}
case "command_signing.algorithm", "update_signing.algorithm":
if algo, ok := value.(string); ok {
if algo != "ed25519" {
return fmt.Errorf("only ed25519 algorithm is currently supported")
}
} else {
return fmt.Errorf("algorithm must be a string")
}
}
return nil
}
// InitializeDefaultSettings creates default settings in the database if they don't exist
func (s *SecuritySettingsService) InitializeDefaultSettings() error {
defaults := s.getDefaultSettings()
for category, settings := range defaults {
for key, value := range settings {
existing, err := s.settingsQueries.GetSetting(category, key)
if err != nil {
return fmt.Errorf("failed to check existing setting %s.%s: %w", category, key, err)
}
if existing == nil {
isEncrypted := s.isSensitiveSetting(category, key)
_, err := s.settingsQueries.CreateSetting(category, key, value, isEncrypted, nil)
if err != nil {
return fmt.Errorf("failed to create default setting %s.%s: %w", category, key, err)
}
}
}
}
return nil
}
// Helper methods
func (s *SecuritySettingsService) getDefaultSettings() map[string]map[string]interface{} {
return map[string]map[string]interface{}{
"command_signing": {
"enabled": true,
"enforcement_mode": "strict",
"algorithm": "ed25519",
},
"update_signing": {
"enabled": true,
"enforcement_mode": "strict",
"allow_unsigned": false,
},
"nonce_validation": {
"timeout_seconds": 600,
"reject_expired": true,
"log_expired_attempts": true,
},
"machine_binding": {
"enabled": true,
"enforcement_mode": "strict",
"strict_action": "reject",
},
"signature_verification": {
"log_level": "warn",
"log_retention_days": 30,
"log_failures": true,
"alert_on_failure": true,
},
}
}
func (s *SecuritySettingsService) getDefaultValue(category, key string) interface{} {
defaults := s.getDefaultSettings()
if cat, exists := defaults[category]; exists {
if value, exists := cat[key]; exists {
return value
}
}
return nil
}
func (s *SecuritySettingsService) getEnvironmentValue(category, key string) interface{} {
envKey := fmt.Sprintf("REDFLAG_%s_%s", strings.ToUpper(category), strings.ToUpper(key))
envValue := os.Getenv(envKey)
if envValue == "" {
return nil
}
// Try to parse as boolean
if strings.ToLower(envValue) == "true" {
return true
}
if strings.ToLower(envValue) == "false" {
return false
}
// Try to parse as number
if num, err := strconv.ParseFloat(envValue, 64); err == nil {
return num
}
// Return as string
return envValue
}
func (s *SecuritySettingsService) getConfigValue(category, key string) interface{} {
// This would be implemented based on your config structure
// For now, returning nil to prioritize env vars and database
return nil
}
func (s *SecuritySettingsService) isSensitiveSetting(category, key string) bool {
// Define which settings are sensitive and should be encrypted
sensitive := map[string]bool{
"command_signing.private_key": true,
"update_signing.private_key": true,
"machine_binding.server_key": true,
"encryption.master_key": true,
}
settingKey := fmt.Sprintf("%s.%s", category, key)
return sensitive[settingKey]
}
func (s *SecuritySettingsService) encrypt(value string) (string, error) {
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(value), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (s *SecuritySettingsService) decrypt(encryptedValue string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encryptedValue)
if err != nil {
return "", err
}
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func stringOrNil(s *string) string {
if s == nil {
return ""
}
return *s
}
// GetNonceTimeout returns the current nonce validation timeout in seconds
func (s *SecuritySettingsService) GetNonceTimeout() (int, error) {
value, err := s.GetSetting("nonce_validation", "timeout_seconds")
if err != nil {
return 600, err // Return default on error
}
if timeout, ok := value.(float64); ok {
return int(timeout), nil
}
return 600, nil // Return default if type is wrong
}
// GetEnforcementMode returns the enforcement mode for a given category
func (s *SecuritySettingsService) GetEnforcementMode(category string) (string, error) {
value, err := s.GetSetting(category, "enforcement_mode")
if err != nil {
return "strict", err // Return default on error
}
if mode, ok := value.(string); ok {
return mode, nil
}
return "strict", nil // Return default if type is wrong
}
// IsSignatureVerificationEnabled returns whether signature verification is enabled for a category
func (s *SecuritySettingsService) IsSignatureVerificationEnabled(category string) (bool, error) {
value, err := s.GetSetting(category, "enabled")
if err != nil {
return true, err // Return default on error
}
if enabled, ok := value.(bool); ok {
return enabled, nil
}
return true, nil // Return default if type is wrong
}