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:
401
aggregator-server/internal/api/handlers/agent_updates.go
Normal file
401
aggregator-server/internal/api/handlers/agent_updates.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
57
aggregator-server/internal/api/handlers/system.go
Normal file
57
aggregator-server/internal/api/handlers/system.go
Normal 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",
|
||||
},
|
||||
})
|
||||
}
|
||||
137
aggregator-server/internal/api/handlers/verification.go
Normal file
137
aggregator-server/internal/api/handlers/verification.go
Normal 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
|
||||
}
|
||||
99
aggregator-server/internal/api/middleware/machine_binding.go
Normal file
99
aggregator-server/internal/api/middleware/machine_binding.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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)';
|
||||
219
aggregator-server/internal/database/queries/agent_updates.go
Normal file
219
aggregator-server/internal/database/queries/agent_updates.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
67
aggregator-server/internal/models/agent_update.go
Normal file
67
aggregator-server/internal/models/agent_update.go
Normal 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"`
|
||||
}
|
||||
239
aggregator-server/internal/services/signing.go
Normal file
239
aggregator-server/internal/services/signing.go
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user