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:
@@ -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")
|
||||
|
||||
130
aggregator-agent/internal/crypto/pubkey.go
Normal file
130
aggregator-agent/internal/crypto/pubkey.go
Normal 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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
129
aggregator-agent/internal/system/machine_id.go
Normal file
129
aggregator-agent/internal/system/machine_id.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user