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

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/system"
"github.com/google/uuid"
)
@@ -21,19 +22,36 @@ type Client struct {
http *http.Client
RapidPollingEnabled bool
RapidPollingUntil time.Time
machineID string // Cached machine ID for security binding
}
// NewClient creates a new API client
func NewClient(baseURL, token string) *Client {
// Get machine ID for security binding (v0.1.22+)
machineID, err := system.GetMachineID()
if err != nil {
// Log warning but don't fail - older servers may not require it
fmt.Printf("Warning: Failed to get machine ID: %v\n", err)
machineID = "" // Will be handled by server validation
}
return &Client{
baseURL: baseURL,
token: token,
baseURL: baseURL,
token: token,
machineID: machineID,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// addMachineIDHeader adds X-Machine-ID header to authenticated requests (v0.1.22+)
func (c *Client) addMachineIDHeader(req *http.Request) {
if c.machineID != "" {
req.Header.Set("X-Machine-ID", c.machineID)
}
}
// GetToken returns the current JWT token
func (c *Client) GetToken() string {
return c.token
@@ -46,13 +64,15 @@ func (c *Client) SetToken(token string) {
// RegisterRequest is the payload for agent registration
type RegisterRequest struct {
Hostname string `json:"hostname"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
RegistrationToken string `json:"registration_token,omitempty"` // Fallback method
Metadata map[string]string `json:"metadata"`
Hostname string `json:"hostname"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
RegistrationToken string `json:"registration_token,omitempty"` // Fallback method
MachineID string `json:"machine_id"`
PublicKeyFingerprint string `json:"public_key_fingerprint"`
Metadata map[string]string `json:"metadata"`
}
// RegisterResponse is returned after successful registration
@@ -230,6 +250,7 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) (*Comman
}
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
@@ -297,6 +318,7 @@ func (c *Client) ReportUpdates(agentID uuid.UUID, report UpdateReport) error {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
@@ -314,13 +336,14 @@ func (c *Client) ReportUpdates(agentID uuid.UUID, report UpdateReport) error {
// LogReport represents an execution log
type LogReport struct {
CommandID string `json:"command_id"`
Action string `json:"action"`
Result string `json:"result"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
CommandID string `json:"command_id"`
Action string `json:"action"`
Result string `json:"result"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ReportLog sends an execution log to the server
@@ -338,6 +361,7 @@ func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
@@ -392,6 +416,7 @@ func (c *Client) ReportDependencies(agentID uuid.UUID, report DependencyReport)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
@@ -437,6 +462,7 @@ func (c *Client) ReportSystemInfo(agentID uuid.UUID, report SystemInfoReport) er
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
@@ -474,6 +500,49 @@ func DetectSystem() (osType, osVersion, osArch string) {
return
}
// AgentInfo represents agent information from the server
type AgentInfo struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
CurrentVersion string `json:"current_version"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
LastCheckIn string `json:"last_check_in"`
}
// GetAgent retrieves agent information from the server
func (c *Client) GetAgent(agentID string) (*AgentInfo, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s", c.baseURL, agentID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
c.addMachineIDHeader(req) // Security: Validate machine binding (v0.1.22+)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
var agent AgentInfo
if err := json.NewDecoder(resp.Body).Decode(&agent); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &agent, nil
}
// parseOSRelease parses /etc/os-release to get proper distro name
func parseOSRelease(data []byte) string {
lines := strings.Split(string(data), "\n")

View File

@@ -0,0 +1,130 @@
package crypto
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
)
// getPublicKeyPath returns the platform-specific path for storing the server's public key
func getPublicKeyPath() string {
if runtime.GOOS == "windows" {
return "C:\\ProgramData\\RedFlag\\server_public_key"
}
return "/etc/aggregator/server_public_key"
}
// PublicKeyResponse represents the server's public key response
type PublicKeyResponse struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
Algorithm string `json:"algorithm"`
KeySize int `json:"key_size"`
}
// FetchAndCacheServerPublicKey fetches the server's Ed25519 public key and caches it locally
// This implements Trust-On-First-Use (TOFU) security model
func FetchAndCacheServerPublicKey(serverURL string) (ed25519.PublicKey, error) {
// Check if we already have a cached key
if cachedKey, err := LoadCachedPublicKey(); err == nil && cachedKey != nil {
return cachedKey, nil
}
// Fetch from server
resp, err := http.Get(serverURL + "/api/v1/public-key")
if err != nil {
return nil, fmt.Errorf("failed to fetch public key from server: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var keyResp PublicKeyResponse
if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil {
return nil, fmt.Errorf("failed to parse public key response: %w", err)
}
// Validate algorithm
if keyResp.Algorithm != "ed25519" {
return nil, fmt.Errorf("unsupported signature algorithm: %s (expected ed25519)", keyResp.Algorithm)
}
// Decode hex public key
pubKeyBytes, err := hex.DecodeString(keyResp.PublicKey)
if err != nil {
return nil, fmt.Errorf("invalid public key format: %w", err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return nil, fmt.Errorf("invalid public key size: expected %d bytes, got %d",
ed25519.PublicKeySize, len(pubKeyBytes))
}
publicKey := ed25519.PublicKey(pubKeyBytes)
// Cache it for future use
if err := cachePublicKey(publicKey); err != nil {
// Log warning but don't fail - we have the key in memory
fmt.Printf("Warning: Failed to cache public key: %v\n", err)
}
fmt.Printf("✓ Server public key fetched and cached (fingerprint: %s)\n", keyResp.Fingerprint)
return publicKey, nil
}
// LoadCachedPublicKey loads the cached public key from disk
func LoadCachedPublicKey() (ed25519.PublicKey, error) {
keyPath := getPublicKeyPath()
data, err := os.ReadFile(keyPath)
if err != nil {
return nil, err // File doesn't exist or can't be read
}
if len(data) != ed25519.PublicKeySize {
return nil, fmt.Errorf("cached public key has invalid size: %d bytes", len(data))
}
return ed25519.PublicKey(data), nil
}
// cachePublicKey saves the public key to disk
func cachePublicKey(publicKey ed25519.PublicKey) error {
keyPath := getPublicKeyPath()
// Ensure directory exists
dir := filepath.Dir(keyPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Write public key (read-only for non-root users)
if err := os.WriteFile(keyPath, publicKey, 0644); err != nil {
return fmt.Errorf("failed to write public key: %w", err)
}
return nil
}
// GetPublicKey returns the cached public key or fetches it from the server
// This is the main entry point for getting the verification key
func GetPublicKey(serverURL string) (ed25519.PublicKey, error) {
// Try cached key first
if cachedKey, err := LoadCachedPublicKey(); err == nil {
return cachedKey, nil
}
// Fetch from server if not cached
return FetchAndCacheServerPublicKey(serverURL)
}

View File

@@ -209,6 +209,13 @@ func (s *redflagService) runAgent() {
}
}
// Check if commands response is valid
if commands == nil {
log.Printf("Check-in successful - no commands received (nil response)")
elog.Info(1, "Check-in successful - no commands received (nil response)")
continue
}
if len(commands.Commands) == 0 {
log.Printf("Check-in successful - no new commands")
elog.Info(1, "Check-in successful - no new commands")

View File

@@ -0,0 +1,129 @@
package system
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"runtime"
"strings"
"github.com/denisbrodbeck/machineid"
)
// GetMachineID generates a unique machine identifier that persists across reboots
func GetMachineID() (string, error) {
// Try machineid library first (cross-platform)
id, err := machineid.ID()
if err == nil && id != "" {
// Hash the machine ID for consistency and privacy
return hashMachineID(id), nil
}
// Fallback to OS-specific methods
switch runtime.GOOS {
case "linux":
return getLinuxMachineID()
case "windows":
return getWindowsMachineID()
case "darwin":
return getDarwinMachineID()
default:
return generateGenericMachineID()
}
}
// hashMachineID creates a consistent hash from machine ID
func hashMachineID(id string) string {
hash := sha256.Sum256([]byte(id))
return hex.EncodeToString(hash[:]) // Return full hash for uniqueness
}
// getLinuxMachineID tries multiple sources for Linux machine ID
func getLinuxMachineID() (string, error) {
// Try /etc/machine-id first (systemd)
if id, err := os.ReadFile("/etc/machine-id"); err == nil {
idStr := strings.TrimSpace(string(id))
if idStr != "" {
return hashMachineID(idStr), nil
}
}
// Try /var/lib/dbus/machine-id
if id, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil {
idStr := strings.TrimSpace(string(id))
if idStr != "" {
return hashMachineID(idStr), nil
}
}
// Try DMI product UUID
if id, err := os.ReadFile("/sys/class/dmi/id/product_uuid"); err == nil {
idStr := strings.TrimSpace(string(id))
if idStr != "" {
return hashMachineID(idStr), nil
}
}
// Try /etc/hostname as last resort
if hostname, err := os.ReadFile("/etc/hostname"); err == nil {
hostnameStr := strings.TrimSpace(string(hostname))
if hostnameStr != "" {
return hashMachineID(hostnameStr + "-linux-fallback"), nil
}
}
return generateGenericMachineID()
}
// getWindowsMachineID gets Windows machine ID
func getWindowsMachineID() (string, error) {
// Try machineid library Windows registry keys first
if id, err := machineid.ID(); err == nil && id != "" {
return hashMachineID(id), nil
}
// Fallback to generating generic ID
return generateGenericMachineID()
}
// getDarwinMachineID gets macOS machine ID
func getDarwinMachineID() (string, error) {
// Try machineid library platform-specific keys first
if id, err := machineid.ID(); err == nil && id != "" {
return hashMachineID(id), nil
}
// Fallback to generating generic ID
return generateGenericMachineID()
}
// generateGenericMachineID creates a fallback machine ID from available system info
func generateGenericMachineID() (string, error) {
// Combine hostname with other available info
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
// Create a reasonably unique ID from available system info
idSource := fmt.Sprintf("%s-%s-%s", hostname, runtime.GOOS, runtime.GOARCH)
return hashMachineID(idSource), nil
}
// GetEmbeddedPublicKey returns the embedded public key fingerprint
// This should be set at build time using ldflags
var EmbeddedPublicKey = "not-set-at-build-time"
// GetPublicKeyFingerprint returns the fingerprint of the embedded public key
func GetPublicKeyFingerprint() string {
if EmbeddedPublicKey == "not-set-at-build-time" {
return ""
}
// Return first 8 bytes as fingerprint
if len(EmbeddedPublicKey) >= 16 {
return EmbeddedPublicKey[:16]
}
return EmbeddedPublicKey
}