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:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View 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)
}

View 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)
}
})
}
}

View 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.")
}

View 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
}

View 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)
}
}