Watch
1
0
Fork
You've already forked RedFlag
0

feat: machine-bound token renewal + refresh-token rotation with reuse detection

Bind /renew to the registered machine so a stolen refresh token can't mint
tokens from another host. Rotate the refresh token on every renewal; replaying
a consumed token whose successor is also consumed revokes the family. Accept-
previous-once grace covers agent crash-before-save. Typed auth errors so the
polling loop renews on 401 and treats refresh/machine failures as terminal.
This commit is contained in:
Fimeg 2026-05-29 10:47:48 -04:00
commit e8afcc7994
7 changed files with 293 additions and 32 deletions

View file

@ -2,11 +2,11 @@ package agent
import (
"context"
"errors"
"fmt"
"log"
"math/rand"
"os"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
@ -246,18 +246,41 @@ func RunPollingLoop(loopCtx *LoopContext) error {
// Get commands from server
response, err := ctx.APIClient.GetCommands(ctx.Cfg.AgentID, metrics)
if err != nil {
if strings.Contains(err.Error(), "401 Unauthorized") && ctx.Cfg.RefreshToken != "" {
if errors.Is(err, client.ErrMachineMismatch) {
// Terminal: the server no longer recognizes this host as the one the
// agent registered on — config moved or copied. Renewal can't fix it
// (renewal is now machine-bound too), so don't even try. Surface loudly;
// the client already buffered a critical machine_binding_rejected event.
// We keep polling rather than exit, so the agent stays visible and
// self-heals the moment an operator rebinds it server-side.
log.Printf("[ERROR] [agent] [auth] machine_id_mismatch identity_moved_or_copied re_registration_required agent_id=%s", ctx.Cfg.AgentID)
} else if errors.Is(err, client.ErrUnauthorized) && ctx.Cfg.RefreshToken != "" {
log.Printf("[INFO] [agent] [auth] jwt_expired attempting_renewal agent_id=%s", ctx.Cfg.AgentID)
if renewErr := ctx.APIClient.RenewToken(ctx.Cfg.AgentID, ctx.Cfg.RefreshToken, version.Version); renewErr != nil {
log.Printf("[ERROR] [agent] [auth] token_renewal_failed error=%v", renewErr)
} else {
renewErr := ctx.APIClient.RenewToken(ctx.Cfg.AgentID, ctx.Cfg.RefreshToken, version.Version)
switch {
case renewErr == nil:
ctx.Cfg.Token = ctx.APIClient.GetToken()
// The refresh token rotates on each renewal (migration 045) — persist
// the new one or the next renewal will look like a replay. If the Save
// fails here, the server's accept-previous-once grace recovers us on
// the next attempt with the old token still on disk.
if rt := ctx.APIClient.GetRefreshToken(); rt != "" {
ctx.Cfg.RefreshToken = rt
}
if saveErr := ctx.Cfg.Save(constants.GetAgentConfigPath()); saveErr != nil {
log.Printf("[WARNING] [agent] [auth] token_persist_failed error=%v", saveErr)
}
log.Printf("[INFO] [agent] [auth] token_renewed_successfully")
consecutiveFailures = 0
continue
case errors.Is(renewErr, client.ErrRefreshTokenInvalid):
// Terminal: the refresh token is dead. Backing off won't help —
// the agent needs re-registration. Surface it loudly; the client
// already buffered a critical refresh_token_invalid event.
log.Printf("[ERROR] [agent] [auth] refresh_token_invalid re_registration_required agent_id=%s error=%v", ctx.Cfg.AgentID, renewErr)
default:
// Transient renewal failure (network, 502). Fall through to backoff and retry.
log.Printf("[ERROR] [agent] [auth] token_renewal_failed error=%v", renewErr)
}
}
consecutiveFailures++

View file

@ -3,6 +3,7 @@ package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@ -21,6 +22,26 @@ import (
"github.com/google/uuid"
)
// Auth sentinel errors. The polling loop branches on these via errors.Is rather
// than matching status text, so a change in error formatting can't silently
// disable token renewal.
//
// - ErrUnauthorized: the access token (JWT) was rejected — expected at the
// 24h expiry boundary. Recoverable: renew with the refresh token.
// - ErrRefreshTokenInvalid: the refresh token itself was rejected (expired,
// revoked, or machine unbound). Terminal — no auto-recovery, the agent
// must be re-registered. Distinct so operators can tell it apart from a
// transient renewal failure (network, 502).
var (
ErrUnauthorized = errors.New("unauthorized: access token rejected")
ErrRefreshTokenInvalid = errors.New("unauthorized: refresh token rejected")
// ErrMachineMismatch: the server rejected us because our machine ID doesn't
// match the one this agent registered with (403). Terminal — this identity
// has been moved or copied to another host. Renewing won't help; a human must
// re-register. Distinct so the loop can alarm instead of silently retrying.
ErrMachineMismatch = errors.New("forbidden: machine ID mismatch")
)
// Client handles API communication with the server
type Client struct {
baseURL string
@ -29,6 +50,7 @@ type Client struct {
RapidPollingEnabled bool
RapidPollingUntil time.Time
machineID string // Cached machine ID for security binding
refreshToken string // Most recent refresh token (rotated on each renew, migration 045)
eventBuffer *event.Buffer
agentID uuid.UUID
}
@ -175,6 +197,12 @@ func (c *Client) SetToken(token string) {
c.token = token
}
// GetRefreshToken returns the most recent refresh token. Empty unless a renewal
// has rotated one this process lifetime; the caller persists it to config.
func (c *Client) GetRefreshToken() string {
return c.refreshToken
}
// DownloadAuthenticatedToFile fetches a relative or absolute URL using the agent's
// JWT + machine binding and streams the body into dstPath. maxBytes caps the size to
// guard against a misbehaving server filling the disk. Returns the number of bytes
@ -358,7 +386,8 @@ type TokenRenewalRequest struct {
// TokenRenewalResponse is returned after successful token renewal
type TokenRenewalResponse struct {
Token string `json:"token"` // New short-lived access token (24h)
Token string `json:"token"` // New short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Rotated refresh token (migration 045) — must be persisted
}
// RenewToken uses refresh token to get a new access token (proper implementation)
@ -395,6 +424,7 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
return err
}
httpReq.Header.Set("Content-Type", "application/json")
c.addMachineIDHeader(httpReq) // Renewal is machine-bound: a refresh token only works from the registered host.
resp, err := c.http.Do(httpReq)
if err != nil {
@ -414,8 +444,20 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
bodyBytes, _ := io.ReadAll(resp.Body)
errorMsg := fmt.Sprintf("token renewal failed: %s - %s", resp.Status, string(bodyBytes))
// Buffer token renewal failure event
c.bufferEventInternal("token_renewal_failure", "api_error", "error", "client",
// A 401/403 on the renew endpoint means the refresh token itself is no
// longer valid (expired, revoked, or machine unbound). That's terminal —
// no amount of retrying recovers it, the agent must be re-registered.
// Emit a distinct, higher-severity event so it isn't lost among transient
// renewal failures, and wrap the terminal sentinel so the loop can react.
terminal := resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
subtype := "api_error"
severity := "error"
if terminal {
subtype = "refresh_token_invalid"
severity = "critical"
}
c.bufferEventInternal("token_renewal_failure", subtype, severity, "client",
errorMsg,
map[string]interface{}{
"status_code": resp.StatusCode,
@ -423,6 +465,10 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
"agent_id": agentID.String(),
"server_url": c.baseURL,
})
if terminal {
return fmt.Errorf("%w: %s", ErrRefreshTokenInvalid, string(bodyBytes))
}
return fmt.Errorf("%s", errorMsg)
}
@ -438,8 +484,14 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
return err
}
// Update client token
// Update client token, and the rotated refresh token if the server sent one.
// The caller reads GetRefreshToken() and persists it to config; if persistence
// fails or the agent crashes first, the server's accept-previous-once grace
// lets the next attempt with the old token recover.
c.token = result.Token
if result.RefreshToken != "" {
c.refreshToken = result.RefreshToken
}
return nil
}
@ -539,6 +591,22 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) (*Comman
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, string(bodyBytes))
}
if resp.StatusCode == http.StatusForbidden {
// Machine binding rejected us — this config is being used from a host
// it wasn't registered on. Loud, not silent: buffer a critical event so
// the operator sees a possible cloned identity, not just a backoff line.
c.bufferEventInternal("machine_binding_rejected", "machine_id_mismatch", "critical", "client",
fmt.Sprintf("Server rejected check-in with 403 (machine ID mismatch): %s", string(bodyBytes)),
map[string]interface{}{
"status_code": resp.StatusCode,
"agent_id": agentID.String(),
"server_url": c.baseURL,
})
return nil, fmt.Errorf("%w: %s", ErrMachineMismatch, string(bodyBytes))
}
return nil, fmt.Errorf("failed to get commands: %s - %s", resp.Status, string(bodyBytes))
}

View file

@ -413,8 +413,9 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
}
refreshTokenExpiry := time.Now().UTC().Add(90 * 24 * time.Hour)
tokenHash := queries.HashRefreshToken(refreshToken)
if _, err := tx.Exec("INSERT INTO refresh_tokens (agent_id, token_hash, expires_at) VALUES ($1, $2, $3)",
agent.ID, tokenHash, refreshTokenExpiry); err != nil {
refreshFamilyID := uuid.New() // root of this agent's rotation family (migration 045)
if _, err := tx.Exec("INSERT INTO refresh_tokens (agent_id, token_hash, expires_at, family_id) VALUES ($1, $2, $3, $4)",
agent.ID, tokenHash, refreshTokenExpiry, refreshFamilyID); err != nil {
log.Printf("[ERROR] [server] [registration] create_refresh_token_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store refresh token"})
return
@ -1276,17 +1277,87 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
}
defer renewTx.Rollback()
// Validate refresh token within transaction
// Look up the presented token. We deliberately do NOT filter on `revoked` or
// `consumed_at` here: reuse detection needs to see a spent token to react to it.
tokenHash := queries.HashRefreshToken(req.RefreshToken)
var refreshToken queries.RefreshToken
validateQuery := `
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked,
family_id, superseded_by, consumed_at
FROM refresh_tokens
WHERE agent_id = $1 AND token_hash = $2 AND NOT revoked
WHERE agent_id = $1 AND token_hash = $2
`
if err := renewTx.Get(&refreshToken, validateQuery, req.AgentID, tokenHash); err != nil {
log.Printf("[WARNING] [server] [auth] token_renewal_failed agent_id=%s error=%v", req.AgentID, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
// Bind renewal to the machine the agent registered on, exactly as the command
// endpoints do. The refresh token is a long-lived secret sitting on the agent's
// disk; without this check it mints access tokens from *any* machine, so a
// stolen config.json works anywhere for 90 days. A mismatch is a copied-identity
// signal, not a transient error — we reject and surface it before sliding the
// window or minting a token.
reportedMachineID := c.GetHeader("X-Machine-ID")
if reportedMachineID == "" {
log.Printf("[WARNING] [server] [auth] renew_missing_machine_id agent_id=%s", req.AgentID)
c.JSON(http.StatusForbidden, gin.H{"error": "missing machine ID header"})
return
}
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
if agent.MachineID == nil {
log.Printf("[WARNING] [server] [auth] renew_agent_unbound agent_id=%s", req.AgentID)
c.JSON(http.StatusForbidden, gin.H{"error": "agent not bound to machine - re-registration required"})
return
}
if *agent.MachineID != reportedMachineID {
log.Printf("[WARNING] [server] [auth] renew_machine_id_mismatch agent=%s (%s) db=%s reported=%s",
agent.Hostname, req.AgentID, *agent.MachineID, reportedMachineID)
if h.securityLogger != nil {
h.securityLogger.LogMachineIDMismatch(req.AgentID, *agent.MachineID, reportedMachineID)
}
c.JSON(http.StatusForbidden, gin.H{
"error": "machine ID mismatch - refresh token presented from a different machine",
"security_note": "renewal is bound to the registered machine; this prevents stolen-token replay",
})
return
}
// --- Refresh-token rotation + reuse detection (migration 045) ---
// familyID groups the rotation chain; fall back to the token's own id for
// pre-rotation tokens that predate the 045 backfill.
familyID := refreshToken.ID
if refreshToken.FamilyID != nil {
familyID = *refreshToken.FamilyID
}
// revokeFamily kills every token in the chain, commits, and logs a security
// event. Used on reuse/theft and on any revoked-token replay against a live
// family. Fail-closed and loud — both the legitimate agent and a thief lose
// access, forcing a deliberate human re-registration.
revokeFamily := func(reason string) {
if _, e := renewTx.Exec("UPDATE refresh_tokens SET revoked = true WHERE family_id = $1", familyID); e != nil {
log.Printf("[ERROR] [server] [auth] family_revoke_failed family_id=%s error=%q", familyID, e)
}
if e := renewTx.Commit(); e != nil {
log.Printf("[ERROR] [server] [auth] family_revoke_commit_failed family_id=%s error=%q", familyID, e)
}
log.Printf("[WARNING] [server] [auth] refresh_token_family_revoked agent_id=%s family_id=%s reason=%s", req.AgentID, familyID, reason)
if h.securityLogger != nil {
h.securityLogger.LogUnauthorizedAccessAttempt(c.ClientIP(), "/api/v1/agents/renew", "refresh_token_reuse: "+reason, req.AgentID)
}
}
// A revoked token presented while the family is alive is anomalous → reuse.
if refreshToken.Revoked {
revokeFamily("revoked_token_replayed")
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token revoked"})
return
}
if time.Now().UTC().After(refreshToken.ExpiresAt) {
@ -1294,13 +1365,84 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
return
}
// Update expiration within same transaction
newExpiry := time.Now().UTC().Add(90 * 24 * time.Hour)
if _, err := renewTx.Exec("UPDATE refresh_tokens SET expires_at = $1, last_used_at = NOW() WHERE id = $2",
newExpiry, refreshToken.ID); err != nil {
log.Printf("[ERROR] [server] [auth] update_expiration_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
return
// mintSuccessor inserts a fresh token into the family and returns its plaintext + id.
mintSuccessor := func() (string, uuid.UUID, error) {
plain, gErr := queries.GenerateRefreshToken()
if gErr != nil {
return "", uuid.Nil, gErr
}
exp := time.Now().UTC().Add(90 * 24 * time.Hour)
var newID uuid.UUID
if sErr := renewTx.QueryRowx(
`INSERT INTO refresh_tokens (agent_id, token_hash, expires_at, family_id)
VALUES ($1, $2, $3, $4) RETURNING id`,
req.AgentID, queries.HashRefreshToken(plain), exp, familyID,
).Scan(&newID); sErr != nil {
return "", uuid.Nil, sErr
}
return plain, newID, nil
}
var newRefreshToken string
if refreshToken.ConsumedAt == nil {
// First use of this token — normal rotation: mint a successor and mark spent.
plain, newID, mErr := mintSuccessor()
if mErr != nil {
log.Printf("[ERROR] [server] [auth] mint_successor_failed error=%q", mErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
return
}
if _, uErr := renewTx.Exec(
"UPDATE refresh_tokens SET consumed_at = NOW(), superseded_by = $1, last_used_at = NOW() WHERE id = $2",
newID, refreshToken.ID); uErr != nil {
log.Printf("[ERROR] [server] [auth] consume_token_failed error=%q", uErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
return
}
newRefreshToken = plain
} else {
// Replay of an already-consumed token. Inspect its successor to tell a
// benign crash-before-save retry from genuine reuse.
if refreshToken.SupersededBy == nil {
revokeFamily("consumed_without_successor")
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token reuse detected"})
return
}
var successor queries.RefreshToken
succErr := renewTx.Get(&successor,
`SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked,
family_id, superseded_by, consumed_at
FROM refresh_tokens WHERE id = $1`, *refreshToken.SupersededBy)
if succErr != nil || successor.Revoked || successor.ConsumedAt != nil {
// Successor is missing, revoked, or already consumed → the chain advanced
// past this token without us. Reuse of a superseded token: revoke family.
revokeFamily("successor_consumed_or_missing")
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token reuse detected"})
return
}
// Successor exists and is still unconsumed → the agent rotated but crashed
// before persisting the new token (it provably never used the successor).
// Accept-previous-once: orphan that unsaved leaf, mint a fresh one, re-point.
if _, rErr := renewTx.Exec("UPDATE refresh_tokens SET revoked = true WHERE id = $1", successor.ID); rErr != nil {
log.Printf("[ERROR] [server] [auth] grace_orphan_failed error=%q", rErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
return
}
plain, newID, mErr := mintSuccessor()
if mErr != nil {
log.Printf("[ERROR] [server] [auth] grace_mint_failed error=%q", mErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
return
}
if _, uErr := renewTx.Exec(
"UPDATE refresh_tokens SET superseded_by = $1, last_used_at = NOW() WHERE id = $2",
newID, refreshToken.ID); uErr != nil {
log.Printf("[ERROR] [server] [auth] grace_repoint_failed error=%q", uErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
return
}
log.Printf("[WARNING] [server] [auth] refresh_token_grace_reissue agent_id=%s family_id=%s reason=crash_before_save_recovery", req.AgentID, familyID)
newRefreshToken = plain
}
if err := renewTx.Commit(); err != nil {
@ -1309,13 +1451,6 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
return
}
// Check if agent still exists (outside transaction)
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
return
}
// Update agent last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(req.AgentID); err != nil {
log.Printf("[WARNING] [server] [auth] update_last_seen_failed agent_id=%s error=%v", req.AgentID, err)
@ -1337,9 +1472,12 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
log.Printf("[INFO] [server] [agents]Token renewed successfully for agent %s (%s)", agent.Hostname, req.AgentID)
// Return new access token
// Return new access token + the rotated refresh token. The agent must persist
// the refresh token; if it crashes before doing so, the accept-previous-once
// grace above lets it recover on the next attempt.
response := models.TokenRenewalResponse{
Token: token,
Token: token,
RefreshToken: newRefreshToken,
}
c.JSON(http.StatusOK, response)

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_refresh_tokens_family;
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS consumed_at;
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS superseded_by;
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS family_id;

View file

@ -0,0 +1,20 @@
-- Refresh-token rotation + reuse detection.
--
-- Each refresh token belongs to a family. A renewal mints a successor token in
-- the same family and marks the parent "consumed" (consumed_at + superseded_by).
-- Replaying a consumed token whose successor is ALSO consumed means the chain
-- advanced past it without this caller — i.e. a stolen/duplicated token is being
-- replayed. That is reuse: the whole family is revoked (fail-closed), forcing
-- re-registration. A consumed token whose successor is still UNCONSUMED is the
-- benign crash-before-save case (accept-previous-once grace).
--
-- See RAF/security/01-trust-boundaries.md for the full state machine.
ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS family_id UUID;
ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS superseded_by UUID;
ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS consumed_at TIMESTAMPTZ;
-- Backfill: every pre-rotation token is the root of its own family.
UPDATE refresh_tokens SET family_id = id WHERE family_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family ON refresh_tokens(family_id);

View file

@ -28,6 +28,13 @@ type RefreshToken struct {
CreatedAt time.Time `db:"created_at"`
LastUsedAt *time.Time `db:"last_used_at"`
Revoked bool `db:"revoked"`
// Rotation lineage (migration 045). FamilyID groups a rotation chain;
// SupersededBy points at the successor minted when this token was used;
// ConsumedAt is set the moment this token mints a successor. A consumed token
// replayed after its successor is also consumed = reuse → family revoked.
FamilyID *uuid.UUID `db:"family_id"`
SupersededBy *uuid.UUID `db:"superseded_by"`
ConsumedAt *time.Time `db:"consumed_at"`
}
// GenerateRefreshToken creates a cryptographically secure random token

View file

@ -108,7 +108,8 @@ type TokenRenewalRequest struct {
// TokenRenewalResponse is returned after successful token renewal
type TokenRenewalResponse struct {
Token string `json:"token"` // New short-lived access token (24h)
Token string `json:"token"` // New short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Rotated refresh token (migration 045) — agent must persist it
}
// UTCTime is a time.Time that marshals to ISO format with UTC timezone