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

263 lines
6.7 KiB
Go

package services
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
)
// SecretsManager handles Docker secrets creation and management
type SecretsManager struct {
secretsPath string
encryptionKey string
}
// NewSecretsManager creates a new secrets manager
func NewSecretsManager() *SecretsManager {
secretsPath := getSecretsPath()
return &SecretsManager{
secretsPath: secretsPath,
}
}
// CreateDockerSecrets creates Docker secrets from the provided secrets map
func (sm *SecretsManager) CreateDockerSecrets(secrets map[string]string) error {
if len(secrets) == 0 {
return nil
}
// Ensure secrets directory exists
if err := os.MkdirAll(sm.secretsPath, 0755); err != nil {
return fmt.Errorf("failed to create secrets directory: %w", err)
}
// Generate encryption key if not provided
if sm.encryptionKey == "" {
key, err := sm.GenerateEncryptionKey()
if err != nil {
return fmt.Errorf("failed to generate encryption key: %w", err)
}
sm.encryptionKey = key
}
// Create each secret
for name, value := range secrets {
if err := sm.createSecret(name, value); err != nil {
return fmt.Errorf("failed to create secret %s: %w", name, err)
}
}
return nil
}
// createSecret creates a single Docker secret
func (sm *SecretsManager) createSecret(name, value string) error {
secretPath := filepath.Join(sm.secretsPath, name)
// Encrypt sensitive values
encryptedValue, err := sm.encryptSecret(value)
if err != nil {
return fmt.Errorf("failed to encrypt secret: %w", err)
}
// Write secret file with restricted permissions
if err := os.WriteFile(secretPath, encryptedValue, 0400); err != nil {
return fmt.Errorf("failed to write secret file: %w", err)
}
return nil
}
// encryptSecret encrypts a secret value using AES-256-GCM
func (sm *SecretsManager) encryptSecret(value string) ([]byte, error) {
// Generate key from master key
keyBytes, err := hex.DecodeString(sm.encryptionKey)
if err != nil {
return nil, fmt.Errorf("invalid encryption key format: %w", err)
}
// Create cipher
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
// Generate nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt
ciphertext := gcm.Seal(nonce, nonce, []byte(value), nil)
// Prepend nonce to ciphertext
result := append(nonce, ciphertext...)
return result, nil
}
// decryptSecret decrypts a secret value using AES-256-GCM
func (sm *SecretsManager) decryptSecret(encryptedValue []byte) (string, error) {
if len(encryptedValue) < 12 { // GCM nonce size
return "", fmt.Errorf("invalid encrypted value length")
}
// Generate key from master key
keyBytes, err := hex.DecodeString(sm.encryptionKey)
if err != nil {
return "", fmt.Errorf("invalid encryption key format: %w", err)
}
// Create cipher
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
// Extract nonce and ciphertext
nonce := encryptedValue[:gcm.NonceSize()]
ciphertext := encryptedValue[gcm.NonceSize():]
// Decrypt
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt secret: %w", err)
}
return string(plaintext), nil
}
// GenerateEncryptionKey generates a new encryption key
func (sm *SecretsManager) GenerateEncryptionKey() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate encryption key: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// SetEncryptionKey sets the master encryption key
func (sm *SecretsManager) SetEncryptionKey(key string) {
sm.encryptionKey = key
}
// GetEncryptionKey returns the current encryption key
func (sm *SecretsManager) GetEncryptionKey() string {
return sm.encryptionKey
}
// GetSecretsPath returns the current secrets path
func (sm *SecretsManager) GetSecretsPath() string {
return sm.secretsPath
}
// ValidateSecrets validates that all required secrets exist
func (sm *SecretsManager) ValidateSecrets(requiredSecrets []string) error {
for _, secretName := range requiredSecrets {
secretPath := filepath.Join(sm.secretsPath, secretName)
if _, err := os.Stat(secretPath); os.IsNotExist(err) {
return fmt.Errorf("required secret not found: %s", secretName)
}
}
return nil
}
// ListSecrets returns a list of all created secrets
func (sm *SecretsManager) ListSecrets() ([]string, error) {
entries, err := os.ReadDir(sm.secretsPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
}
var secrets []string
for _, entry := range entries {
if !entry.IsDir() {
secrets = append(secrets, entry.Name())
}
}
return secrets, nil
}
// RemoveSecret removes a Docker secret
func (sm *SecretsManager) RemoveSecret(name string) error {
secretPath := filepath.Join(sm.secretsPath, name)
return os.Remove(secretPath)
}
// Cleanup removes all secrets and the secrets directory
func (sm *SecretsManager) Cleanup() error {
if _, err := os.Stat(sm.secretsPath); os.IsNotExist(err) {
return nil
}
// Remove all files in the directory
entries, err := os.ReadDir(sm.secretsPath)
if err != nil {
return fmt.Errorf("failed to read secrets directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
if err := os.Remove(filepath.Join(sm.secretsPath, entry.Name())); err != nil {
return fmt.Errorf("failed to remove secret %s: %w", entry.Name(), err)
}
}
}
// Remove the directory itself
return os.Remove(sm.secretsPath)
}
// getSecretsPath returns the platform-specific secrets path
func getSecretsPath() string {
if runtime.GOOS == "windows" {
return `C:\ProgramData\Docker\secrets`
}
return "/run/secrets"
}
// IsDockerEnvironment checks if running in Docker
func IsDockerEnvironment() bool {
// Check for .dockerenv file
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Check for Docker in cgroup
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
if containsString(string(data), "docker") {
return true
}
}
return false
}
// containsString checks if a string contains a substring
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr ||
(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr)))
}