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,177 @@
# Ed25519 Signing Pipeline
**Server-side command and binary signing with Ed25519.**
---
## Overview
All commands and binaries are signed with Ed25519 private key on the server. Agents verify signatures before execution.
**Cross-references:**
- [verification/02-agent-verification](02-agent-verification.md) (agent-side verification)
- [verification/03-key-rotation](03-key-rotation.md) (key rotation support)
- [security/01-trust-boundaries](../security/01-trust-boundaries.md) (signature as trust boundary)
---
## Key Generation
**Method:** Server startup automatic key registration
**File:** `server/internal/services/signing.go`
```go
func (s *SigningService) InitializePrimaryKey(ctx context.Context) error {
// 1. Get current key fingerprint (SHA-256 of public key, truncated)
keyID := s.GetCurrentKeyID()
publicKeyHex := s.GetPublicKeyHex()
// 2. Query next version number from database
nextVersion, err := s.signingKeyQueries.GetNextVersion(ctx)
if err != nil {
nextVersion = 1
}
// 3. Insert key (ON CONFLICT DO NOTHING — safe on every startup)
if err := s.signingKeyQueries.InsertSigningKey(ctx, keyID, publicKeyHex, nextVersion); err != nil {
return fmt.Errorf("failed to insert signing key: %w", err)
}
// 4. Set as primary
if err := s.signingKeyQueries.SetPrimaryKey(ctx, keyID); err != nil {
return fmt.Errorf("failed to set primary key: %w", err)
}
return nil
}
```
---
## Command Signing
**Method:** v3 message format with agent_id binding
**File:** `server/internal/services/signing.go`
```go
func (s *SigningService) SignCommand(cmd *models.AgentCommand) (string, error) {
// 1. Record signing time and key identity
now := time.Now().UTC()
cmd.SignedAt = &now
cmd.KeyID = s.GetCurrentKeyID()
// 2. Serialize params and hash
paramsJSON, _ := json.Marshal(cmd.Params)
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
// 3. Create v3 message format
// agent_id binding prevents cross-agent replay (F-1 fix)
message := fmt.Sprintf("%s:%s:%s:%s:%d",
cmd.AgentID.String(),
cmd.ID.String(),
cmd.CommandType,
paramsHashHex,
now.Unix())
// 4. Sign with Ed25519
signature := ed25519.Sign(s.privateKey, []byte(message))
return hex.EncodeToString(signature), nil
}
```
**v3 Message Format Benefits:**
- **Agent binding:** Includes agent_id to prevent command relay attacks
- **Timestamp:** Prevents replay attacks (4-hour max age)
- **Parameter hash:** Hides full parameter data while allowing verification
---
## Binary Signing
**Method:** BuildOrchestrator signs binaries at startup
**File:** `server/internal/services/build_orchestrator.go`
```go
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
// 1. Load binary from disk
binaryName := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
binaryName += ".exe"
}
binaryPath := filepath.Join(s.agentDir, "binaries", platform+"-"+architecture, binaryName)
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
}
if s.signingService.IsEnabled() {
// 2. Compute checksum and sign
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
// 3. Set metadata
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
// 4. Store in database
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
return signedPackage, nil
} else {
log.Printf("Signing disabled, creating unsigned package entry")
// Create unsigned package entry for backward compatibility
unsignedPackage := &models.AgentUpdatePackage{
ID: uuid.New(),
Version: version,
Platform: platform,
Architecture: architecture,
BinaryPath: binaryPath,
Signature: "",
Checksum: "",
CreatedBy: "build-orchestrator",
IsActive: true,
}
// Get file info
if info, err := os.Stat(binaryPath); err == nil {
unsignedPackage.FileSize = info.Size()
}
// Store unsigned package
err := s.packageQueries.StoreSignedPackage(unsignedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store unsigned package: %w", err)
}
return unsignedPackage, nil
}
}
```
---
## Footer: Assumptions & Connections
**Assumption:** Ed25519 signing is enabled when `REDFLAG_SIGNING_PRIVATE_KEY` environment variable is set.
**Connection:** [verification/02-agent-verification](02-agent-verification.md) (agent-side signature verification)
**Connection:** [verification/03-key-rotation](03-key-rotation.md) (multi-key rotation support)
**Connection:** [security/01-trust-boundaries](../security/01-trust-boundaries.md) (cryptographic trust boundary)
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,162 @@
# Agent-Side Verification
**Agent verifies commands and binaries before execution.**
---
## Overview
Agent verifies Ed25519 signatures, timestamps, and nonces before executing commands.
**Cross-references:**
- [verification/01-signing-pipeline](01-signing-pipeline.md) (server-side signing)
- [verification/03-key-rotation](03-key-rotation.md) (key rotation)
- [verification/04-replay-protection](04-replay-protection.md) (replay protection)
---
## Public Key Caching (TOFU)
**Method:** Trust On First Use — TTL+key_id cache with rotation awareness
**File:** `agent/internal/crypto/pubkey.go`
```go
func FetchAndCacheServerPublicKey(serverURL string) (ed25519.PublicKey, error) {
// 1. Check if cache is valid (TTL + key_id match)
if meta, err := loadCacheMetadata(); err == nil && meta.KeyID != "" && !meta.IsExpired() {
if cachedKey, loadErr := LoadCachedPublicKey(); loadErr == nil {
return cachedKey, nil
}
}
// 2. Fetch from server GET /api/v1/public-key
pubKeyBytes, err := httpGetPublicKey(serverURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch public key: %w", err)
}
// 3. Cache to disk
if err := cachePublicKey(pubKeyBytes); err != nil {
fmt.Printf("Warning: Failed to cache public key: %v\n", err)
}
return pubKeyBytes, nil
}
```
---
## Command Verification
**Method:** Verify v3 signature with timestamp, falls back to older formats for backward compatibility
**File:** `agent/internal/crypto/verification.go`
```go
func (v *CommandVerifier) VerifyCommandWithTimestamp(
cmd client.Command,
serverPubKey ed25519.PublicKey,
maxAge time.Duration,
clockSkew time.Duration,
) error {
// 1. If cmd.SignedAt is nil, fall back to oldest format (backward compat)
if cmd.SignedAt == nil {
fmt.Printf("[WARNING] [agent] [crypto] command_uses_oldest_format command_id=%s no_signed_at=true upgrade_server_recommended\n", cmd.ID)
return v.VerifyCommand(cmd, serverPubKey)
}
// 2. 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)
}
// 3. 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
}
// 4. v2 format: timestamp but no agent_id (backward compat)
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 errors.New("signature verification failed")
}
return nil
}
```
**Verification Modes:**
- **Strict:** Reject all verification failures (default)
- **Warning:** Log failure but execute command
- **Disabled:** Skip verification entirely
**Fallback Chain:** v3 (agent_id + timestamp) → v2 (timestamp only) → oldest (no timestamp)
```
**Verification Modes:**
- **Strict:** Reject all verification failures (default)
- **Warning:** Log failure but execute command
- **Disabled:** Skip verification entirely
---
## Binary Verification
**Method:** Verify checksum and Ed25519 signature before installation
**File:** `agent/internal/orchestrator/update_handler.go`
```go
func (h *UpdateHandler) verifyDownload(binaryData []byte, checksum string) error {
// 1. Verify checksum
computedChecksum := sha256.Sum256(binaryData)
if hex.EncodeToString(computedChecksum[:]) != checksum {
return errors.New("checksum mismatch")
}
// 2. Verify Ed25519 signature
signature, err := h.downloadBinarySignature()
if err != nil {
return err
}
publicKey, err := h.LoadCachedPublicKey()
if err != nil {
return err
}
if !ed25519.Verify(publicKey, binaryData, []byte(signature)) {
return errors.New("signature verification failed")
}
return nil
}
```
---
## Cross-References
- **Server signing** → [verification/01-signing-pipeline](01-signing-pipeline.md)
- **Key rotation** → [verification/03-key-rotation](03-key-rotation.md)
- **Replay protection** → [verification/04-replay-protection](04-replay-protection.md)
- **Install script** → [flows/01-registration](../flows/01-registration.md) (TOFU key caching)
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,138 @@
# Key Rotation Support
**Ed25519 signing key rotation without downtime.**
---
## Overview
RedFlag supports rotating the server's Ed25519 signing key while maintaining agent trust continuity via the TOFU (Trust On First Use) model.
---
## Key Storage
**Server-side:**
- Private key: `REDFLAG_SIGNING_PRIVATE_KEY` (env var or Docker secret)
- Public key: Stored in database table `server_public_keys`
- Key format: Ed25519 (32-byte curve25519)
**Agent-side:**
- Public key cached at registration (`~/.config/redflag/server_keys.json`)
- Cached keys validated against active keys in database
- TOFU: First key accepted becomes trusted forever
---
## Rotation Process
### Server-Side Rotation
```
1. Generate new key pair (Ed25519)
2. INSERT new public key into server_public_keys (status = 'pending')
3. Update server config to use new private key
4. Sign next command with new key
5. Agents verify signature against new key (fails old key check)
6. Agents accept new key as valid (TOFU update)
7. Old key marked deprecated in database
8. Old keys removed after grace period
```
**Implementation:**
- `server/internal/services/signing.go:SetPrimaryKey()`
- `server/internal/database/migrations/035_server_public_keys.up.sql`
---
## Agent Verification Flow
```
1. Agent receives signed command
2. Extract public key from signature (if v3 format)
3. Check if key exists in cached trusted keys
4. If not cached → fetch from server_public_keys table
5. Validate key signature against command
6. Accept key as trusted (TOFU)
7. Update local cache
```
**Implementation:**
- `agent/internal/crypto/pubkey.go` (TOFU key caching)
- `agent/internal/crypto/verification.go:VerifyCommandWithTimestamp()`
---
## Key Lifecycle States
| State | Description | Database Field |
|-------|-------------|----------------|
| `active` | Currently signing commands | `status = 'active'` |
| `pending` | Newly added, awaiting agent adoption | `status = 'pending'` |
| `deprecated` | Old key, still accepted | `status = 'deprecated'` |
| `revoked` | Compromised or removed key | `status = 'revoked'` |
---
## Security Considerations
### During Rotation
- Old key remains `active` until new key is confirmed by agents
- Grace period prevents agents from being orphaned
- No downtime for command dispatch
### Key Compromise
- Immediately revoke compromised key
- Force agents to re-fetch public key list
- Affected commands can be replayed until key revocation propagates
### Forward-Only Enforcement at the Agent Key Path
A rotated-out or revoked key must stop being trusted. Two agent-side rules make
that hold even when the agent cannot reach the server (SEC-028):
- **Bounded stale cache.** When the public-key fetch fails (network down), the
agent keeps serving its last cached key only within a bounded staleness
window. Past the window — or when the cache age cannot be established (no
metadata sidecar) — it fails closed rather than trusting a possibly
rotated-out key indefinitely. Acceptance within the window is surfaced at
ERROR, not WARNING: it is a degraded-trust state, not routine.
- The **window length** is operator policy: security setting
`command_signing.stale_key_max_age_hours` (default 168h / 7d), delivered
fleet-wide via `GET /api/v1/agents/:id/config` and overridable per
deployment for sites with long offline windows.
- The **existence of a fail-closed ceiling is doctrine, not a knob.** The
agent clamps any configured value to `[1h, 30d]` (`SetStaleKeyMaxAge`) and
the server rejects out-of-range writes (1–720h validation). No setting and
no tampered local config can disable the ceiling or set it to infinite.
- **Active-set refusal.** When a command names a `key_id` the server's active
set does not contain, the agent refuses verification (returns an error that
the command handler surfaces as a verification failure) instead of falling
back to the primary cached key. A key the server has rotated out is dead.
**Implementation:**
- `agent/internal/crypto/pubkey.go` (`FetchAndCacheServerPublicKey`, `SetStaleKeyMaxAge`)
- `agent/internal/crypto/verification.go:CheckKeyRotation()`
- `server/internal/services/security_settings_service.go` (`command_signing.stale_key_max_age_hours` default + validation)
### TOFU Limitations
- Compromised initial key → all future keys trusted
- Mitigation: Monitor agent registration patterns
- Mitigation: Periodic key rotation limits exposure window
---
## Footer: Assumptions & Connections
**Assumption:** Key rotation is a rare operation — expected once per year or less.
**Connection:** Rotation process (`verification/03-key-rotation.md`) complements TOFU caching (`verification/02-agent-verification.md`).
**Connection:** `server_public_keys` table enables rotation without code changes.
**Connection:** Agent-side key caching (`agent/internal/crypto/pubkey.go`) implements TOFU.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,129 @@
# Replay Protection
**Multi-layer defense against command replay attacks.**
---
## Overview
RedFlag implements defense-in-depth against replay attacks at three layers:
1. **Nonce validation** (temporal binding for update commands)
2. **Command deduplication** (disk-based ID tracking)
3. **Timestamp validation** (command age limits)
---
## Layer 1: Update Nonce Validation
**Purpose:** Bind update commands to specific time window.
**Mechanism:**
- Server generates nonce with max age = 2× check-in interval
- Nonce embedded in `update_agent` command signature
- Agent must present valid nonce within expiry window
- Server validates nonce age before execution
**Implementation:**
- Server: `server/internal/services/update_nonce.go`
- Validation: `server/internal/middleware/machine_binding.go:validateNonce()`
- Nonce expiry: 2× `check_in_interval` (default 10 minutes)
**Security Model:**
```
Command dispatched → Nonce generated (age=0)
Agent receives → Nonce cached (age < maxAge/2)
Agent executes → Nonce validated (age < maxAge)
Command executed → Nonce consumed (single-use)
```
---
## Layer 2: Command Deduplication
**Purpose:** Prevent duplicate execution after agent restart.
**Mechanism:**
- Executed command IDs persisted to `executed_commands.json`
- 4-hour max age window (aligns with command TTL)
- Atomic writes prevent corruption
**Implementation:**
- Storage: `agent/internal/orchestrator/executed_commands.json`
- Load: `loadExecutedCommands()`
- Add: `executedIDs.Add(cmd.ID)`
- Save: `saveExecutedCommands(executedIDs)`
**Deduplication Flow:**
```
Command received → Check executed IDs
If duplicate → Reject with security event
If unique → Add to executed IDs
Execute command
```
---
## Layer 3: Timestamp Validation
**Purpose:** Reject stale commands regardless of nonce.
**Mechanism:**
- Commands must have `created_at` within TTL window
- Default TTL: 4 hours
- Server rejects commands older than TTL
**Implementation:**
- Validation: `server/internal/handlers/agents.go:validateCommandTimestamp()`
- TTL: 4 hours (configurable via `security_settings.command_ttl_hours`)
---
## Combined Protection Flow
```
┌─────────────────────────────────────────────────────────────┐
│ Command Execution │
├─────────────────────────────────────────────────────────────┤
│ 1. Agent polls server for commands │
│ 2. Server returns signed command with nonce │
│ 3. Agent validates nonce age (Layer 1) │
│ 4. Agent checks executed_commands.json (Layer 2) │
│ 5. Agent executes command │
│ 6. Agent records command ID in executed_commands.json │
│ 7. Command times out (Layer 3) → rejected on retry │
└─────────────────────────────────────────────────────────────┘
```
---
## Circuit Breaker Integration
**Purpose:** Prevent replay during scanner failures.
**Mechanism:**
- Circuit breaker per subsystem (APT, DNF, Winget, WUA, Docker)
- Opens after N failures in T window
- Blocks all scanner commands while open
**Implementation:**
- `agent/internal/circuitbreaker/circuitbreaker.go`
- Failure threshold: 5 failures in 60 seconds
- Open duration: 5 minutes
---
## Footer: Assumptions & Connections
**Assumption:** Replay attacks are rare — protection is defense-in-depth, not primary security.
**Connection:** Nonce validation (`verification/04-replay-protection.md`) complements machine binding (`security/02-authentication-stack.md`).
**Connection:** Command deduplication (`verification/04-replay-protection.md`) implements ETHOS #4 (idempotency).
**Connection:** Circuit breaker (`verification/04-replay-protection.md`) implements ETHOS #3 (assume failure).
**Connection:** Nonce service (`server/internal/services/update_nonce.go`) ties to security settings (`security_settings.operational`).
---
*Last reviewed: 2026-05-26*