projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
382
agent/internal/crypto/pubkey.go
Normal file
382
agent/internal/crypto/pubkey.go
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
)
|
||||
|
||||
var teeLogger *event.TeeLogger
|
||||
|
||||
// InitLogger sets the package-level TeeLogger for dual-output logging.
|
||||
func InitLogger(l *event.TeeLogger) {
|
||||
teeLogger = l
|
||||
}
|
||||
|
||||
const defaultCacheTTLHours = 24
|
||||
|
||||
// Stale-cache fallback window (SEC-028). When the server is unreachable the
|
||||
// agent keeps working on its last-known public key, but only for a bounded
|
||||
// time: a key the server rotated OUT must not stay trusted forever just because
|
||||
// the agent can't phone home. The WINDOW LENGTH is operator policy delivered via
|
||||
// security settings (command_signing.stale_key_max_age_hours). The BOUNDS are
|
||||
// doctrine and live here, so neither a setting nor a tampered local config can
|
||||
// widen it past the ceiling or disable it. Forward-only is not a knob; the
|
||||
// existence of a fail-closed ceiling is fixed, only its length is tunable.
|
||||
const (
|
||||
defaultStaleKeyMaxAge = 7 * 24 * time.Hour // policy default; mirrors the server-side setting default
|
||||
minStaleKeyMaxAge = 1 * time.Hour // doctrinal floor — the window is always bounded
|
||||
maxStaleKeyMaxAge = 30 * 24 * time.Hour // doctrinal ceiling — forward-only cap, cannot be exceeded
|
||||
)
|
||||
|
||||
// staleKeyMaxAgeNanos holds the active window. Written by SetStaleKeyMaxAge from
|
||||
// the config-refresh goroutine, read on the command-verify path — atomic so the
|
||||
// two don't race. Zero means "unset": staleKeyMaxAge() falls back to the default.
|
||||
var staleKeyMaxAgeNanos atomic.Int64
|
||||
|
||||
// staleKeyMaxAge returns the active stale-cache window.
|
||||
func staleKeyMaxAge() time.Duration {
|
||||
if n := staleKeyMaxAgeNanos.Load(); n > 0 {
|
||||
return time.Duration(n)
|
||||
}
|
||||
return defaultStaleKeyMaxAge
|
||||
}
|
||||
|
||||
// SetStaleKeyMaxAge applies an operator-configured window (in hours), clamped to
|
||||
// the doctrinal [min,max] range. hours <= 0 means "unset" and restores the
|
||||
// default. The clamp is the enforcement point: a setting or local config that
|
||||
// asks for more than the ceiling gets the ceiling, never the request. Returns
|
||||
// the window actually applied.
|
||||
func SetStaleKeyMaxAge(hours int) time.Duration {
|
||||
d := defaultStaleKeyMaxAge
|
||||
if hours > 0 {
|
||||
d = time.Duration(hours) * time.Hour
|
||||
if d < minStaleKeyMaxAge {
|
||||
d = minStaleKeyMaxAge
|
||||
}
|
||||
if d > maxStaleKeyMaxAge {
|
||||
d = maxStaleKeyMaxAge
|
||||
}
|
||||
}
|
||||
staleKeyMaxAgeNanos.Store(int64(d))
|
||||
return d
|
||||
}
|
||||
|
||||
// getPublicKeyDir returns the platform-specific directory for key cache files
|
||||
// Uses constants package to ensure consistency with other path definitions.
|
||||
func getPublicKeyDir() string {
|
||||
return constants.GetServerPublicKeyDir()
|
||||
}
|
||||
|
||||
// getPrimaryKeyPath returns the path for the primary cached public key
|
||||
// Uses constants package as single source of truth (BUG-012 fix).
|
||||
func getPrimaryKeyPath() string {
|
||||
return constants.GetServerPublicKeyPath()
|
||||
}
|
||||
|
||||
// getKeyPathByID returns the path for a specific key cached by key_id
|
||||
func getKeyPathByID(keyID string) string {
|
||||
return filepath.Join(getPublicKeyDir(), "server_public_key_"+keyID)
|
||||
}
|
||||
|
||||
// getPrimaryMetaPath returns the metadata file path for the primary key
|
||||
func getPrimaryMetaPath() string {
|
||||
return filepath.Join(getPublicKeyDir(), "server_public_key.meta")
|
||||
}
|
||||
|
||||
// CacheMetadata holds metadata about the cached public key
|
||||
type CacheMetadata struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Version int `json:"version"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
TTLHours int `json:"ttl_hours"`
|
||||
}
|
||||
|
||||
// IsExpired returns true if the cache TTL has been exceeded
|
||||
func (m *CacheMetadata) IsExpired() bool {
|
||||
ttl := time.Duration(m.TTLHours) * time.Hour
|
||||
if ttl <= 0 {
|
||||
ttl = defaultCacheTTLHours * time.Hour
|
||||
}
|
||||
return time.Since(m.CachedAt) > ttl
|
||||
}
|
||||
|
||||
// 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"`
|
||||
KeyID string `json:"key_id"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// ActivePublicKeyEntry represents one entry from GET /api/v1/public-keys
|
||||
type ActivePublicKeyEntry struct {
|
||||
KeyID string `json:"key_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Version int `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
}
|
||||
|
||||
// loadCacheMetadata loads the metadata sidecar file for the primary key
|
||||
func loadCacheMetadata() (*CacheMetadata, error) {
|
||||
data, err := os.ReadFile(getPrimaryMetaPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta CacheMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
// saveCacheMetadata writes the metadata sidecar file
|
||||
func saveCacheMetadata(meta *CacheMetadata) error {
|
||||
dir := getPublicKeyDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create key dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
return os.WriteFile(getPrimaryMetaPath(), data, 0644)
|
||||
}
|
||||
|
||||
// FetchAndCacheServerPublicKey fetches the server's Ed25519 primary public key.
|
||||
// Uses a TTL+key_id cache: skips the fetch only if both TTL is valid AND key_id matches.
|
||||
// Implements Trust-On-First-Use (TOFU) with rotation awareness.
|
||||
func FetchAndCacheServerPublicKey(serverURL string) (ed25519.PublicKey, error) {
|
||||
// Check if cache is still valid
|
||||
if meta, err := loadCacheMetadata(); err == nil && meta.KeyID != "" && !meta.IsExpired() {
|
||||
// Cache metadata is valid and within TTL — try to load the cached key
|
||||
if cachedKey, err := LoadCachedPublicKey(); err == nil && cachedKey != nil {
|
||||
return cachedKey, nil
|
||||
}
|
||||
// Cache file missing despite valid metadata — fall through to re-fetch
|
||||
}
|
||||
|
||||
// Fetch primary key from server
|
||||
resp, err := http.Get(serverURL + "/api/v1/public-key")
|
||||
if err != nil {
|
||||
// Network failed — serve the cached key only within the bounded staleness
|
||||
// window (SEC-028). Past the ceiling, or when the cache age can't be
|
||||
// established, fail closed: forward-only forbids trusting a possibly
|
||||
// rotated-out key indefinitely.
|
||||
cachedKey, loadErr := LoadCachedPublicKey()
|
||||
if loadErr != nil {
|
||||
return nil, fmt.Errorf("failed to fetch public key from server: %w", err)
|
||||
}
|
||||
meta, metaErr := loadCacheMetadata()
|
||||
if metaErr != nil {
|
||||
return nil, fmt.Errorf("public key fetch failed and cache age is unknown (no metadata); refusing stale key: %w", err)
|
||||
}
|
||||
age := time.Since(meta.CachedAt)
|
||||
window := staleKeyMaxAge()
|
||||
if age > window {
|
||||
return nil, fmt.Errorf("public key fetch failed and cached key is stale (age %s > max %s); refusing: %w",
|
||||
age.Round(time.Hour), window, err)
|
||||
}
|
||||
// Degraded trust, not a routine warning: surface at ERROR so an operator
|
||||
// sees an agent running on an un-refreshed signing key.
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "pubkey", "server unreachable, serving stale cached public key within bounded window", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"cache_age": age.Round(time.Minute).String(),
|
||||
"max_stale": window.String(),
|
||||
"key_id": meta.KeyID,
|
||||
})
|
||||
}
|
||||
return cachedKey, nil
|
||||
}
|
||||
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 keyResp PublicKeyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key response: %w", err)
|
||||
}
|
||||
|
||||
if keyResp.Algorithm != "ed25519" {
|
||||
return nil, fmt.Errorf("unsupported signature algorithm: %s (expected ed25519)", keyResp.Algorithm)
|
||||
}
|
||||
|
||||
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 the primary key
|
||||
if err := cachePublicKey(publicKey); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache primary public key", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Use key_id from response (fall back to fingerprint for old servers)
|
||||
keyID := keyResp.KeyID
|
||||
if keyID == "" {
|
||||
keyID = keyResp.Fingerprint
|
||||
}
|
||||
|
||||
// Write metadata sidecar
|
||||
meta := &CacheMetadata{
|
||||
KeyID: keyID,
|
||||
Version: keyResp.Version,
|
||||
CachedAt: time.Now().UTC(),
|
||||
TTLHours: defaultCacheTTLHours,
|
||||
}
|
||||
if err := saveCacheMetadata(meta); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to save key cache metadata", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also cache by key_id for multi-key lookup
|
||||
if keyID != "" {
|
||||
if err := CachePublicKeyByID(keyID, publicKey); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache key by ID", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "crypto", "pubkey", "public key fetched and cached", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"version": keyResp.Version,
|
||||
})
|
||||
}
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
// FetchAndCacheAllActiveKeys fetches all active public keys from GET /api/v1/public-keys
|
||||
// and caches each one by its key_id. Used during key rotation transition windows.
|
||||
func FetchAndCacheAllActiveKeys(serverURL string) ([]ActivePublicKeyEntry, error) {
|
||||
resp, err := http.Get(serverURL + "/api/v1/public-keys")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch active public keys: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var entries []ActivePublicKeyEntry
|
||||
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode public keys list: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Algorithm != "ed25519" {
|
||||
continue
|
||||
}
|
||||
pubKeyBytes, err := hex.DecodeString(entry.PublicKey)
|
||||
if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize {
|
||||
continue
|
||||
}
|
||||
if err := CachePublicKeyByID(entry.KeyID, ed25519.PublicKey(pubKeyBytes)); err != nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache key", map[string]interface{}{
|
||||
"key_id": entry.KeyID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// LoadCachedPublicKey loads the primary cached public key from disk (backward compat path)
|
||||
func LoadCachedPublicKey() (ed25519.PublicKey, error) {
|
||||
data, err := os.ReadFile(getPrimaryKeyPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("cached public key has invalid size: %d bytes", len(data))
|
||||
}
|
||||
return ed25519.PublicKey(data), nil
|
||||
}
|
||||
|
||||
// LoadCachedPublicKeyByID loads a cached public key by its key_id.
|
||||
// Falls back to the primary key if the key_id-specific file does not exist.
|
||||
func LoadCachedPublicKeyByID(keyID string) (ed25519.PublicKey, error) {
|
||||
if keyID == "" {
|
||||
return LoadCachedPublicKey()
|
||||
}
|
||||
data, err := os.ReadFile(getKeyPathByID(keyID))
|
||||
if err == nil && len(data) == ed25519.PublicKeySize {
|
||||
return ed25519.PublicKey(data), nil
|
||||
}
|
||||
// Fall back to primary
|
||||
return LoadCachedPublicKey()
|
||||
}
|
||||
|
||||
// IsKeyIDCached returns true if a key with the given key_id is cached locally
|
||||
func IsKeyIDCached(keyID string) bool {
|
||||
if keyID == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(getKeyPathByID(keyID))
|
||||
return err == nil && info.Size() == ed25519.PublicKeySize
|
||||
}
|
||||
|
||||
// cachePublicKey saves the primary public key to disk (backward compat path)
|
||||
func cachePublicKey(publicKey ed25519.PublicKey) error {
|
||||
dir := getPublicKeyDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
return os.WriteFile(getPrimaryKeyPath(), publicKey, 0644)
|
||||
}
|
||||
|
||||
// CachePublicKeyByID saves a public key under its key_id filename
|
||||
func CachePublicKeyByID(keyID string, publicKey ed25519.PublicKey) error {
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("keyID cannot be empty")
|
||||
}
|
||||
dir := getPublicKeyDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
return os.WriteFile(getKeyPathByID(keyID), publicKey, 0644)
|
||||
}
|
||||
|
||||
// GetPublicKey returns the primary cached public key or fetches it from the server
|
||||
func GetPublicKey(serverURL string) (ed25519.PublicKey, error) {
|
||||
// Try with TTL-aware fetch (will use cache if valid)
|
||||
return FetchAndCacheServerPublicKey(serverURL)
|
||||
}
|
||||
50
agent/internal/crypto/pubkey_test.go
Normal file
50
agent/internal/crypto/pubkey_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCacheMetadataIsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta CacheMetadata
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "fresh_within_ttl",
|
||||
meta: CacheMetadata{CachedAt: time.Now(), TTLHours: 24},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired_past_ttl",
|
||||
meta: CacheMetadata{CachedAt: time.Now().Add(-25 * time.Hour), TTLHours: 24},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "zero_ttl_defaults_24h_fresh",
|
||||
meta: CacheMetadata{CachedAt: time.Now(), TTLHours: 0},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "zero_ttl_defaults_24h_expired",
|
||||
meta: CacheMetadata{CachedAt: time.Now().Add(-25 * time.Hour), TTLHours: 0},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exactly_at_ttl_boundary",
|
||||
meta: CacheMetadata{CachedAt: time.Now().Add(-24 * time.Hour), TTLHours: 24},
|
||||
expected: true, // at exactly TTL, treat as expired
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.meta.IsExpired()
|
||||
if got != tt.expected {
|
||||
t.Errorf("IsExpired() = %v, want %v (cachedAt=%v, ttl=%dh)",
|
||||
got, tt.expected, tt.meta.CachedAt, tt.meta.TTLHours)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
241
agent/internal/crypto/replay_test.go
Normal file
241
agent/internal/crypto/replay_test.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
package crypto
|
||||
|
||||
// replay_test.go — Pre-fix tests for command replay attack surface on the agent side.
|
||||
//
|
||||
// These tests document the current (buggy) behaviour of the agent's command
|
||||
// verification path. All tests use helpers defined in verification_test.go
|
||||
// (generateKeyPair, signCommand, signCommandOld) which are in the same package.
|
||||
//
|
||||
// Each test is categorised:
|
||||
//
|
||||
// PASS-NOW / FAIL-AFTER-FIX — documents a bug as-is; flips to fail when fix is applied.
|
||||
//
|
||||
// Run: cd agent && go test ./internal/crypto/... -v -run TestReplay
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestOld
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestNew
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestSame
|
||||
// cd agent && go test ./internal/crypto/... -v -run TestCross
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.1 — BUG F-3: Old-format commands are valid forever
|
||||
//
|
||||
// Category: PASS-NOW / FAIL-AFTER-FIX
|
||||
//
|
||||
// VerifyCommand (crypto/verification.go:25) reconstructs the message as
|
||||
// "{id}:{type}:{sha256(params)}" and verifies the Ed25519 signature.
|
||||
// There is NO time check. A command signed 72 hours ago — or 72 years ago —
|
||||
// passes without error. The test PASSES now (bug is present).
|
||||
// After fix (add expiry to VerifyCommand or deprecate old format): FAILS.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOldFormatReplayIsUnbounded(t *testing.T) {
|
||||
// POST-FIX (F-3): Old-format commands with CreatedAt older than 48h are rejected.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Old-format command created 72 hours ago
|
||||
createdAt := time.Now().Add(-72 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "old-replay-cmd-72h",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
SignedAt: nil,
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
// After F-3 fix: 72h old-format command must be rejected
|
||||
err := v.VerifyCommand(cmd, pub)
|
||||
if err == nil {
|
||||
t.Error("F-3 FIX BROKEN: old-format command 72h old should be rejected, but passed")
|
||||
}
|
||||
t.Logf("F-3 FIXED: Old-format command created 72h ago correctly rejected: %v", err)
|
||||
}
|
||||
|
||||
func TestOldFormatRecentCommandStillPasses(t *testing.T) {
|
||||
// POST-FIX (F-3): Old-format commands WITHIN 48h still pass (backward compat).
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Old-format command created 12 hours ago (within 48h limit)
|
||||
createdAt := time.Now().Add(-12 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "old-recent-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
SignedAt: nil,
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
err := v.VerifyCommand(cmd, pub)
|
||||
if err != nil {
|
||||
t.Errorf("old-format command within 48h should pass: %v", err)
|
||||
}
|
||||
t.Log("F-3 BACKWARD COMPAT: Old-format command created 12h ago passes verification.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.2 — F-4: New-format commands can be replayed for 24 hours
|
||||
//
|
||||
// Category: PASS-NOW / MAY-REMAIN-PASSING-UNTIL-maxAge-IS-REDUCED
|
||||
//
|
||||
// VerifyCommandWithTimestamp allows commands signed up to commandMaxAge (24h)
|
||||
// in the past. A captured command from 23 hours and 59 minutes ago still
|
||||
// passes verification. This documents the replay window.
|
||||
//
|
||||
// Note: This test reflects an intentional (but generous) design decision.
|
||||
// It will only flip to FAIL if commandMaxAge is reduced below 24h.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewFormatCommandCanBeReplayedWithin24Hours(t *testing.T) {
|
||||
// POST-FIX (F-4): commandMaxAge reduced to 4h. Test updated to use 3h59m.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Command signed almost 4 hours ago — still within the new 4h window.
|
||||
signedAt := time.Now().UTC().Add(-3*time.Hour - 59*time.Minute)
|
||||
|
||||
cmd := client.Command{
|
||||
ID: "replay-4h-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
AgentID: "agent-replay-test",
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("expected 3h59m old command to pass within 4h window, got: %v", err)
|
||||
}
|
||||
|
||||
t.Log("F-4 FIXED: Command signed 3h59m ago passes VerifyCommandWithTimestamp with 4h window.")
|
||||
t.Logf(" SignedAt: %v (window: 4h)", signedAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func TestCommandBeyond4HoursIsRejected(t *testing.T) {
|
||||
// POST-FIX (F-4): Commands older than 4h must be rejected.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
signedAt := time.Now().UTC().Add(-4*time.Hour - 1*time.Minute)
|
||||
cmd := client.Command{
|
||||
ID: "expired-4h-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
AgentID: "agent-replay-test",
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected 4h1m old command to be rejected, but it passed")
|
||||
}
|
||||
t.Logf("F-4 FIXED: Command signed 4h1m ago correctly rejected: %v", err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.3 — BUG F-2: The same command can be verified any number of times
|
||||
//
|
||||
// Category: PASS-NOW / FAIL-AFTER-FIX
|
||||
//
|
||||
// VerifyCommandWithTimestamp is a pure function — given the same inputs it
|
||||
// returns the same output every time. There is no nonce, no single-use token,
|
||||
// and no agent-side deduplication. A replayed command passes verification
|
||||
// identically on the second, third, and Nth call within the time window.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSameCommandCanBeVerifiedTwice(t *testing.T) {
|
||||
// POST-FIX (F-2): Deduplication is now at the ProcessCommand level,
|
||||
// not at the VerifyCommandWithTimestamp level. The verifier is a pure
|
||||
// function — it still returns success on repeated calls. The dedup
|
||||
// is handled by CommandHandler.ProcessCommand's executedIDs set.
|
||||
//
|
||||
// This test documents that the VERIFIER allows repeated verification
|
||||
// (which is correct — dedup is a higher-layer concern).
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
signedAt := time.Now().UTC()
|
||||
cmd := client.Command{
|
||||
ID: "no-nonce-cmd",
|
||||
Type: "reboot",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-dedup-test",
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
// Verifier-level: still passes multiple times (pure function)
|
||||
err1 := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err1 != nil {
|
||||
t.Fatalf("first verification should pass: %v", err1)
|
||||
}
|
||||
|
||||
err2 := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err2 != nil {
|
||||
t.Fatalf("second verification should also pass at verifier level: %v", err2)
|
||||
}
|
||||
|
||||
t.Log("F-2 NOTE: Verifier is a pure function — dedup is at ProcessCommand layer.")
|
||||
t.Log("CommandHandler.ProcessCommand maintains executedIDs set for single-use enforcement.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.4 — BUG F-1: Signed message contains no agent binding
|
||||
//
|
||||
// Category: PASS-NOW / FAIL-AFTER-FIX
|
||||
//
|
||||
// The signed message format is: "{id}:{type}:{sha256(params)}:{timestamp}"
|
||||
// None of these components are bound to a specific agent. The client.Command
|
||||
// struct has no agent_id field at all — it is stripped before delivery.
|
||||
// The same signature verifies regardless of which agent receives the command.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCrossAgentSignatureVerifies(t *testing.T) {
|
||||
// POST-FIX (F-1): agent_id is now in the signed message.
|
||||
// A command signed for agent A must fail verification when presented
|
||||
// with agent B's ID.
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
signedAt := time.Now().UTC()
|
||||
agentA := "agent-aaa-111"
|
||||
agentB := "agent-bbb-222"
|
||||
|
||||
// Sign command for agent A using v3 format
|
||||
cmd := client.Command{
|
||||
ID: "cross-agent-cmd",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx"},
|
||||
AgentID: agentA,
|
||||
}
|
||||
cmd.SignedAt = &signedAt
|
||||
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
|
||||
|
||||
// Verify with correct agent A — should pass
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("verification with correct agent A should pass, got: %v", err)
|
||||
}
|
||||
|
||||
// Now try with agent B's ID — should FAIL
|
||||
cmdForB := cmd
|
||||
cmdForB.AgentID = agentB
|
||||
err = v.VerifyCommandWithTimestamp(cmdForB, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("F-1 FIX BROKEN: cross-agent verification with agent B should FAIL but passed")
|
||||
}
|
||||
|
||||
t.Logf("F-1 FIXED: Command signed for agent %q fails verification when presented as agent %q", agentA, agentB)
|
||||
t.Log("The signature is now bound to the target agent_id in the v3 message format.")
|
||||
}
|
||||
299
agent/internal/crypto/verification.go
Normal file
299
agent/internal/crypto/verification.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// CommandVerifier handles Ed25519 signature verification for commands
|
||||
type CommandVerifier struct{}
|
||||
|
||||
// NewCommandVerifier creates a new command verifier
|
||||
func NewCommandVerifier() *CommandVerifier {
|
||||
return &CommandVerifier{}
|
||||
}
|
||||
|
||||
// oldFormatMaxAge is the maximum age for old-format commands (no signed_at).
|
||||
// Phase 1 (F-3 fix): reject old-format commands older than 48h.
|
||||
// Phase 2 (future): remove old-format fallback entirely after 90 days from migration 025 deployment.
|
||||
const oldFormatMaxAge = 48 * time.Hour
|
||||
|
||||
// VerifyCommand verifies a command using the old signing format (no timestamp).
|
||||
// Used for backward compatibility with commands signed before key rotation support.
|
||||
// Format: "{id}:{command_type}:{sha256(params)}"
|
||||
// F-3 fix: rejects commands older than 48h if CreatedAt is available.
|
||||
func (v *CommandVerifier) VerifyCommand(cmd client.Command, serverPubKey ed25519.PublicKey) error {
|
||||
// F-3 fix: check age using server-side created_at if available
|
||||
if cmd.CreatedAt != nil {
|
||||
age := time.Since(*cmd.CreatedAt)
|
||||
if age > oldFormatMaxAge {
|
||||
return fmt.Errorf("command too old: old-format command exceeds 48h age limit (created %v ago)", age.Round(time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Signature == "" {
|
||||
return fmt.Errorf("command missing signature")
|
||||
}
|
||||
sig, err := hex.DecodeString(cmd.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(sig))
|
||||
}
|
||||
message, err := v.reconstructMessage(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconstruct message: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(serverPubKey, message, sig) {
|
||||
return fmt.Errorf("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconstructMessage recreates the signed message using the old format (no timestamp).
|
||||
// Format: "{id}:{command_type}:{sha256(params)}"
|
||||
func (v *CommandVerifier) reconstructMessage(cmd client.Command) ([]byte, error) {
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s", cmd.ID, cmd.Type, paramsHashHex)
|
||||
return []byte(message), nil
|
||||
}
|
||||
|
||||
// reconstructMessageV3 recreates the signed message using v3 format (with agent_id + timestamp).
|
||||
// Format: "{agent_id}:{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
|
||||
func (v *CommandVerifier) reconstructMessageV3(cmd client.Command) ([]byte, error) {
|
||||
if cmd.SignedAt == nil {
|
||||
return nil, fmt.Errorf("command SignedAt is nil, cannot reconstruct v3 message")
|
||||
}
|
||||
if cmd.AgentID == "" {
|
||||
return nil, fmt.Errorf("command AgentID is empty, cannot reconstruct v3 message")
|
||||
}
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s:%s:%d", cmd.AgentID, cmd.ID, cmd.Type, paramsHashHex, cmd.SignedAt.Unix())
|
||||
return []byte(message), nil
|
||||
}
|
||||
|
||||
// reconstructMessageWithTimestamp recreates the signed message using v2 format (timestamp, no agent_id).
|
||||
// Format: "{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
|
||||
func (v *CommandVerifier) reconstructMessageWithTimestamp(cmd client.Command) ([]byte, error) {
|
||||
if cmd.SignedAt == nil {
|
||||
return nil, fmt.Errorf("command SignedAt is nil, cannot reconstruct timestamped message")
|
||||
}
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, cmd.SignedAt.Unix())
|
||||
return []byte(message), nil
|
||||
}
|
||||
|
||||
// VerifyCommandWithTimestamp verifies a command signature AND validates the signing timestamp.
|
||||
// Rejects commands signed more than maxAge in the past, or more than clockSkew in the future.
|
||||
// Uses the new timestamped message format.
|
||||
// If cmd.SignedAt is nil, falls back to the old (no-timestamp) verification format for backward compatibility.
|
||||
//
|
||||
// The default maxAge used by command_handler.go is 4 hours (reduced from 24h in A-2 fix F-4).
|
||||
// This balances security (shorter replay window) against operational flexibility (agents
|
||||
// polling every few minutes have ample time to receive and verify commands).
|
||||
// See commandMaxAge constant in orchestrator/command_handler.go.
|
||||
func (v *CommandVerifier) VerifyCommandWithTimestamp(
|
||||
cmd client.Command,
|
||||
serverPubKey ed25519.PublicKey,
|
||||
maxAge time.Duration,
|
||||
clockSkew time.Duration,
|
||||
) error {
|
||||
if cmd.SignedAt == nil {
|
||||
// No timestamp — fall back to old format (backward compat)
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "deprecated command format: oldest format, no signed_at", map[string]interface{}{
|
||||
"command_id": cmd.ID,
|
||||
})
|
||||
}
|
||||
return v.VerifyCommand(cmd, serverPubKey)
|
||||
}
|
||||
|
||||
// Validate timestamp window
|
||||
now := time.Now().UTC()
|
||||
age := now.Sub(*cmd.SignedAt)
|
||||
if age > maxAge {
|
||||
return fmt.Errorf("command timestamp too old: signed %v ago (max %v)", age.Round(time.Second), maxAge)
|
||||
}
|
||||
if age < -clockSkew {
|
||||
return fmt.Errorf("command timestamp is in the future: %v ahead (max skew %v)", (-age).Round(time.Second), clockSkew)
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if cmd.Signature == "" {
|
||||
return fmt.Errorf("command missing signature")
|
||||
}
|
||||
sig, err := hex.DecodeString(cmd.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(sig))
|
||||
}
|
||||
|
||||
// Try v3 format first (with agent_id) if AgentID is present
|
||||
if cmd.AgentID != "" {
|
||||
message, err := v.reconstructMessageV3(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconstruct v3 message: %w", err)
|
||||
}
|
||||
if ed25519.Verify(serverPubKey, message, sig) {
|
||||
return nil // v3 verification succeeded
|
||||
}
|
||||
// v3 failed — try v2 as fallback (server may not have been upgraded yet)
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "v3 verification failed, trying v2", map[string]interface{}{
|
||||
"command_id": cmd.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// v2 format: timestamp but no agent_id (backward compat)
|
||||
if cmd.AgentID == "" {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "deprecated command format: v2, no agent_id", map[string]interface{}{
|
||||
"command_id": cmd.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
message, err := v.reconstructMessageWithTimestamp(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reconstruct timestamped message: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(serverPubKey, message, sig) {
|
||||
return fmt.Errorf("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckKeyRotation checks if the key_id in a command is cached locally.
|
||||
// If not cached, it fetches all active keys from the server and caches them.
|
||||
// Returns the correct public key to use for verifying this command.
|
||||
func (v *CommandVerifier) CheckKeyRotation(keyID string, serverURL string) (ed25519.PublicKey, bool, error) {
|
||||
if keyID == "" {
|
||||
// No key_id in command — backward compat: use primary cached key
|
||||
key, err := LoadCachedPublicKey()
|
||||
return key, false, err
|
||||
}
|
||||
|
||||
// Check if this key is already cached
|
||||
if IsKeyIDCached(keyID) {
|
||||
key, err := LoadCachedPublicKeyByID(keyID)
|
||||
return key, false, err
|
||||
}
|
||||
|
||||
// Key not cached — fetch all active keys from server
|
||||
if teeLogger != nil {
|
||||
teeLogger.Info("agent", "crypto", "verification", "key not cached, fetching active keys from server", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
})
|
||||
}
|
||||
entries, err := FetchAndCacheAllActiveKeys(serverURL)
|
||||
if err != nil {
|
||||
// Forward-only (SEC-028): the active-set fetch failed, but trusting the
|
||||
// primary cache here with NO age bound is a wider hole than the primary
|
||||
// fetch path (FetchAndCacheServerPublicKey) closes on its own. Apply the
|
||||
// same bounded-stale ceiling: serve the primary only within the window,
|
||||
// fail closed past it. A primary the server rotated OUT must not verify a
|
||||
// key_id'd command just because /public-keys is unreachable.
|
||||
key, loadErr := LoadCachedPublicKey()
|
||||
if loadErr != nil {
|
||||
return nil, false, fmt.Errorf("key %s not cached and active-set fetch failed: fetch=%v, load=%v", keyID, err, loadErr)
|
||||
}
|
||||
meta, metaErr := loadCacheMetadata()
|
||||
if metaErr != nil {
|
||||
return nil, false, fmt.Errorf("active-set fetch failed and primary cache age unknown (no metadata); refusing: %w", err)
|
||||
}
|
||||
age := time.Since(meta.CachedAt)
|
||||
window := staleKeyMaxAge()
|
||||
if age > window {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "verification", "active-set fetch failed and primary cache stale; refusing key_id'd command", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"cache_age": age.Round(time.Hour).String(),
|
||||
"max_stale": window.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return nil, false, fmt.Errorf("active-set fetch failed and primary cache stale (age %s > %s); refusing: %w",
|
||||
age.Round(time.Hour), window, err)
|
||||
}
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "active-set fetch failed, serving bounded-stale primary", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"cache_age": age.Round(time.Minute).String(),
|
||||
"max_stale": window.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return key, false, nil
|
||||
}
|
||||
|
||||
// Check if we got the requested key
|
||||
for _, entry := range entries {
|
||||
if entry.KeyID == keyID {
|
||||
key, err := LoadCachedPublicKeyByID(keyID)
|
||||
return key, true, err
|
||||
}
|
||||
}
|
||||
|
||||
// Requested key_id was fetched but is not in the server's active set. A key
|
||||
// the server has rotated OUT is dead — forward-only doctrine refuses it
|
||||
// rather than silently falling back to the primary key (SEC-028). The caller
|
||||
// surfaces this as a command-verification failure.
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "verification", "command key_id not in server active set, refusing", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
})
|
||||
}
|
||||
return nil, false, fmt.Errorf("key %s not in server active set", keyID)
|
||||
}
|
||||
|
||||
// VerifyCommandBatch verifies multiple commands efficiently
|
||||
func (v *CommandVerifier) VerifyCommandBatch(
|
||||
commands []client.Command,
|
||||
serverPubKey ed25519.PublicKey,
|
||||
) []error {
|
||||
errors := make([]error, len(commands))
|
||||
for i, cmd := range commands {
|
||||
errors[i] = v.VerifyCommand(cmd, serverPubKey)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
// ExtractCommandIDFromSignature attempts to verify a signature and returns the command ID
|
||||
func (v *CommandVerifier) ExtractCommandIDFromSignature(
|
||||
signature string,
|
||||
expectedMessage string,
|
||||
serverPubKey ed25519.PublicKey,
|
||||
) (string, error) {
|
||||
sig, err := hex.DecodeString(signature)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid signature encoding: %w", err)
|
||||
}
|
||||
if !ed25519.Verify(serverPubKey, []byte(expectedMessage), sig) {
|
||||
return "", fmt.Errorf("signature verification failed")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
216
agent/internal/crypto/verification_test.go
Normal file
216
agent/internal/crypto/verification_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
)
|
||||
|
||||
// signCommand is a test helper that signs a command using the v3 format (with agent_id).
|
||||
// Format: "{agent_id}:{id}:{type}:{sha256(params)}:{unix_timestamp}"
|
||||
// Falls back to v2 format if cmd.AgentID is empty (for backward compat tests).
|
||||
func signCommand(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command, signedAt time.Time) string {
|
||||
t.Helper()
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal params: %v", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
var message string
|
||||
if cmd.AgentID != "" {
|
||||
// v3 format with agent_id
|
||||
message = fmt.Sprintf("%s:%s:%s:%s:%d", cmd.AgentID, cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
|
||||
} else {
|
||||
// v2 format without agent_id (backward compat)
|
||||
message = fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
|
||||
}
|
||||
sig := ed25519.Sign(privKey, []byte(message))
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// signCommandV2 explicitly signs using v2 format (no agent_id) for backward compat tests.
|
||||
func signCommandV2(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command, signedAt time.Time) string {
|
||||
t.Helper()
|
||||
paramsJSON, err := json.Marshal(cmd.Params)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal params: %v", err)
|
||||
}
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
|
||||
sig := ed25519.Sign(privKey, []byte(message))
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// signCommandOld is a test helper that signs using the old format (no timestamp).
|
||||
// Format: "{id}:{type}:{sha256(params)}"
|
||||
func signCommandOld(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command) string {
|
||||
t.Helper()
|
||||
paramsJSON, _ := json.Marshal(cmd.Params)
|
||||
paramsHash := sha256.Sum256(paramsJSON)
|
||||
paramsHashHex := hex.EncodeToString(paramsHash[:])
|
||||
message := fmt.Sprintf("%s:%s:%s", cmd.ID, cmd.Type, paramsHashHex)
|
||||
sig := ed25519.Sign(privKey, []byte(message))
|
||||
return hex.EncodeToString(sig)
|
||||
}
|
||||
|
||||
func generateKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key pair: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_ValidRecent(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-1",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{"target": "apt"},
|
||||
AgentID: "agent-001",
|
||||
}
|
||||
cmd.SignedAt = &now
|
||||
cmd.Signature = signCommand(t, priv, &cmd, now)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("expected valid recent command to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_TooOld(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
oldTime := time.Now().UTC().Add(-2 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-2",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-002",
|
||||
}
|
||||
cmd.SignedAt = &oldTime
|
||||
cmd.Signature = signCommand(t, priv, &cmd, oldTime)
|
||||
|
||||
// With maxAge of 1 hour — should fail
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 1*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected old command to fail timestamp check, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_FutureBeyondSkew(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
futureTime := time.Now().UTC().Add(10 * time.Minute)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-3",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-003",
|
||||
}
|
||||
cmd.SignedAt = &futureTime
|
||||
cmd.Signature = signCommand(t, priv, &cmd, futureTime)
|
||||
|
||||
// With clockSkew of 5 min — should fail (10 min future)
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected future-dated command to fail, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_FutureWithinSkew(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
futureTime := time.Now().UTC().Add(2 * time.Minute) // within 5 min skew
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-4",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-004",
|
||||
}
|
||||
cmd.SignedAt = &futureTime
|
||||
cmd.Signature = signCommand(t, priv, &cmd, futureTime)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("expected command within clock skew to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_BackwardCompatNoTimestamp(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Set CreatedAt to recent time so F-3 48h check passes
|
||||
createdAt := time.Now().Add(-1 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-5",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{"pkg": "nginx"},
|
||||
SignedAt: nil, // no timestamp — old server
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
// Should fall back to old verification and succeed (within 48h)
|
||||
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("expected backward-compat (no timestamp) command to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommandWithTimestamp_WrongKey(t *testing.T) {
|
||||
_, priv := generateKeyPair(t)
|
||||
wrongPub, _ := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-6",
|
||||
Type: "scan_updates",
|
||||
Params: map[string]interface{}{},
|
||||
AgentID: "agent-006",
|
||||
}
|
||||
cmd.SignedAt = &now
|
||||
cmd.Signature = signCommand(t, priv, &cmd, now)
|
||||
|
||||
err := v.VerifyCommandWithTimestamp(cmd, wrongPub, 24*time.Hour, 5*time.Minute)
|
||||
if err == nil {
|
||||
t.Error("expected wrong-key verification to fail, but it passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCommand_BackwardCompat(t *testing.T) {
|
||||
pub, priv := generateKeyPair(t)
|
||||
v := NewCommandVerifier()
|
||||
|
||||
// Set CreatedAt to recent time so F-3 48h check passes
|
||||
createdAt := time.Now().Add(-1 * time.Hour)
|
||||
cmd := client.Command{
|
||||
ID: "test-cmd-7",
|
||||
Type: "install_updates",
|
||||
Params: map[string]interface{}{"package": "nginx", "version": "1.20.0"},
|
||||
CreatedAt: &createdAt,
|
||||
}
|
||||
cmd.Signature = signCommandOld(t, priv, &cmd)
|
||||
|
||||
if err := v.VerifyCommand(cmd, pub); err != nil {
|
||||
t.Errorf("expected old-format verification to pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue