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>
469 lines
13 KiB
Go
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
|
|
} |