Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/RAF/security/02-authentication-stack.md

6 KiB

Authentication Stack

Four-layer authentication: registration tokens → JWT → refresh tokens → machine binding.


Layer 1: Registration Tokens

Purpose: One-time enrollment tokens

Format: Random 64-character hex string

Lifecycle:

  1. Server generates token with max_seats count
  2. Admin distributes token to operators
  3. Agent uses token to register
  4. Server marks token as used and increments seats_used
  5. Token is revoked after use

Endpoint: POST /api/v1/agents/register

Request:

{
  "hostname": "server-01",
  "os_type": "linux",
  "os_version": "6.19",
  "machine_id": "sha256-fingerprint...",
  "public_key": "ed25519-public-key...",
  "available_scanners": ["apt", "dnf", "docker"]
}

Response:

{
  "agent_id": "uuid-4",
  "jwt_token": "...",
  "refresh_token": "...",
  "server_public_key": "..."
}

Cross-references:


Layer 2: JWT Access Tokens

Purpose: Short-lived access tokens for API calls

Issuer: "redflag-agent" for agents, "redflag-web" for web

Duration: 24 hours

Algorithm: HS256

Claims:

{
  "sub": "agent-uuid",
  "iss": "redflag-agent",
  "exp": 1234567890,
  "iat": 1234567890
}

Validation:

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
            c.Abort()
            return
        }

        tokenString := strings.TrimPrefix(authHeader, "Bearer ")
        if tokenString == authHeader {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"})
            c.Abort()
            return
        }

        token, err := jwt.ParseWithClaims(tokenString, &AgentClaims{}, func(token *jwt.Token) (interface{}, error) {
            return []byte(JWTSecret), nil
        })

        if err != nil || !token.Valid {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
            c.Abort()
            return
        }

        if claims, ok := token.Claims.(*AgentClaims); ok {
            // Validate issuer to prevent cross-type token confusion
            if claims.Issuer != "" && claims.Issuer != JWTIssuerAgent {
                c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token type"})
                c.Abort()
                return
            }
            c.Set("agent_id", claims.AgentID)
            c.Next()
        } else {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
            c.Abort()
        }
    }
}

Cross-references:


Layer 3: Refresh Tokens

Purpose: Long-lived authentication for polling, with rotation and reuse detection

Format: 64-character hex string (32 bytes crypto/rand)

Storage: SHA-256 hash in database; rotation lineage via family_id, consumed_at, superseded_by (migration 045)

Duration: 90 days (bumped on each renewal, not on every check-in)

Lifecycle:

  1. Generated at registration with a fresh family_id (root of the rotation chain)
  2. Agent calls POST /renew only when its JWT expires (~24h) — not on every poll
  3. Each renewal mints a successor token in the same family and marks the parent consumed
  4. Server returns the new refresh token alongside the new JWT; agent persists it to config.json
  5. Accept-previous-once grace: an agent that crashed before persisting the new token can retry with the consumed old one — the server sees the successor is still unconsumed and re-issues
  6. Reuse detection: a consumed token presented after its successor is also consumed → entire family revoked, security event logged, both parties locked out

Machine binding: Renewal requires X-Machine-ID to match the registered host (same as command endpoints). A stolen config.json cannot mint access tokens from an unregistered machine.

Instance lock: A flock (Unix) or named kernel mutex (Windows) prevents two agent processes from sharing the same config.json on the same host, serializing renewal at the process level.

Endpoint: POST /api/v1/agents/renew

Request:

{
  "agent_id": "uuid",
  "refresh_token": "64-char-hex",
  "agent_version": "0.2.0.7"
}

Headers: X-Machine-ID (required), Content-Type: application/json

Response:

{
  "token": "new-jwt...",
  "refresh_token": "new-64-char-hex..."
}

The agent must persist refresh_token to disk; if it crashes before doing so, accept-previous-once grace recovers on the next attempt.

Cross-references:


Layer 4: Machine Binding

Purpose: Bind JWT to specific hardware

Method: SHA-256 hash of machine-id + hostname (no boot-id)

Validation: Middleware checks X-Machine-ID header matches DB

Failure modes:

  • Machine ID mismatch → 403 Forbidden
  • Agent row deleted → 401 Unauthorized
  • Update in progress → Validates nonce

Middleware: server/internal/api/middleware/machine_binding.go

Cross-references:


Assumption: Trust On First Use (TOFU) model — agent caches server public key at registration and uses it for all future verification.

Connection: security/01-trust-boundaries (trust boundary matrix)

Connection: security/04-machine-binding (hardware-bound auth)

Connection: verification/01-signing-pipeline (Ed25519 signing)

Connection: verification/02-agent-verification (command verification)


Last reviewed: 2026-05-26

Last reviewed: 2026-05-26