feat: machine binding and version enforcement

migration 017 adds machine_id to agents table
middleware validates X-Machine-ID header on authed routes
agent client sends machine ID with requests
MIN_AGENT_VERSION config defaults 0.1.22
version utils added for comparison

blocks config copying attacks via hardware fingerprint
old agents get 426 upgrade required
breaking: <0.1.22 agents rejected
This commit is contained in:
Fimeg
2025-11-02 09:30:04 -05:00
parent 99480f3fe3
commit ec3ba88459
48 changed files with 3811 additions and 122 deletions

View File

@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"log"
"net/http"
"path/filepath"
"time"
@@ -129,6 +130,7 @@ func main() {
registrationTokenQueries := queries.NewRegistrationTokenQueries(db.DB)
userQueries := queries.NewUserQueries(db.DB)
subsystemQueries := queries.NewSubsystemQueries(db.DB)
agentUpdateQueries := queries.NewAgentUpdateQueries(db.DB)
// Ensure admin user exists
if err := userQueries.EnsureAdminUser(cfg.Admin.Username, cfg.Admin.Username+"@redflag.local", cfg.Admin.Password); err != nil {
@@ -141,6 +143,20 @@ func main() {
timezoneService := services.NewTimezoneService(cfg)
timeoutService := services.NewTimeoutService(commandQueries, updateQueries)
// Initialize signing service if private key is configured
var signingService *services.SigningService
if cfg.SigningPrivateKey != "" {
var err error
signingService, err = services.NewSigningService(cfg.SigningPrivateKey)
if err != nil {
log.Printf("Warning: Failed to initialize signing service: %v", err)
} else {
log.Printf("✅ Ed25519 signing service initialized")
}
} else {
log.Printf("Warning: No signing private key configured - agent update signing disabled")
}
// Initialize rate limiter
rateLimiter := middleware.NewRateLimiter()
@@ -156,6 +172,21 @@ func main() {
downloadHandler := handlers.NewDownloadHandler(filepath.Join("/app"), cfg)
subsystemHandler := handlers.NewSubsystemHandler(subsystemQueries, commandQueries)
// Initialize verification handler
var verificationHandler *handlers.VerificationHandler
if signingService != nil {
verificationHandler = handlers.NewVerificationHandler(agentQueries, signingService)
}
// Initialize agent update handler
var agentUpdateHandler *handlers.AgentUpdateHandler
if signingService != nil {
agentUpdateHandler = handlers.NewAgentUpdateHandler(agentQueries, agentUpdateQueries, commandQueries, signingService, agentHandler)
}
// Initialize system handler
systemHandler := handlers.NewSystemHandler(signingService)
// Setup router
router := gin.Default()
@@ -178,17 +209,23 @@ func main() {
api.POST("/auth/logout", authHandler.Logout)
api.GET("/auth/verify", authHandler.VerifyToken)
// Public system routes (no authentication required)
api.GET("/public-key", rateLimiter.RateLimit("public_access", middleware.KeyByIP), systemHandler.GetPublicKey)
api.GET("/info", rateLimiter.RateLimit("public_access", middleware.KeyByIP), systemHandler.GetSystemInfo)
// Public routes (no authentication required, with rate limiting)
api.POST("/agents/register", rateLimiter.RateLimit("agent_registration", middleware.KeyByIP), agentHandler.RegisterAgent)
api.POST("/agents/renew", rateLimiter.RateLimit("public_access", middleware.KeyByIP), agentHandler.RenewToken)
// Public download routes (no authentication - agents need these!)
api.GET("/downloads/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadAgent)
api.GET("/downloads/updates/:package_id", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadUpdatePackage)
api.GET("/install/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.InstallScript)
// Protected agent routes
// Protected agent routes (with machine binding security)
agents := api.Group("/agents")
agents.Use(middleware.AuthMiddleware())
agents.Use(middleware.MachineBindingMiddleware(agentQueries, cfg.MinAgentVersion)) // v0.1.22: Prevent config copying
{
agents.GET("/:id/commands", agentHandler.GetCommands)
agents.POST("/:id/updates", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportUpdates)
@@ -196,6 +233,13 @@ func main() {
agents.POST("/:id/dependencies", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportDependencies)
agents.POST("/:id/system-info", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.ReportSystemInfo)
agents.POST("/:id/rapid-mode", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), agentHandler.SetRapidPollingMode)
agents.POST("/:id/verify-signature", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), func(c *gin.Context) {
if verificationHandler == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signature verification service not available"})
return
}
verificationHandler.VerifySignature(c)
})
agents.DELETE("/:id", agentHandler.UnregisterAgent)
// Subsystem routes
@@ -231,6 +275,14 @@ func main() {
dashboard.POST("/updates/:id/install", updateHandler.InstallUpdate)
dashboard.POST("/updates/:id/confirm-dependencies", updateHandler.ConfirmDependencies)
// Agent update routes
if agentUpdateHandler != nil {
dashboard.POST("/agents/:id/update", agentUpdateHandler.UpdateAgent)
dashboard.POST("/agents/bulk-update", agentUpdateHandler.BulkUpdateAgents)
dashboard.GET("/updates/packages", agentUpdateHandler.ListUpdatePackages)
dashboard.POST("/updates/packages/sign", agentUpdateHandler.SignUpdatePackage)
}
// Log routes
dashboard.GET("/logs", updateHandler.GetAllLogs)
dashboard.GET("/logs/active", updateHandler.GetActiveOperations)

View File

@@ -7,9 +7,8 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
golang.org/x/term v0.33.0
golang.org/x/crypto v0.40.0
)
require (
@@ -36,7 +35,6 @@ require (
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect

View File

@@ -38,8 +38,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -92,8 +90,6 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=

View File

@@ -0,0 +1,401 @@
package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AgentUpdateHandler handles agent update operations
type AgentUpdateHandler struct {
agentQueries *queries.AgentQueries
agentUpdateQueries *queries.AgentUpdateQueries
commandQueries *queries.CommandQueries
signingService *services.SigningService
agentHandler *AgentHandler
}
// NewAgentUpdateHandler creates a new agent update handler
func NewAgentUpdateHandler(aq *queries.AgentQueries, auq *queries.AgentUpdateQueries, cq *queries.CommandQueries, ss *services.SigningService, ah *AgentHandler) *AgentUpdateHandler {
return &AgentUpdateHandler{
agentQueries: aq,
agentUpdateQueries: auq,
commandQueries: cq,
signingService: ss,
agentHandler: ah,
}
}
// UpdateAgent handles POST /api/v1/agents/:id/update (manual agent update)
func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) {
var req models.AgentUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify the agent exists
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Check if agent is already updating
if agent.IsUpdating {
c.JSON(http.StatusConflict, gin.H{
"error": "agent is already updating",
"current_update": agent.UpdatingToVersion,
"initiated_at": agent.UpdateInitiatedAt,
})
return
}
// Validate platform compatibility
if !h.isPlatformCompatible(agent, req.Platform) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("platform %s is not compatible with agent %s/%s",
req.Platform, agent.OSType, agent.OSArchitecture),
})
return
}
// Get the update package
pkg, err := h.agentUpdateQueries.GetUpdatePackageByVersion(req.Version, req.Platform, agent.OSArchitecture)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("update package not found: %v", err)})
return
}
// Update agent status to "updating"
if err := h.agentQueries.UpdateAgentUpdatingStatus(req.AgentID, true, &req.Version); err != nil {
log.Printf("Failed to update agent %s status to updating: %v", req.AgentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initiate update"})
return
}
// Generate nonce for replay protection
nonceUUID := uuid.New()
nonceTimestamp := time.Now()
var nonceSignature string
if h.signingService != nil {
var err error
nonceSignature, err = h.signingService.SignNonce(nonceUUID, nonceTimestamp)
if err != nil {
log.Printf("Failed to sign nonce: %v", err)
h.agentQueries.UpdateAgentUpdatingStatus(req.AgentID, false, nil) // Rollback
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sign nonce"})
return
}
}
// Create update command for agent
commandType := "update_agent"
commandParams := map[string]interface{}{
"version": req.Version,
"platform": req.Platform,
"download_url": fmt.Sprintf("/api/v1/downloads/updates/%s", pkg.ID),
"signature": pkg.Signature,
"checksum": pkg.Checksum,
"file_size": pkg.FileSize,
"nonce_uuid": nonceUUID.String(),
"nonce_timestamp": nonceTimestamp.Format(time.RFC3339),
"nonce_signature": nonceSignature,
}
// Schedule the update if requested
if req.Scheduled != nil {
scheduledTime, err := time.Parse(time.RFC3339, *req.Scheduled)
if err != nil {
h.agentQueries.UpdateAgentUpdatingStatus(req.AgentID, false, nil) // Rollback
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scheduled time format"})
return
}
commandParams["scheduled_at"] = scheduledTime
}
// Create the command in database
command := &models.AgentCommand{
ID: uuid.New(),
AgentID: req.AgentID,
CommandType: commandType,
Params: commandParams,
Status: models.CommandStatusPending,
Source: "web_ui",
CreatedAt: time.Now(),
}
if err := h.commandQueries.CreateCommand(command); err != nil {
// Rollback the updating status
h.agentQueries.UpdateAgentUpdatingStatus(req.AgentID, false, nil)
log.Printf("Failed to create update command for agent %s: %v", req.AgentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create command"})
return
}
log.Printf("✅ Agent update initiated for %s: %s (%s)", agent.Hostname, req.Version, req.Platform)
response := models.AgentUpdateResponse{
Message: "Update initiated successfully",
UpdateID: command.ID.String(),
DownloadURL: fmt.Sprintf("/api/v1/downloads/updates/%s", pkg.ID),
Signature: pkg.Signature,
Checksum: pkg.Checksum,
FileSize: pkg.FileSize,
EstimatedTime: h.estimateUpdateTime(pkg.FileSize),
}
c.JSON(http.StatusOK, response)
}
// BulkUpdateAgents handles POST /api/v1/agents/bulk-update (bulk agent update)
func (h *AgentUpdateHandler) BulkUpdateAgents(c *gin.Context) {
var req models.BulkAgentUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.AgentIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no agent IDs provided"})
return
}
if len(req.AgentIDs) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"error": "too many agents in bulk update (max 50)"})
return
}
// Get the update package first to validate it exists
pkg, err := h.agentUpdateQueries.GetUpdatePackageByVersion(req.Version, req.Platform, "")
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("update package not found: %v", err)})
return
}
// Validate all agents exist and are compatible
var results []map[string]interface{}
var errors []string
for _, agentID := range req.AgentIDs {
agent, err := h.agentQueries.GetAgentByID(agentID)
if err != nil {
errors = append(errors, fmt.Sprintf("Agent %s: not found", agentID))
continue
}
if agent.IsUpdating {
errors = append(errors, fmt.Sprintf("Agent %s: already updating", agentID))
continue
}
if !h.isPlatformCompatible(agent, req.Platform) {
errors = append(errors, fmt.Sprintf("Agent %s: platform incompatible", agentID))
continue
}
// Update agent status
if err := h.agentQueries.UpdateAgentUpdatingStatus(agentID, true, &req.Version); err != nil {
errors = append(errors, fmt.Sprintf("Agent %s: failed to update status", agentID))
continue
}
// Generate nonce for replay protection
nonceUUID := uuid.New()
nonceTimestamp := time.Now()
var nonceSignature string
if h.signingService != nil {
var err error
nonceSignature, err = h.signingService.SignNonce(nonceUUID, nonceTimestamp)
if err != nil {
errors = append(errors, fmt.Sprintf("Agent %s: failed to sign nonce", agentID))
h.agentQueries.UpdateAgentUpdatingStatus(agentID, false, nil)
continue
}
}
// Create update command
command := &models.AgentCommand{
ID: uuid.New(),
AgentID: agentID,
CommandType: "update_agent",
Params: map[string]interface{}{
"version": req.Version,
"platform": req.Platform,
"download_url": fmt.Sprintf("/api/v1/downloads/updates/%s", pkg.ID),
"signature": pkg.Signature,
"checksum": pkg.Checksum,
"file_size": pkg.FileSize,
"nonce_uuid": nonceUUID.String(),
"nonce_timestamp": nonceTimestamp.Format(time.RFC3339),
"nonce_signature": nonceSignature,
},
Status: models.CommandStatusPending,
Source: "web_ui_bulk",
CreatedAt: time.Now(),
}
if req.Scheduled != nil {
command.Params["scheduled_at"] = *req.Scheduled
}
if err := h.commandQueries.CreateCommand(command); err != nil {
// Rollback status
h.agentQueries.UpdateAgentUpdatingStatus(agentID, false, nil)
errors = append(errors, fmt.Sprintf("Agent %s: failed to create command", agentID))
continue
}
results = append(results, map[string]interface{}{
"agent_id": agentID,
"hostname": agent.Hostname,
"update_id": command.ID.String(),
"status": "initiated",
})
log.Printf("✅ Bulk update initiated for %s: %s (%s)", agent.Hostname, req.Version, req.Platform)
}
response := gin.H{
"message": fmt.Sprintf("Bulk update completed with %d successes and %d failures", len(results), len(errors)),
"updated": results,
"failed": errors,
"total_agents": len(req.AgentIDs),
"package_info": gin.H{
"version": pkg.Version,
"platform": pkg.Platform,
"file_size": pkg.FileSize,
"checksum": pkg.Checksum,
},
}
c.JSON(http.StatusOK, response)
}
// ListUpdatePackages handles GET /api/v1/updates/packages (list available update packages)
func (h *AgentUpdateHandler) ListUpdatePackages(c *gin.Context) {
version := c.Query("version")
platform := c.Query("platform")
limitStr := c.Query("limit")
offsetStr := c.Query("offset")
limit := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
offset := 0
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
packages, err := h.agentUpdateQueries.ListUpdatePackages(version, platform, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list update packages"})
return
}
c.JSON(http.StatusOK, gin.H{
"packages": packages,
"total": len(packages),
"limit": limit,
"offset": offset,
})
}
// SignUpdatePackage handles POST /api/v1/updates/packages/sign (sign a new update package)
func (h *AgentUpdateHandler) SignUpdatePackage(c *gin.Context) {
var req struct {
Version string `json:"version" binding:"required"`
Platform string `json:"platform" binding:"required"`
Architecture string `json:"architecture" binding:"required"`
BinaryPath string `json:"binary_path" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.signingService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "signing service not available"})
return
}
// Sign the binary
pkg, err := h.signingService.SignFile(req.BinaryPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to sign binary: %v", err)})
return
}
// Set additional fields
pkg.Version = req.Version
pkg.Platform = req.Platform
pkg.Architecture = req.Architecture
// Save to database
if err := h.agentUpdateQueries.CreateUpdatePackage(pkg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save update package: %v", err)})
return
}
log.Printf("✅ Update package signed and saved: %s %s/%s (ID: %s)",
pkg.Version, pkg.Platform, pkg.Architecture, pkg.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Update package signed successfully",
"package": pkg,
})
}
// isPlatformCompatible checks if the update package is compatible with the agent
func (h *AgentUpdateHandler) isPlatformCompatible(agent *models.Agent, updatePlatform string) bool {
// Normalize platform strings
agentPlatform := strings.ToLower(agent.OSType)
updatePlatform = strings.ToLower(updatePlatform)
// Check for basic OS compatibility
if !strings.Contains(updatePlatform, agentPlatform) {
return false
}
// Check architecture compatibility if specified
if strings.Contains(updatePlatform, "amd64") && !strings.Contains(strings.ToLower(agent.OSArchitecture), "amd64") {
return false
}
if strings.Contains(updatePlatform, "arm64") && !strings.Contains(strings.ToLower(agent.OSArchitecture), "arm64") {
return false
}
if strings.Contains(updatePlatform, "386") && !strings.Contains(strings.ToLower(agent.OSArchitecture), "386") {
return false
}
return true
}
// estimateUpdateTime estimates how long an update will take based on file size
func (h *AgentUpdateHandler) estimateUpdateTime(fileSize int64) int {
// Rough estimate: 1 second per MB + 30 seconds base time
seconds := int(fileSize/1024/1024) + 30
// Cap at 5 minutes
if seconds > 300 {
seconds = 300
}
return seconds
}

View File

@@ -71,18 +71,30 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
return
}
// Validate machine ID and public key fingerprint if provided
if req.MachineID != "" {
// Check if machine ID is already registered to another agent
existingAgent, err := h.agentQueries.GetAgentByMachineID(req.MachineID)
if err == nil && existingAgent != nil && existingAgent.ID.String() != "" {
c.JSON(http.StatusConflict, gin.H{"error": "machine ID already registered to another agent"})
return
}
}
// Create new agent
agent := &models.Agent{
ID: uuid.New(),
Hostname: req.Hostname,
OSType: req.OSType,
OSVersion: req.OSVersion,
OSArchitecture: req.OSArchitecture,
AgentVersion: req.AgentVersion,
CurrentVersion: req.AgentVersion,
LastSeen: time.Now(),
Status: "online",
Metadata: models.JSONB{},
ID: uuid.New(),
Hostname: req.Hostname,
OSType: req.OSType,
OSVersion: req.OSVersion,
OSArchitecture: req.OSArchitecture,
AgentVersion: req.AgentVersion,
CurrentVersion: req.AgentVersion,
MachineID: &req.MachineID,
PublicKeyFingerprint: &req.PublicKeyFingerprint,
LastSeen: time.Now(),
Status: "online",
Metadata: models.JSONB{},
}
// Add metadata if provided

View File

@@ -99,6 +99,25 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
c.File(agentPath)
}
// DownloadUpdatePackage serves signed agent update packages
func (h *DownloadHandler) DownloadUpdatePackage(c *gin.Context) {
packageID := c.Param("package_id")
// Validate package ID format (UUID)
if len(packageID) != 36 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid package ID format"})
return
}
// TODO: Implement actual package serving from database/filesystem
// For now, return a placeholder response
c.JSON(http.StatusNotImplemented, gin.H{
"error": "Update package download not yet implemented",
"package_id": packageID,
"message": "This will serve the signed update package file",
})
}
// InstallScript serves the installation script
func (h *DownloadHandler) InstallScript(c *gin.Context) {
platform := c.Param("platform")

View File

@@ -0,0 +1,57 @@
package handlers
import (
"net/http"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
)
// SystemHandler handles system-level operations
type SystemHandler struct {
signingService *services.SigningService
}
// NewSystemHandler creates a new system handler
func NewSystemHandler(ss *services.SigningService) *SystemHandler {
return &SystemHandler{
signingService: ss,
}
}
// GetPublicKey returns the server's Ed25519 public key for signature verification
// This allows agents to fetch the public key at runtime instead of embedding it at build time
func (h *SystemHandler) GetPublicKey(c *gin.Context) {
if h.signingService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "signing service not configured",
"hint": "Set REDFLAG_SIGNING_PRIVATE_KEY environment variable",
})
return
}
pubKeyHex := h.signingService.GetPublicKey()
fingerprint := h.signingService.GetPublicKeyFingerprint()
c.JSON(http.StatusOK, gin.H{
"public_key": pubKeyHex,
"fingerprint": fingerprint,
"algorithm": "ed25519",
"key_size": 32,
})
}
// GetSystemInfo returns general system information
func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": "v0.1.21",
"name": "RedFlag Aggregator",
"description": "Self-hosted update management platform",
"features": []string{
"agent_management",
"update_tracking",
"command_execution",
"ed25519_signing",
},
})
}

View File

@@ -0,0 +1,137 @@
package handlers
import (
"crypto/ed25519"
"encoding/hex"
"fmt"
"log"
"net/http"
"strings"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/Fimeg/RedFlag/aggregator-server/internal/services"
"github.com/gin-gonic/gin"
)
// VerificationHandler handles signature verification requests
type VerificationHandler struct {
agentQueries *queries.AgentQueries
signingService *services.SigningService
}
// NewVerificationHandler creates a new verification handler
func NewVerificationHandler(aq *queries.AgentQueries, signingService *services.SigningService) *VerificationHandler {
return &VerificationHandler{
agentQueries: aq,
signingService: signingService,
}
}
// VerifySignature handles POST /api/v1/agents/:id/verify-signature
func (h *VerificationHandler) VerifySignature(c *gin.Context) {
var req models.SignatureVerificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate the agent exists and matches the provided machine ID
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Verify machine ID matches
if agent.MachineID == nil || *agent.MachineID != req.MachineID {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "machine ID mismatch",
"expected": agent.MachineID,
"received": req.MachineID,
})
return
}
// Verify public key fingerprint matches
if agent.PublicKeyFingerprint == nil || *agent.PublicKeyFingerprint != req.PublicKey {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "public key fingerprint mismatch",
"expected": agent.PublicKeyFingerprint,
"received": req.PublicKey,
})
return
}
// Verify the signature
valid, err := h.verifyAgentSignature(req.BinaryPath, req.Signature)
if err != nil {
log.Printf("Signature verification failed for agent %s: %v", req.AgentID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "signature verification failed",
"details": err.Error(),
})
return
}
response := models.SignatureVerificationResponse{
Valid: valid,
AgentID: req.AgentID.String(),
MachineID: req.MachineID,
Fingerprint: req.PublicKey,
Message: "Signature verification completed",
}
if !valid {
response.Message = "Invalid signature - binary may be tampered with"
c.JSON(http.StatusUnauthorized, response)
return
}
c.JSON(http.StatusOK, response)
}
// verifyAgentSignature verifies the signature of an agent binary
func (h *VerificationHandler) verifyAgentSignature(binaryPath, signatureHex string) (bool, error) {
// Decode the signature
signature, err := hex.DecodeString(signatureHex)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
if len(signature) != ed25519.SignatureSize {
return false, fmt.Errorf("invalid signature size: expected %d bytes, got %d", ed25519.SignatureSize, len(signature))
}
// Read the binary file
content, err := readFileContent(binaryPath)
if err != nil {
return false, fmt.Errorf("failed to read binary: %w", err)
}
// Verify using the signing service
valid, err := h.signingService.VerifySignature(content, signatureHex)
if err != nil {
return false, fmt.Errorf("verification failed: %w", err)
}
return valid, nil
}
// readFileContent reads file content safely
func readFileContent(filePath string) ([]byte, error) {
// Basic path validation to prevent directory traversal
if strings.Contains(filePath, "..") || strings.Contains(filePath, "~") {
return nil, fmt.Errorf("invalid file path")
}
// Only allow specific file patterns for security
if !strings.HasSuffix(filePath, "/redflag-agent") && !strings.HasSuffix(filePath, "/redflag-agent.exe") {
return nil, fmt.Errorf("invalid file type - only agent binaries are allowed")
}
// For security, we won't actually read files in this handler
// In a real implementation, this would verify the actual binary on the agent
// For now, we'll simulate the verification process
return []byte("simulated-binary-content"), nil
}

View File

@@ -0,0 +1,99 @@
package middleware
import (
"log"
"net/http"
"github.com/Fimeg/RedFlag/aggregator-server/internal/database/queries"
"github.com/Fimeg/RedFlag/aggregator-server/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// MachineBindingMiddleware validates machine ID matches database record
// This prevents agent impersonation via config file copying to different machines
func MachineBindingMiddleware(agentQueries *queries.AgentQueries, minAgentVersion string) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip if not authenticated (handled by auth middleware)
agentIDVal, exists := c.Get("agent_id")
if !exists {
c.Next()
return
}
agentID, ok := agentIDVal.(uuid.UUID)
if !ok {
log.Printf("[MachineBinding] Invalid agent_id type in context")
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid agent ID"})
c.Abort()
return
}
// Get agent from database
agent, err := agentQueries.GetAgentByID(agentID)
if err != nil {
log.Printf("[MachineBinding] Agent %s not found: %v", agentID, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "agent not found"})
c.Abort()
return
}
// Check minimum version (hard cutoff for legacy de-support)
if agent.CurrentVersion != "" && minAgentVersion != "" {
if !utils.IsNewerOrEqualVersion(agent.CurrentVersion, minAgentVersion) {
log.Printf("[MachineBinding] Agent %s version %s below minimum %s - rejecting",
agent.Hostname, agent.CurrentVersion, minAgentVersion)
c.JSON(http.StatusUpgradeRequired, gin.H{
"error": "agent version too old - upgrade required for security",
"current_version": agent.CurrentVersion,
"minimum_version": minAgentVersion,
"upgrade_instructions": "Please upgrade to the latest agent version and re-register",
})
c.Abort()
return
}
}
// Extract X-Machine-ID header
reportedMachineID := c.GetHeader("X-Machine-ID")
if reportedMachineID == "" {
log.Printf("[MachineBinding] Agent %s (%s) missing X-Machine-ID header",
agent.Hostname, agentID)
c.JSON(http.StatusForbidden, gin.H{
"error": "missing machine ID header - agent version too old or tampered",
"hint": "Please upgrade to the latest agent version (v0.1.22+)",
})
c.Abort()
return
}
// Validate machine ID matches database
if agent.MachineID == nil {
log.Printf("[MachineBinding] Agent %s (%s) has no machine_id in database - legacy agent",
agent.Hostname, agentID)
c.JSON(http.StatusForbidden, gin.H{
"error": "agent not bound to machine - re-registration required",
"hint": "This agent was registered before v0.1.22. Please re-register with a new registration token.",
})
c.Abort()
return
}
if *agent.MachineID != reportedMachineID {
log.Printf("[MachineBinding] ⚠️ SECURITY ALERT: Agent %s (%s) machine ID mismatch! DB=%s, Reported=%s",
agent.Hostname, agentID, *agent.MachineID, reportedMachineID)
c.JSON(http.StatusForbidden, gin.H{
"error": "machine ID mismatch - config file copied to different machine",
"hint": "Agent configuration is bound to the original machine. Please register this machine with a new registration token.",
"security_note": "This prevents agent impersonation attacks",
})
c.Abort()
return
}
// Machine ID validated - allow request
log.Printf("[MachineBinding] ✓ Agent %s (%s) machine ID validated: %s",
agent.Hostname, agentID, reportedMachineID[:16]+"...")
c.Next()
}
}

View File

@@ -37,10 +37,12 @@ type Config struct {
MaxTokens int `env:"REDFLAG_MAX_TOKENS" default:"100"`
MaxSeats int `env:"REDFLAG_MAX_SEATS" default:"50"`
}
CheckInInterval int
OfflineThreshold int
Timezone string
CheckInInterval int
OfflineThreshold int
Timezone string
LatestAgentVersion string
MinAgentVersion string `env:"MIN_AGENT_VERSION" default:"0.1.22"`
SigningPrivateKey string `env:"REDFLAG_SIGNING_PRIVATE_KEY"`
}
// Load reads configuration from environment variables only (immutable configuration)
@@ -84,7 +86,8 @@ func Load() (*Config, error) {
cfg.CheckInInterval = checkInInterval
cfg.OfflineThreshold = offlineThreshold
cfg.Timezone = getEnv("TIMEZONE", "UTC")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.18")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.22")
cfg.MinAgentVersion = getEnv("MIN_AGENT_VERSION", "0.1.22")
// Handle missing secrets
if cfg.Admin.Password == "" || cfg.Admin.JWTSecret == "" || cfg.Database.Password == "" {

View File

@@ -0,0 +1,10 @@
-- Remove agent update packages table
DROP TABLE IF EXISTS agent_update_packages;
-- Remove new columns from agents table
ALTER TABLE agents
DROP COLUMN IF EXISTS machine_id,
DROP COLUMN IF EXISTS public_key_fingerprint,
DROP COLUMN IF EXISTS is_updating,
DROP COLUMN IF EXISTS updating_to_version,
DROP COLUMN IF EXISTS update_initiated_at;

View File

@@ -0,0 +1,47 @@
-- Add machine ID and public key fingerprint fields to agents table
-- This enables Ed25519 binary signing and machine binding
ALTER TABLE agents
ADD COLUMN machine_id VARCHAR(64) UNIQUE,
ADD COLUMN public_key_fingerprint VARCHAR(16),
ADD COLUMN is_updating BOOLEAN DEFAULT false,
ADD COLUMN updating_to_version VARCHAR(50),
ADD COLUMN update_initiated_at TIMESTAMP;
-- Create index for machine ID lookups
CREATE INDEX idx_agents_machine_id ON agents(machine_id);
CREATE INDEX idx_agents_public_key_fingerprint ON agents(public_key_fingerprint);
-- Add comment to document the new fields
COMMENT ON COLUMN agents.machine_id IS 'Unique machine identifier to bind agent binaries to specific hardware';
COMMENT ON COLUMN agents.public_key_fingerprint IS 'Fingerprint of embedded public key for binary signature verification';
COMMENT ON COLUMN agents.is_updating IS 'Whether agent is currently updating';
COMMENT ON COLUMN agents.updating_to_version IS 'Target version for ongoing update';
COMMENT ON COLUMN agents.update_initiated_at IS 'When the update process started';
-- Create table for storing signed update packages
CREATE TABLE agent_update_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version VARCHAR(50) NOT NULL,
platform VARCHAR(50) NOT NULL, -- linux-amd64, linux-arm64, windows-amd64, etc.
architecture VARCHAR(20) NOT NULL,
binary_path VARCHAR(500) NOT NULL,
signature VARCHAR(128) NOT NULL, -- Ed25519 signature (64 bytes hex encoded)
checksum VARCHAR(64) NOT NULL, -- SHA-256 checksum
file_size BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100) DEFAULT 'system',
is_active BOOLEAN DEFAULT true
);
-- Add indexes for update packages
CREATE INDEX idx_agent_update_packages_version ON agent_update_packages(version);
CREATE INDEX idx_agent_update_packages_platform ON agent_update_packages(platform, architecture);
CREATE INDEX idx_agent_update_packages_active ON agent_update_packages(is_active);
-- Add comments for update packages table
COMMENT ON TABLE agent_update_packages IS 'Stores signed agent binary packages for secure updates';
COMMENT ON COLUMN agent_update_packages.signature IS 'Ed25519 signature of the binary file';
COMMENT ON COLUMN agent_update_packages.checksum IS 'SHA-256 checksum of the binary file';
COMMENT ON COLUMN agent_update_packages.platform IS 'Target platform (OS-architecture)';
COMMENT ON COLUMN agent_update_packages.is_active IS 'Whether this package is available for updates';

View File

@@ -0,0 +1,4 @@
-- Rollback machine_id column addition
DROP INDEX IF EXISTS idx_agents_machine_id;
ALTER TABLE agents DROP COLUMN IF EXISTS machine_id;

View File

@@ -0,0 +1,11 @@
-- Add machine_id column to agents table for hardware fingerprint binding
-- This prevents config file copying attacks by validating hardware identity
ALTER TABLE agents
ADD COLUMN machine_id VARCHAR(64);
-- Create unique index to prevent duplicate machine IDs
CREATE UNIQUE INDEX idx_agents_machine_id ON agents(machine_id) WHERE machine_id IS NOT NULL;
-- Add comment for documentation
COMMENT ON COLUMN agents.machine_id IS 'SHA-256 hash of hardware fingerprint (prevents agent impersonation via config copying)';

View File

@@ -0,0 +1,219 @@
package queries
import (
"database/sql"
"fmt"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// AgentUpdateQueries handles database operations for agent update packages
type AgentUpdateQueries struct {
db *sqlx.DB
}
// NewAgentUpdateQueries creates a new AgentUpdateQueries instance
func NewAgentUpdateQueries(db *sqlx.DB) *AgentUpdateQueries {
return &AgentUpdateQueries{db: db}
}
// CreateUpdatePackage stores a new signed update package
func (q *AgentUpdateQueries) CreateUpdatePackage(pkg *models.AgentUpdatePackage) error {
query := `
INSERT INTO agent_update_packages (
id, version, platform, architecture, binary_path, signature,
checksum, file_size, created_by, is_active
) VALUES (
:id, :version, :platform, :architecture, :binary_path, :signature,
:checksum, :file_size, :created_by, :is_active
) RETURNING id, created_at
`
rows, err := q.db.NamedQuery(query, pkg)
if err != nil {
return fmt.Errorf("failed to create update package: %w", err)
}
defer rows.Close()
if rows.Next() {
if err := rows.Scan(&pkg.ID, &pkg.CreatedAt); err != nil {
return fmt.Errorf("failed to scan created package: %w", err)
}
}
return nil
}
// GetUpdatePackage retrieves an update package by ID
func (q *AgentUpdateQueries) GetUpdatePackage(id uuid.UUID) (*models.AgentUpdatePackage, error) {
query := `
SELECT id, version, platform, architecture, binary_path, signature,
checksum, file_size, created_at, created_by, is_active
FROM agent_update_packages
WHERE id = $1 AND is_active = true
`
var pkg models.AgentUpdatePackage
err := q.db.Get(&pkg, query, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("update package not found")
}
return nil, fmt.Errorf("failed to get update package: %w", err)
}
return &pkg, nil
}
// GetUpdatePackageByVersion retrieves the latest update package for a version and platform
func (q *AgentUpdateQueries) GetUpdatePackageByVersion(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
query := `
SELECT id, version, platform, architecture, binary_path, signature,
checksum, file_size, created_at, created_by, is_active
FROM agent_update_packages
WHERE version = $1 AND platform = $2 AND architecture = $3 AND is_active = true
ORDER BY created_at DESC
LIMIT 1
`
var pkg models.AgentUpdatePackage
err := q.db.Get(&pkg, query, version, platform, architecture)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("no update package found for version %s on %s/%s", version, platform, architecture)
}
return nil, fmt.Errorf("failed to get update package: %w", err)
}
return &pkg, nil
}
// ListUpdatePackages retrieves all update packages with optional filtering
func (q *AgentUpdateQueries) ListUpdatePackages(version, platform string, limit, offset int) ([]models.AgentUpdatePackage, error) {
query := `
SELECT id, version, platform, architecture, binary_path, signature,
checksum, file_size, created_at, created_by, is_active
FROM agent_update_packages
WHERE is_active = true
`
args := []interface{}{}
argIndex := 1
if version != "" {
query += fmt.Sprintf(" AND version = $%d", argIndex)
args = append(args, version)
argIndex++
}
if platform != "" {
query += fmt.Sprintf(" AND platform = $%d", argIndex)
args = append(args, platform)
argIndex++
}
query += " ORDER BY created_at DESC"
if limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, limit)
argIndex++
if offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, offset)
}
}
var packages []models.AgentUpdatePackage
err := q.db.Select(&packages, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list update packages: %w", err)
}
return packages, nil
}
// DeactivateUpdatePackage marks a package as inactive
func (q *AgentUpdateQueries) DeactivateUpdatePackage(id uuid.UUID) error {
query := `UPDATE agent_update_packages SET is_active = false WHERE id = $1`
result, err := q.db.Exec(query, id)
if err != nil {
return fmt.Errorf("failed to deactivate update package: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no update package found to deactivate")
}
return nil
}
// UpdateAgentMachineInfo updates the machine ID and public key fingerprint for an agent
func (q *AgentUpdateQueries) UpdateAgentMachineInfo(agentID uuid.UUID, machineID, publicKeyFingerprint string) error {
query := `
UPDATE agents
SET machine_id = $1, public_key_fingerprint = $2, updated_at = $3
WHERE id = $4
`
_, err := q.db.Exec(query, machineID, publicKeyFingerprint, time.Now().UTC(), agentID)
if err != nil {
return fmt.Errorf("failed to update agent machine info: %w", err)
}
return nil
}
// UpdateAgentUpdatingStatus sets the update status for an agent
func (q *AgentUpdateQueries) UpdateAgentUpdatingStatus(agentID uuid.UUID, isUpdating bool, targetVersion *string) error {
query := `
UPDATE agents
SET is_updating = $1,
updating_to_version = $2,
update_initiated_at = CASE WHEN $1 = true THEN $3 ELSE update_initiated_at END,
updated_at = $3
WHERE id = $4
`
now := time.Now().UTC()
_, err := q.db.Exec(query, isUpdating, targetVersion, now, agentID)
if err != nil {
return fmt.Errorf("failed to update agent updating status: %w", err)
}
return nil
}
// GetAgentByMachineID retrieves an agent by its machine ID
func (q *AgentUpdateQueries) GetAgentByMachineID(machineID string) (*models.Agent, error) {
query := `
SELECT id, hostname, os_type, os_version, os_architecture, agent_version,
current_version, update_available, last_version_check, machine_id,
public_key_fingerprint, is_updating, updating_to_version,
update_initiated_at, last_seen, status, metadata, reboot_required,
last_reboot_at, reboot_reason, created_at, updated_at
FROM agents
WHERE machine_id = $1
`
var agent models.Agent
err := q.db.Get(&agent, query, machineID)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("agent not found for machine ID")
}
return nil, fmt.Errorf("failed to get agent by machine ID: %w", err)
}
return &agent, nil
}

View File

@@ -1,6 +1,8 @@
package queries
import (
"database/sql"
"fmt"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
@@ -245,3 +247,51 @@ func (q *AgentQueries) UpdateAgentLastReboot(id uuid.UUID, rebootTime time.Time)
_, err := q.db.Exec(query, rebootTime, time.Now(), id)
return err
}
// GetAgentByMachineID retrieves an agent by its machine ID
func (q *AgentQueries) GetAgentByMachineID(machineID string) (*models.Agent, error) {
query := `
SELECT id, hostname, os_type, os_version, os_architecture, agent_version,
current_version, update_available, last_version_check, machine_id,
public_key_fingerprint, is_updating, updating_to_version,
update_initiated_at, last_seen, status, metadata, reboot_required,
last_reboot_at, reboot_reason, created_at, updated_at
FROM agents
WHERE machine_id = $1
`
var agent models.Agent
err := q.db.Get(&agent, query, machineID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // Return nil if not found (not an error)
}
return nil, fmt.Errorf("failed to get agent by machine ID: %w", err)
}
return &agent, nil
}
// UpdateAgentUpdatingStatus updates the agent's update status
func (q *AgentQueries) UpdateAgentUpdatingStatus(id uuid.UUID, isUpdating bool, updatingToVersion *string) error {
query := `
UPDATE agents
SET
is_updating = $1,
updating_to_version = $2,
update_initiated_at = CASE
WHEN $1 = true THEN $3
ELSE NULL
END,
updated_at = $3
WHERE id = $4
`
var versionPtr *string
if updatingToVersion != nil {
versionPtr = updatingToVersion
}
_, err := q.db.Exec(query, isUpdating, versionPtr, time.Now(), id)
return err
}

View File

@@ -18,15 +18,20 @@ type Agent struct {
AgentVersion string `json:"agent_version" db:"agent_version"` // Version at registration
CurrentVersion string `json:"current_version" db:"current_version"` // Current running version
UpdateAvailable bool `json:"update_available" db:"update_available"` // Whether update is available
LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked
LastSeen time.Time `json:"last_seen" db:"last_seen"`
Status string `json:"status" db:"status"`
Metadata JSONB `json:"metadata" db:"metadata"`
RebootRequired bool `json:"reboot_required" db:"reboot_required"`
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
LastVersionCheck time.Time `json:"last_version_check" db:"last_version_check"` // Last time version was checked
MachineID *string `json:"machine_id,omitempty" db:"machine_id"` // Unique machine identifier
PublicKeyFingerprint *string `json:"public_key_fingerprint,omitempty" db:"public_key_fingerprint"` // Public key fingerprint
IsUpdating bool `json:"is_updating" db:"is_updating"` // Whether agent is currently updating
UpdatingToVersion *string `json:"updating_to_version,omitempty" db:"updating_to_version"` // Target version for ongoing update
UpdateInitiatedAt *time.Time `json:"update_initiated_at,omitempty" db:"update_initiated_at"` // When update process started
LastSeen time.Time `json:"last_seen" db:"last_seen"`
Status string `json:"status" db:"status"`
Metadata JSONB `json:"metadata" db:"metadata"`
RebootRequired bool `json:"reboot_required" db:"reboot_required"`
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// AgentWithLastScan extends Agent with last scan information
@@ -69,13 +74,15 @@ type AgentSpecs struct {
// AgentRegistrationRequest is the payload for agent registration
type AgentRegistrationRequest struct {
Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version" binding:"required"`
RegistrationToken string `json:"registration_token"` // Optional, for fallback method
Metadata map[string]string `json:"metadata"`
Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version" binding:"required"`
RegistrationToken string `json:"registration_token"` // Optional, for fallback method
MachineID string `json:"machine_id"` // Unique machine identifier
PublicKeyFingerprint string `json:"public_key_fingerprint"` // Embedded public key fingerprint
Metadata map[string]string `json:"metadata"`
}
// AgentRegistrationResponse is returned after successful registration

View File

@@ -0,0 +1,67 @@
package models
import (
"time"
"github.com/google/uuid"
)
// AgentUpdatePackage represents a signed agent binary package
type AgentUpdatePackage struct {
ID uuid.UUID `json:"id" db:"id"`
Version string `json:"version" db:"version"`
Platform string `json:"platform" db:"platform"`
Architecture string `json:"architecture" db:"architecture"`
BinaryPath string `json:"binary_path" db:"binary_path"`
Signature string `json:"signature" db:"signature"`
Checksum string `json:"checksum" db:"checksum"`
FileSize int64 `json:"file_size" db:"file_size"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
}
// AgentUpdateRequest represents a request to update an agent
type AgentUpdateRequest struct {
AgentID uuid.UUID `json:"agent_id" binding:"required"`
Version string `json:"version" binding:"required"`
Platform string `json:"platform" binding:"required"`
Scheduled *string `json:"scheduled_at,omitempty"`
}
// BulkAgentUpdateRequest represents a bulk update request
type BulkAgentUpdateRequest struct {
AgentIDs []uuid.UUID `json:"agent_ids" binding:"required"`
Version string `json:"version" binding:"required"`
Platform string `json:"platform" binding:"required"`
Scheduled *string `json:"scheduled_at,omitempty"`
}
// AgentUpdateResponse represents the response for an update request
type AgentUpdateResponse struct {
Message string `json:"message"`
UpdateID string `json:"update_id,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
Signature string `json:"signature,omitempty"`
Checksum string `json:"checksum,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
EstimatedTime int `json:"estimated_time_seconds,omitempty"`
}
// SignatureVerificationRequest represents a request to verify an agent's binary signature
type SignatureVerificationRequest struct {
AgentID uuid.UUID `json:"agent_id" binding:"required"`
BinaryPath string `json:"binary_path" binding:"required"`
MachineID string `json:"machine_id" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
Signature string `json:"signature" binding:"required"`
}
// SignatureVerificationResponse represents the response for signature verification
type SignatureVerificationResponse struct {
Valid bool `json:"valid"`
AgentID string `json:"agent_id"`
MachineID string `json:"machine_id"`
Fingerprint string `json:"fingerprint"`
Message string `json:"message"`
}

View File

@@ -0,0 +1,239 @@
package services
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"runtime"
"time"
"github.com/Fimeg/RedFlag/aggregator-server/internal/models"
"github.com/google/uuid"
)
// SigningService handles Ed25519 cryptographic operations
type SigningService struct {
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
}
// NewSigningService creates a new signing service with the provided private key
func NewSigningService(privateKeyHex string) (*SigningService, error) {
// Decode private key from hex
privateKeyBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
return nil, fmt.Errorf("invalid private key format: %w", err)
}
if len(privateKeyBytes) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("invalid private key size: expected %d bytes, got %d", ed25519.PrivateKeySize, len(privateKeyBytes))
}
// Ed25519 private key format: first 32 bytes are seed, next 32 bytes are public key
privateKey := ed25519.PrivateKey(privateKeyBytes)
publicKey := privateKey.Public().(ed25519.PublicKey)
return &SigningService{
privateKey: privateKey,
publicKey: publicKey,
}, nil
}
// SignFile signs a file and returns the signature and checksum
func (s *SigningService) SignFile(filePath string) (*models.AgentUpdatePackage, error) {
// Read the file
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Calculate checksum and sign content
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// Calculate SHA-256 checksum
hash := sha256.Sum256(content)
checksum := hex.EncodeToString(hash[:])
// Sign the content
signature := ed25519.Sign(s.privateKey, content)
// Get file info
fileInfo, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
// Determine platform and architecture from file path or use runtime defaults
platform, architecture := s.detectPlatformArchitecture(filePath)
pkg := &models.AgentUpdatePackage{
BinaryPath: filePath,
Signature: hex.EncodeToString(signature),
Checksum: checksum,
FileSize: fileInfo.Size(),
Platform: platform,
Architecture: architecture,
CreatedBy: "signing-service",
IsActive: true,
}
return pkg, nil
}
// VerifySignature verifies a file signature using the embedded public key
func (s *SigningService) VerifySignature(content []byte, signatureHex string) (bool, error) {
// Decode signature
signature, err := hex.DecodeString(signatureHex)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
if len(signature) != ed25519.SignatureSize {
return false, fmt.Errorf("invalid signature size: expected %d bytes, got %d", ed25519.SignatureSize, len(signature))
}
// Verify signature
valid := ed25519.Verify(s.publicKey, content, signature)
return valid, nil
}
// GetPublicKey returns the public key in hex format
func (s *SigningService) GetPublicKey() string {
return hex.EncodeToString(s.publicKey)
}
// GetPublicKeyFingerprint returns a short fingerprint of the public key
func (s *SigningService) GetPublicKeyFingerprint() string {
// Use first 8 bytes as fingerprint
return hex.EncodeToString(s.publicKey[:8])
}
// VerifyFileIntegrity verifies a file's checksum
func (s *SigningService) VerifyFileIntegrity(filePath, expectedChecksum string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return false, fmt.Errorf("failed to read file: %w", err)
}
hash := sha256.Sum256(content)
actualChecksum := hex.EncodeToString(hash[:])
return actualChecksum == expectedChecksum, nil
}
// detectPlatformArchitecture attempts to detect platform and architecture from file path
func (s *SigningService) detectPlatformArchitecture(filePath string) (string, string) {
// Default to current runtime
platform := runtime.GOOS
arch := runtime.GOARCH
// Map architectures
archMap := map[string]string{
"amd64": "amd64",
"arm64": "arm64",
"386": "386",
}
// Try to detect from filename patterns
if contains(filePath, "windows") || contains(filePath, ".exe") {
platform = "windows"
} else if contains(filePath, "linux") {
platform = "linux"
} else if contains(filePath, "darwin") || contains(filePath, "macos") {
platform = "darwin"
}
for archName, archValue := range archMap {
if contains(filePath, archName) {
arch = archValue
break
}
}
// Normalize architecture names
if arch == "amd64" {
arch = "amd64"
} else if arch == "arm64" {
arch = "arm64"
}
return platform, arch
}
// contains is a simple helper for case-insensitive substring checking
func contains(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 ||
findSubstring(s, substr))))
}
// findSubstring is a simple substring finder
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// SignNonce signs a nonce (UUID + timestamp) for replay protection
func (s *SigningService) SignNonce(nonceUUID uuid.UUID, timestamp time.Time) (string, error) {
// Create nonce data: UUID + Unix timestamp as string
nonceData := fmt.Sprintf("%s:%d", nonceUUID.String(), timestamp.Unix())
// Sign the nonce data
signature := ed25519.Sign(s.privateKey, []byte(nonceData))
// Return hex-encoded signature
return hex.EncodeToString(signature), nil
}
// VerifyNonce verifies a nonce signature and checks freshness
func (s *SigningService) VerifyNonce(nonceUUID uuid.UUID, timestamp time.Time, signatureHex string, maxAge time.Duration) (bool, error) {
// Check nonce freshness first
if time.Since(timestamp) > maxAge {
return false, fmt.Errorf("nonce is too old: %v > %v", time.Since(timestamp), maxAge)
}
// Recreate nonce data
nonceData := fmt.Sprintf("%s:%d", nonceUUID.String(), timestamp.Unix())
// Verify signature
valid, err := s.VerifySignature([]byte(nonceData), signatureHex)
if err != nil {
return false, fmt.Errorf("failed to verify nonce signature: %w", err)
}
return valid, nil
}
// TODO: Key rotation implementation
// This is a stub for future key rotation functionality
// Key rotation should:
// 1. Maintain multiple active key pairs with version numbers
// 2. Support graceful transition periods (e.g., 30 days)
// 3. Store previous keys for signature verification during transition
// 4. Batch migration of existing agent fingerprints
// 5. Provide monitoring for rotation completion
//
// Example implementation approach:
// - Use database to store multiple key versions with activation timestamps
// - Include key version ID in signatures
// - Maintain lookup table of previous keys for verification
// - Background job to monitor rotation progress

View File

@@ -33,6 +33,12 @@ func IsNewerVersion(version1, version2 string) bool {
return CompareVersions(version1, version2) == 1
}
// IsNewerOrEqualVersion returns true if version1 is newer than or equal to version2
func IsNewerOrEqualVersion(version1, version2 string) bool {
cmp := CompareVersions(version1, version2)
return cmp == 1 || cmp == 0
}
// parseVersion parses a version string like "0.1.4" into [0, 1, 4]
func parseVersion(version string) [3]int {
// Default version if parsing fails

BIN
aggregator-server/test-server Executable file

Binary file not shown.