Complete RedFlag codebase with two major security audit implementations.
== A-1: Ed25519 Key Rotation Support ==
Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management
Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing
== A-2: Replay Attack Fixes (F-1 through F-7) ==
F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH - Migration 026: expires_at column with partial index
F-6 HIGH - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH - Agent-side executedIDs dedup map with cleanup
F-4 HIGH - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt
Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.
All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
217 lines
6.6 KiB
Go
217 lines
6.6 KiB
Go
package crypto
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Fimeg/RedFlag/aggregator-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)
|
|
}
|
|
}
|