Files
Redflag/aggregator-server/internal/services/update_nonce.go
jpetree331 f97d4845af feat(security): A-1 Ed25519 key rotation + A-2 replay attack fixes
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>
2026-03-28 21:25:47 -04:00

91 lines
2.2 KiB
Go

package services
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
type UpdateNonce struct {
AgentID string `json:"agent_id"`
TargetVersion string `json:"target_version"`
Timestamp int64 `json:"timestamp"`
Signature string `json:"signature"`
}
type UpdateNonceService struct {
privateKey ed25519.PrivateKey
maxAge time.Duration
}
func NewUpdateNonceService(privateKey ed25519.PrivateKey) *UpdateNonceService {
return &UpdateNonceService{
privateKey: privateKey,
maxAge: 10 * time.Minute,
}
}
// Generate creates a signed nonce authorizing an agent to update
func (s *UpdateNonceService) Generate(agentID, targetVersion string) (string, error) {
nonce := UpdateNonce{
AgentID: agentID,
TargetVersion: targetVersion,
Timestamp: time.Now().Unix(),
}
data, err := json.Marshal(nonce)
if err != nil {
return "", fmt.Errorf("marshal failed: %w", err)
}
signature := ed25519.Sign(s.privateKey, data)
nonce.Signature = base64.StdEncoding.EncodeToString(signature)
encoded, err := json.Marshal(nonce)
if err != nil {
return "", fmt.Errorf("encode failed: %w", err)
}
return base64.StdEncoding.EncodeToString(encoded), nil
}
// Validate verifies the nonce signature and freshness
func (s *UpdateNonceService) Validate(encodedNonce string) (*UpdateNonce, error) {
data, err := base64.StdEncoding.DecodeString(encodedNonce)
if err != nil {
return nil, fmt.Errorf("invalid base64: %w", err)
}
var nonce UpdateNonce
if err := json.Unmarshal(data, &nonce); err != nil {
return nil, fmt.Errorf("invalid format: %w", err)
}
// Check freshness
if time.Now().Unix()-nonce.Timestamp > int64(s.maxAge.Seconds()) {
return nil, fmt.Errorf("nonce expired")
}
// Verify signature
signature, err := base64.StdEncoding.DecodeString(nonce.Signature)
if err != nil {
return nil, fmt.Errorf("invalid signature: %w", err)
}
// Remove signature for verification
nonce.Signature = ""
verifyData, err := json.Marshal(nonce)
if err != nil {
return nil, fmt.Errorf("marshal verify data: %w", err)
}
if !ed25519.Verify(s.privateKey.Public().(ed25519.PublicKey), verifyData, signature) {
return nil, fmt.Errorf("signature verification failed")
}
// Return validated nonce
return &nonce, nil
}