Files
Redflag/aggregator-server/internal/api/handlers/setup.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

589 lines
24 KiB
Go

package handlers
import (
"crypto/ed25519"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"github.com/Fimeg/RedFlag/aggregator-server/internal/config"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
_ "github.com/lib/pq"
)
// SetupHandler handles server configuration
type SetupHandler struct {
configPath string
}
func NewSetupHandler(configPath string) *SetupHandler {
return &SetupHandler{
configPath: configPath,
}
}
// updatePostgresPassword updates the PostgreSQL user password
func updatePostgresPassword(dbHost, dbPort, dbUser, currentPassword, newPassword string) error {
// Connect to PostgreSQL with current credentials
connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/postgres?sslmode=disable", dbUser, currentPassword, dbHost, dbPort)
db, err := sql.Open("postgres", connStr)
if err != nil {
return fmt.Errorf("failed to connect to PostgreSQL: %v", err)
}
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
return fmt.Errorf("failed to ping PostgreSQL: %v", err)
}
// Update the password
_, err = db.Exec("ALTER USER "+pq.QuoteIdentifier(dbUser)+" PASSWORD '"+newPassword+"'")
if err != nil {
return fmt.Errorf("failed to update PostgreSQL password: %v", err)
}
fmt.Println("PostgreSQL password updated successfully")
return nil
}
// createSharedEnvContentForDisplay generates the .env file content for display
func createSharedEnvContentForDisplay(req struct {
AdminUser string `json:"adminUser"`
AdminPass string `json:"adminPassword"`
DBHost string `json:"dbHost"`
DBPort string `json:"dbPort"`
DBName string `json:"dbName"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
ServerHost string `json:"serverHost"`
ServerPort string `json:"serverPort"`
MaxSeats string `json:"maxSeats"`
}, jwtSecret string, signingKeys map[string]string) (string, error) {
// Generate .env file content for user to copy
envContent := fmt.Sprintf(`# RedFlag Environment Configuration
# Generated by web setup on 2025-12-13
# [WARNING] SECURITY CRITICAL: Backup the signing key or you will lose access to all agents
# PostgreSQL Configuration (for PostgreSQL container)
POSTGRES_DB=%s
POSTGRES_USER=%s
POSTGRES_PASSWORD=%s
# RedFlag Security - Ed25519 Signing Keys
# These keys are used to cryptographically sign agent updates and commands
# BACKUP THE PRIVATE KEY IMMEDIATELY - Store it in a secure location like a password manager
REDFLAG_SIGNING_PRIVATE_KEY=%s
REDFLAG_SIGNING_PUBLIC_KEY=%s
# RedFlag Server Configuration
REDFLAG_SERVER_HOST=%s
REDFLAG_SERVER_PORT=%s
REDFLAG_DB_HOST=%s
REDFLAG_DB_PORT=%s
REDFLAG_DB_NAME=%s
REDFLAG_DB_USER=%s
REDFLAG_DB_PASSWORD=%s
REDFLAG_ADMIN_USER=%s
REDFLAG_ADMIN_PASSWORD=%s
REDFLAG_JWT_SECRET=%s
REDFLAG_TOKEN_EXPIRY=24h
REDFLAG_MAX_TOKENS=100
REDFLAG_MAX_SEATS=%s
# Security Settings
REDFLAG_SECURITY_COMMAND_SIGNING_ENFORCEMENT=strict
REDFLAG_SECURITY_NONCE_TIMEOUT=600
REDFLAG_SECURITY_LOG_LEVEL=warn
`,
req.DBName, req.DBUser, req.DBPassword,
signingKeys["private_key"], signingKeys["public_key"],
req.ServerHost, req.ServerPort,
req.DBHost, req.DBPort, req.DBName, req.DBUser, req.DBPassword,
req.AdminUser, req.AdminPass, jwtSecret, req.MaxSeats)
return envContent, nil
}
// ShowSetupPage displays the web setup interface
func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
// Display setup page - configuration will be generated via web interface
fmt.Println("Showing setup page - configuration will be generated via web interface")
html := `
<!DOCTYPE html>
<html>
<head>
<title>RedFlag - Server Configuration</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }
.container { max-width: 800px; margin: 0 auto; padding: 40px 20px; }
.card { background: white; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); overflow: hidden; }
.header { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; padding: 30px; text-align: center; }
.content { padding: 40px; }
h1 { margin: 0; font-size: 2.5rem; font-weight: 700; }
.subtitle { margin: 10px 0 0 0; opacity: 0.9; font-size: 1.1rem; }
.form-section { margin: 30px 0; }
.form-section h3 { color: #4f46e5; margin-bottom: 15px; font-size: 1.2rem; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 5px; font-weight: 500; color: #374151; }
input, select { width: 100%%; padding: 12px; border: 2px solid #e5e7eb; border-radius: 6px; font-size: 1rem; transition: border-color 0.3s; }
input:focus, select:focus { outline: none; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); }
.btn { background: linear-gradient(135deg, #4f46e5 0%%, #7c3aed 100%%); color: white; border: none; padding: 14px 28px; border-radius: 6px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.2s; }
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.success { color: #10b981; background: #ecfdf5; padding: 12px; border-radius: 6px; border: 1px solid #10b981; }
.error { color: #ef4444; background: #fef2f2; padding: 12px; border-radius: 6px; border: 1px solid #ef4444; }
.loading { display: none; text-align: center; margin: 20px 0; }
.spinner { border: 3px solid #f3f3f3; border-top: 3px solid #4f46e5; border-radius: 50%%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto; }
@keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="header">
<h1>[START] RedFlag Server Setup</h1>
<p class="subtitle">Configure your RedFlag deployment</p>
</div>
<div class="content">
<form id="setupForm">
<div class="form-section">
<h3>📊 Server Configuration</h3>
<div class="form-group">
<label for="serverHost">Server Host</label>
<input type="text" id="serverHost" name="serverHost" value="0.0.0.0" required>
</div>
<div class="form-group">
<label for="serverPort">Server Port</label>
<input type="number" id="serverPort" name="serverPort" value="8080" required>
</div>
</div>
<div class="form-section">
<h3>🗄️ Database Configuration</h3>
<div class="form-group">
<label for="dbHost">Database Host</label>
<input type="text" id="dbHost" name="dbHost" value="postgres" required>
</div>
<div class="form-group">
<label for="dbPort">Database Port</label>
<input type="number" id="dbPort" name="dbPort" value="5432" required>
</div>
<div class="form-group">
<label for="dbName">Database Name</label>
<input type="text" id="dbName" name="dbName" value="redflag" required>
</div>
<div class="form-group">
<label for="dbUser">Database User</label>
<input type="text" id="dbUser" name="dbUser" value="redflag" required>
</div>
<div class="form-group">
<label for="dbPassword">Database Password</label>
<input type="password" id="dbPassword" name="dbPassword" placeholder="Enter a secure database password" required>
</div>
</div>
<div class="form-section">
<h3>👤 Administrator Account</h3>
<div class="form-group">
<label for="adminUser">Admin Username</label>
<input type="text" id="adminUser" name="adminUser" value="admin" required>
</div>
<div class="form-group">
<label for="adminPassword">Admin Password</label>
<input type="password" id="adminPassword" name="adminPassword" placeholder="Enter a secure admin password" required>
</div>
</div>
<div class="form-section">
<h3>🔧 Agent Settings</h3>
<div class="form-group">
<label for="maxSeats">Maximum Agent Seats</label>
<input type="number" id="maxSeats" name="maxSeats" value="50" min="1" max="1000" required>
<small style="color: #6b7280; font-size: 0.875rem;">Maximum number of agents that can register</small>
</div>
</div>
<button type="submit" class="btn" id="submitBtn">
[START] Configure RedFlag Server
</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Configuring your RedFlag server...</p>
</div>
<div id="result"></div>
</div>
</div>
</div>
<script>
document.getElementById('setupForm').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
const loading = document.getElementById('loading');
const result = document.getElementById('result');
// Get form values
const formData = {
serverHost: document.getElementById('serverHost').value,
serverPort: document.getElementById('serverPort').value,
dbHost: document.getElementById('dbHost').value,
dbPort: document.getElementById('dbPort').value,
dbName: document.getElementById('dbName').value,
dbUser: document.getElementById('dbUser').value,
dbPassword: document.getElementById('dbPassword').value,
adminUser: document.getElementById('adminUser').value,
adminPassword: document.getElementById('adminPassword').value,
maxSeats: document.getElementById('maxSeats').value
};
// Validate inputs
if (!formData.adminUser || !formData.adminPassword) {
result.innerHTML = '<div class="error">[ERROR] Admin username and password are required</div>';
return;
}
if (!formData.dbHost || !formData.dbPort || !formData.dbName || !formData.dbUser || !formData.dbPassword) {
result.innerHTML = '<div class="error">[ERROR] All database fields are required</div>';
return;
}
// Show loading
submitBtn.disabled = true;
loading.style.display = 'block';
result.innerHTML = '';
try {
const response = await fetch('/api/setup/configure', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
const resultData = await response.json();
if (response.ok) {
let resultHtml = '<div class="success">';
resultHtml += '<h3>[SUCCESS] Configuration Generated Successfully!</h3>';
resultHtml += '<p><strong>Your JWT Secret:</strong> <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 3px;">' + resultData.jwtSecret + '</code> ';
resultHtml += '<button onclick="copyJWT(\'' + resultData.jwtSecret + '\')" style="background: #4f46e5; color: white; border: none; padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 0.8rem;">📋 Copy</button></p>';
resultHtml += '<p><strong>[WARNING] Important Next Steps:</strong></p>';
resultHtml += '<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 15px 0;">';
resultHtml += '<p style="margin: 0; color: #92400e;"><strong>🔧 Complete Setup Required:</strong></p>';
resultHtml += '<ol style="margin: 10px 0 0 0; color: #92400e;">';
resultHtml += '<li>Replace the bootstrap environment variables with the newly generated ones below</li>';
resultHtml += '<li>Run: <code style="background: #fef3c7; padding: 2px 6px; border-radius: 3px;">' + resultData.manualRestartCommand + '</code></li>';
resultHtml += '</ol>';
resultHtml += '<p style="margin: 10px 0 0 0; color: #92400e; font-size: 0.9rem;"><strong>This step is required to apply your configuration and run database migrations.</strong></p>';
resultHtml += '</div>';
resultHtml += '</div>';
resultHtml += '<div style="margin-top: 20px;">';
resultHtml += '<h4>📄 Configuration Content:</h4>';
resultHtml += '<textarea readonly style="width: 100%%; height: 300px; font-family: monospace; font-size: 0.85rem; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #f9fafb;">' + resultData.envContent + '</textarea>';
resultHtml += '<button onclick="copyConfig()" style="background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; margin-top: 10px;">📋 Copy All Configuration</button>';
resultHtml += '</div>';
result.innerHTML = resultHtml;
loading.style.display = 'none';
// Store JWT for copy function
window.jwtSecret = resultData.jwtSecret;
window.envContent = resultData.envContent;
} else {
result.innerHTML = '<div class="error">[ERROR] Error: ' + resultData.error + '</div>';
submitBtn.disabled = false;
loading.style.display = 'none';
}
} catch (error) {
result.innerHTML = '<div class="error">[ERROR] Network error: ' + error.message + '</div>';
submitBtn.disabled = false;
loading.style.display = 'none';
}
});
function copyJWT(jwt) {
navigator.clipboard.writeText(jwt).then(() => {
alert('JWT secret copied to clipboard!');
}).catch(() => {
prompt('Copy this JWT secret:', jwt);
});
}
function copyConfig() {
if (window.envContent) {
navigator.clipboard.writeText(window.envContent).then(() => {
alert('Configuration copied to clipboard!');
}).catch(() => {
prompt('Copy this configuration:', window.envContent);
});
}
}
</script>
</body>
</html>`
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
}
// ConfigureServer handles the configuration submission
func (h *SetupHandler) ConfigureServer(c *gin.Context) {
var req struct {
AdminUser string `json:"adminUser"`
AdminPass string `json:"adminPassword"`
DBHost string `json:"dbHost"`
DBPort string `json:"dbPort"`
DBName string `json:"dbName"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
ServerHost string `json:"serverHost"`
ServerPort string `json:"serverPort"`
MaxSeats string `json:"maxSeats"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
// Validate inputs
if req.AdminUser == "" || req.AdminPass == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Admin username and password are required"})
return
}
if req.DBHost == "" || req.DBPort == "" || req.DBName == "" || req.DBUser == "" || req.DBPassword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "All database fields are required"})
return
}
// Parse numeric values
dbPort, err := strconv.Atoi(req.DBPort)
if err != nil || dbPort <= 0 || dbPort > 65535 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid database port"})
return
}
serverPort, err := strconv.Atoi(req.ServerPort)
if err != nil || serverPort <= 0 || serverPort > 65535 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server port"})
return
}
maxSeats, err := strconv.Atoi(req.MaxSeats)
if err != nil || maxSeats <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid maximum agent seats"})
return
}
// Generate secure JWT secret (not derived from credentials for security)
jwtSecret, err := config.GenerateSecureToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate JWT secret"})
return
}
// SECURITY: Generate Ed25519 signing keypair (critical for v0.2.x)
fmt.Println("[START] Generating Ed25519 signing keypair for security...")
signingPublicKey, signingPrivateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fmt.Printf("CRITICAL ERROR: Failed to generate signing keys: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate signing keys. Security features cannot be enabled."})
return
}
signingKeys := map[string]string{
"public_key": hex.EncodeToString(signingPublicKey),
"private_key": hex.EncodeToString(signingPrivateKey),
}
fmt.Printf("[SUCCESS] Generated Ed25519 keypair - Fingerprint: %s\n", signingKeys["public_key"][:16])
fmt.Println("[WARNING] SECURITY WARNING: Backup the private key immediately or you will lose access to all agents!")
// Step 1: Update PostgreSQL password from bootstrap to user password
fmt.Println("Updating PostgreSQL password from bootstrap to user-provided password...")
bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
if err := updatePostgresPassword(req.DBHost, req.DBPort, req.DBUser, bootstrapPassword, req.DBPassword); err != nil {
fmt.Printf("CRITICAL ERROR: Failed to update PostgreSQL password: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update database password. Setup cannot continue.",
"details": err.Error(),
"help": "Ensure PostgreSQL is accessible and the bootstrap password is correct. Check Docker logs for details.",
})
return
}
fmt.Println("PostgreSQL password successfully updated from bootstrap to user-provided password")
// Step 2: Generate configuration content for manual update
fmt.Println("Generating configuration content for manual .env file update...")
// Generate the complete .env file content for the user to copy
newEnvContent, err := createSharedEnvContentForDisplay(req, jwtSecret, signingKeys)
if err != nil {
fmt.Printf("Failed to generate .env content: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate configuration content"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Configuration generated successfully!",
"envContent": newEnvContent,
"restartMessage": "Please replace the bootstrap environment variables with the newly generated ones, then run: docker-compose down && docker-compose up -d",
"manualRestartRequired": true,
"manualRestartCommand": "docker-compose down && docker-compose up -d",
"configFilePath": "./config/.env",
"securityNotice": "[WARNING] A signing key has been generated. BACKUP THE PRIVATE KEY or you will lose access to all agents!",
"publicKeyFingerprint": signingKeys["public_key"][:16] + "...",
})
}
// GenerateSigningKeys generates Ed25519 keypair for agent update signing
func (h *SetupHandler) GenerateSigningKeys(c *gin.Context) {
// Prevent caching of generated keys (security critical)
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
// Load configuration to check for existing key
cfg, err := config.Load() // This will load from .env file
if err == nil && cfg.SigningPrivateKey != "" {
c.JSON(http.StatusConflict, gin.H{"error": "A signing key is already configured for this server."})
return
}
// Generate Ed25519 keypair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate keypair"})
return
}
// Encode to hex
publicKeyHex := hex.EncodeToString(publicKey)
privateKeyHex := hex.EncodeToString(privateKey)
// Generate fingerprint (first 16 chars)
fingerprint := publicKeyHex[:16]
// Log key generation for security audit trail (only fingerprint, not full key)
fmt.Printf("Generated new Ed25519 keypair - Fingerprint: %s\n", fingerprint)
c.JSON(http.StatusOK, gin.H{
"public_key": publicKeyHex,
"private_key": privateKeyHex,
"fingerprint": fingerprint,
"algorithm": "ed25519",
"key_size": 32,
})
}
// ConfigureSecrets creates all Docker secrets automatically
func (h *SetupHandler) ConfigureSecrets(c *gin.Context) {
// Check if Docker API is available
if !services.IsDockerAvailable() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Docker API not available",
"message": "Docker socket is not mounted. Please ensure the server can access Docker daemon",
})
return
}
// Create Docker secrets service
dockerSecrets, err := services.NewDockerSecretsService()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to connect to Docker",
"details": err.Error(),
})
return
}
defer dockerSecrets.Close()
// Generate all required secrets
type SecretConfig struct {
Name string
Value string
}
secrets := []SecretConfig{
{"redflag_admin_password", config.GenerateSecurePassword()},
{"redflag_jwt_secret", generateSecureJWTSecret()},
{"redflag_db_password", config.GenerateSecurePassword()},
}
// Try to create each secret
createdSecrets := []string{}
failedSecrets := []string{}
for _, secret := range secrets {
if err := dockerSecrets.CreateSecret(secret.Name, secret.Value); err != nil {
failedSecrets = append(failedSecrets, fmt.Sprintf("%s: %v", secret.Name, err))
} else {
createdSecrets = append(createdSecrets, secret.Name)
}
}
// Generate signing keys
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate signing keys",
"details": err.Error(),
})
return
}
publicKeyHex := hex.EncodeToString(publicKey)
privateKeyHex := hex.EncodeToString(privateKey)
// Create signing key secret
if err := dockerSecrets.CreateSecret("redflag_signing_private_key", privateKeyHex); err != nil {
failedSecrets = append(failedSecrets, fmt.Sprintf("redflag_signing_private_key: %v", err))
} else {
createdSecrets = append(createdSecrets, "redflag_signing_private_key")
}
response := gin.H{
"created_secrets": createdSecrets,
"public_key": publicKeyHex,
"fingerprint": publicKeyHex[:16],
}
if len(failedSecrets) > 0 {
response["failed_secrets"] = failedSecrets
c.JSON(http.StatusMultiStatus, response)
return
}
c.JSON(http.StatusOK, response)
}
// GenerateSecurePassword generates a secure password for admin/db
func generateSecurePassword() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)[:16] // 16 character random password
}
// generateSecureJWTSecret generates a secure JWT secret
func generateSecureJWTSecret() string {
bytes := make([]byte, 32)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}