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

@@ -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