Watch
1
0
Fork
You've already forked RedFlag
0

SEC-025: fleet-join 2FA — TOTP validation, host-generated seed, migration 057

This commit is contained in:
Fimeg 2026-06-11 13:37:17 -04:00
commit 9c43698b00
7 changed files with 454 additions and 11 deletions

View file

@ -0,0 +1,219 @@
// SEC-025: Fleet-join endpoint. A standalone host calls this to migrate to
// fleet mode. Two-factor: registration token (server-originated) + TOTP code
// (host-originated seed). On success the agent is registered and the server
// returns its authority key(s) so the host can retire its local authority.
package handlers
import (
"log"
"net/http"
"time"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/security"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
)
// FleetJoinHandler handles standalone-to-fleet migration (SEC-025).
type FleetJoinHandler struct {
db *sqlx.DB
tokenQueries *queries.RegistrationTokenQueries
agentQueries *queries.AgentQueries
signingPublicKey string
}
// NewFleetJoinHandler creates a FleetJoinHandler.
func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, signingPublicKey string) *FleetJoinHandler {
return &FleetJoinHandler{
db: db,
tokenQueries: tokenQueries,
agentQueries: agentQueries,
signingPublicKey: signingPublicKey,
}
}
// FleetJoinRequest is the standalone host's join-fleet submission.
type FleetJoinRequest struct {
RegistrationToken string `json:"registration_token" binding:"required"`
TOTPCode string `json:"totp_code" binding:"required"`
TOTPSeed string `json:"totp_seed" binding:"required"`
// Standard agent registration fields (same as RegisterAgent).
Hostname string `json:"hostname" binding:"required"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
MachineID string `json:"machine_id"`
PublicKeyFingerprint string `json:"public_key_fingerprint"`
AvailableScanners []string `json:"available_scanners"`
Metadata map[string]interface{} `json:"metadata"`
}
// FleetJoinResponse is the server's response on successful fleet join.
type FleetJoinResponse struct {
AgentID uuid.UUID `json:"agent_id"`
RefreshToken string `json:"refresh_token"`
SigningPublicKey string `json:"signing_public_key"`
ServerURL string `json:"server_url"`
}
// JoinFleet handles POST /api/v1/fleet-join. Validates the registration token
// and TOTP code, registers the agent, and returns the server's authority key.
func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
var req FleetJoinRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Step 1: Validate registration token.
tokenInfo, err := h.tokenQueries.ValidateRegistrationToken(req.RegistrationToken)
if err != nil || tokenInfo == nil {
log.Printf("[SECURITY] [server] [fleet-join] invalid_token error=%v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired registration token"})
return
}
// Step 2: Check that this token requires TOTP (fleet-join tokens only).
if tokenInfo.TOTPSeedHash == nil {
log.Printf("[SECURITY] [server] [fleet-join] no_totp_required token_id=%s", tokenInfo.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "this token does not require 2FA — use the standard registration endpoint"})
return
}
// Step 3: Validate the TOTP seed. The host sends the plaintext seed;
// we hash it and compare against what's stored on the token.
seedHash := security.HashTOTPSeed(req.TOTPSeed)
if seedHash != *tokenInfo.TOTPSeedHash {
log.Printf("[SECURITY] [server] [fleet-join] seed_mismatch token_id=%s", tokenInfo.ID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "TOTP seed does not match this registration token"})
return
}
// Step 4: Validate the TOTP code against the seed.
if !security.ValidateTOTPCode(req.TOTPSeed, req.TOTPCode) {
log.Printf("[SECURITY] [server] [fleet-join] invalid_totp token_id=%s", tokenInfo.ID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid TOTP code"})
return
}
// Step 5: Check machine ID isn't already registered (same as RegisterAgent).
if req.MachineID != "" {
existing, err := h.agentQueries.GetAgentByMachineID(req.MachineID)
if err == nil && existing != nil && existing.ID.String() != "" {
c.JSON(http.StatusConflict, gin.H{
"error": "machine ID already registered to another agent",
"existing_agent_id": existing.ID.String(),
})
return
}
}
// Step 6: Register the agent (same transaction pattern as RegisterAgent).
tx, err := h.db.Beginx()
if err != nil {
log.Printf("[ERROR] [server] [fleet-join] tx_begin_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
return
}
defer tx.Rollback()
agent := &models.Agent{
ID: uuid.Must(uuid.NewV4()),
Hostname: req.Hostname,
OSType: req.OSType,
OSVersion: req.OSVersion,
OSArchitecture: req.OSArchitecture,
AgentVersion: req.AgentVersion,
CurrentVersion: req.AgentVersion,
MachineID: &req.MachineID,
PublicKeyFingerprint: &req.PublicKeyFingerprint,
LastSeen: time.Now().UTC(),
Status: "online",
Metadata: models.JSONB{},
}
if req.Metadata != nil {
for k, v := range req.Metadata {
agent.Metadata[k] = v
}
}
createQuery := `
INSERT INTO agents (
id, hostname, os_type, os_version, os_architecture,
agent_version, current_version, machine_id, public_key_fingerprint,
last_seen, status, metadata
) VALUES (
:id, :hostname, :os_type, :os_version, :os_architecture,
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
:last_seen, :status, :metadata
)`
if _, err := tx.NamedExec(createQuery, agent); err != nil {
log.Printf("[ERROR] [server] [fleet-join] create_agent_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"})
return
}
// Mark token as used.
var tokenSuccess bool
tokenHash := queries.HashRegistrationToken(req.RegistrationToken)
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", tokenHash, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
log.Printf("[ERROR] [server] [fleet-join] mark_token_failed error=%v success=%v", err, tokenSuccess)
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed"})
return
}
// Generate refresh token.
refreshToken, err := queries.GenerateRefreshToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
return
}
refreshTokenExpiry := time.Now().UTC().Add(90 * 24 * time.Hour)
refreshTokenHash := queries.HashRefreshToken(refreshToken)
familyID := uuid.Must(uuid.NewV4())
if _, err := tx.Exec("INSERT INTO refresh_tokens (agent_id, token_hash, expires_at, family_id) VALUES ($1, $2, $3, $4)",
agent.ID, refreshTokenHash, refreshTokenExpiry, familyID); err != nil {
log.Printf("[ERROR] [server] [fleet-join] create_refresh_token_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store refresh token"})
return
}
// Create subsystems for available scanners.
if len(req.AvailableScanners) > 0 {
for _, scanner := range req.AvailableScanners {
sub := models.AgentSubsystem{
AgentID: agent.ID,
Subsystem: scanner,
Enabled: true,
AutoRun: true,
IntervalMinutes: 60,
}
subQuery := `INSERT INTO agent_subsystems (agent_id, subsystem, enabled, auto_run, interval_minutes) VALUES (:agent_id, :subsystem, :enabled, :auto_run, :interval_minutes)`
if _, err := tx.NamedExec(subQuery, sub); err != nil {
log.Printf("[WARNING] [server] [fleet-join] create_subsystem_failed scanner=%s error=%q", scanner, err)
}
}
}
if err := tx.Commit(); err != nil {
log.Printf("[ERROR] [server] [fleet-join] commit_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
return
}
log.Printf("[INFO] [server] [fleet-join] agent_registered agent_id=%s hostname=%s machine_id=%s",
agent.ID, agent.Hostname, req.MachineID)
c.JSON(http.StatusCreated, FleetJoinResponse{
AgentID: agent.ID,
RefreshToken: refreshToken,
SigningPublicKey: h.signingPublicKey,
ServerURL: c.Request.Host,
})
}

View file

@ -29,10 +29,16 @@ func NewRegistrationTokenHandler(tokenQueries *queries.RegistrationTokenQueries,
// GenerateRegistrationToken creates a new registration token
func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
var request struct {
Label string `json:"label" binding:"required"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
Metadata map[string]interface{} `json:"metadata"`
Label string `json:"label" binding:"required"`
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
Metadata map[string]interface{} `json:"metadata"`
// FleetJoin + TOTPSeedHash: SEC-025 fleet-join 2FA. When the operator
// creates a fleet-join token, they enter the host's TOTP seed (displayed
// on the standalone host). The server stores the hash and requires a
// TOTP code at join time.
FleetJoin bool `json:"fleet_join"`
TOTPSeedHash string `json:"totp_seed_hash"`
}
if err := c.ShouldBindJSON(&request); err != nil {
@ -95,8 +101,14 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
maxSeats = 1
}
// Store token in database
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, maxSeats, metadata)
// Store token in database. For fleet-join tokens (SEC-025), the operator
// provides the TOTP seed hash from the standalone host.
var totpSeedHash *string
if request.FleetJoin && request.TOTPSeedHash != "" {
totpSeedHash = &request.TOTPSeedHash
}
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, maxSeats, metadata, totpSeedHash)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"})
return

View file

@ -0,0 +1,3 @@
-- SEC-025: Reverse — drop TOTP seed hash from registration tokens.
DROP INDEX IF EXISTS idx_registration_tokens_totp_seed_hash;
ALTER TABLE registration_tokens DROP COLUMN IF EXISTS totp_seed_hash;

View file

@ -0,0 +1,11 @@
-- SEC-025: Fleet-join 2FA. Adds a TOTP seed hash to registration tokens so
-- that standalone hosts can require a second factor when joining a fleet.
-- The seed hash is nullable — only set for fleet-join tokens that require 2FA.
ALTER TABLE registration_tokens
ADD COLUMN IF NOT EXISTS totp_seed_hash TEXT;
-- Index for fleet-join lookups (rare, but keeps the join path fast).
CREATE INDEX IF NOT EXISTS idx_registration_tokens_totp_seed_hash
ON registration_tokens (totp_seed_hash)
WHERE totp_seed_hash IS NOT NULL;

View file

@ -51,6 +51,11 @@ type RegistrationToken struct {
Metadata json.RawMessage `json:"metadata" db:"metadata"`
MaxSeats int `json:"max_seats" db:"max_seats"`
SeatsUsed int `json:"seats_used" db:"seats_used"`
// TOTPSeedHash is the SHA-256 of the TOTP seed for fleet-join 2FA (SEC-025).
// Nil for standard registration tokens; set only when the operator creates
// a fleet-join token that requires the standalone host to prove physical
// presence via a TOTP code.
TOTPSeedHash *string `json:"-" db:"totp_seed_hash"`
}
// isLive reports whether a token is still usable for enrollment — the only state
@ -109,7 +114,10 @@ func NewRegistrationTokenQueries(db *sqlx.DB, encKeyB64 string) (*RegistrationTo
// The caller passes the plaintext token; the SHA-256 hash is stored for
// validation and (when a key is configured) the reversible ciphertext is stored
// for one-liner rebuilds.
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}) error {
//
// totpSeedHash is optional (SEC-025 fleet-join 2FA). When non-nil, the token
// requires a TOTP code from the host's seed at fleet-join time.
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}, totpSeedHash *string) error {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
@ -129,11 +137,11 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
tokenHash := HashRegistrationToken(token)
query := `
INSERT INTO registration_tokens (token_hash, token_encrypted, label, expires_at, max_seats, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO registration_tokens (token_hash, token_encrypted, label, expires_at, max_seats, metadata, totp_seed_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err = q.db.Exec(query, tokenHash, encrypted, label, expiresAt, maxSeats, metadataJSON)
_, err = q.db.Exec(query, tokenHash, encrypted, label, expiresAt, maxSeats, metadataJSON, totpSeedHash)
if err != nil {
return fmt.Errorf("failed to create registration token: %w", err)
}
@ -149,7 +157,7 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
query := `
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
revoked, revoked_at, revoked_reason, status, created_by, metadata,
max_seats, seats_used
max_seats, seats_used, totp_seed_hash
FROM registration_tokens
WHERE token_hash = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
`

View file

@ -0,0 +1,96 @@
// Package security provides TOTP (RFC 6238) validation for fleet-join 2FA
// (SEC-025). The seed is stored as a SHA-256 hash on the registration token;
// the host generates the seed and the code. The server only validates.
package security
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
"time"
)
const (
// TotpPeriod is the time step in seconds (RFC 6238 default).
TotpPeriod = 30
// totpDigits is the number of digits in the TOTP code.
totpDigits = 6
// totpSkew is the number of time steps to accept in each direction.
// ±1 step = ±30 seconds, matching standard authenticator apps.
totpSkew = 1
)
// GenerateTOTPSeed creates a new random TOTP seed (base32-encoded, 20 bytes).
// Called by the standalone host; the server never generates seeds.
func GenerateTOTPSeed() (string, error) {
buf := make([]byte, 20) // 160 bits, standard for HMAC-SHA1
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("totp: seed generation failed: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil
}
// HashTOTPSeed returns the SHA-256 hex of the base32 seed for storage.
func HashTOTPSeed(seed string) string {
h := sha256.Sum256([]byte(strings.ToUpper(seed)))
return hex.EncodeToString(h[:])
}
// ValidateTOTPCode checks whether the given 6-digit code is valid for the
// seed at the current time, with ±1 step tolerance.
func ValidateTOTPCode(seed string, code string) bool {
return ValidateTOTPCodeAt(seed, code, time.Now())
}
// ValidateTOTPCodeAt checks the code at a specific time (for testing).
func ValidateTOTPCodeAt(seed string, code string, t time.Time) bool {
if len(code) != totpDigits {
return false
}
seedBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(seed))
if err != nil {
return false
}
counter := uint64(t.Unix()) / TotpPeriod
// Check current time step and ±skew.
for i := -totpSkew; i <= totpSkew; i++ {
c := counter + uint64(i)
if i < 0 && c > counter { // underflow guard
continue
}
if totpAt(seedBytes, c) == code {
return true
}
}
return false
}
// totpAt generates the TOTP code for a given counter value.
func totpAt(key []byte, counter uint64) string {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
mac := hmac.New(sha1.New, key)
mac.Write(buf)
sum := mac.Sum(nil)
// Dynamic truncation (RFC 4226 §5.4).
offset := sum[len(sum)-1] & 0x0f
code := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
// Format as zero-padded digit string.
divisor := uint32(1)
for i := 0; i < totpDigits; i++ {
divisor *= 10
}
return fmt.Sprintf("%0*d", totpDigits, code%divisor)
}

View file

@ -0,0 +1,94 @@
package security
import (
"encoding/base32"
"testing"
"time"
)
func TestGenerateTOTPSeed(t *testing.T) {
seed, err := GenerateTOTPSeed()
if err != nil {
t.Fatalf("GenerateTOTPSeed failed: %v", err)
}
if len(seed) == 0 {
t.Fatal("seed is empty")
}
// Two seeds should differ.
seed2, err := GenerateTOTPSeed()
if err != nil {
t.Fatalf("GenerateTOTPSeed (2) failed: %v", err)
}
if seed == seed2 {
t.Fatal("two generated seeds are identical")
}
}
func TestHashTOTPSeed(t *testing.T) {
seed := "JBSWY3DPEHPK3PXP"
h1 := HashTOTPSeed(seed)
h2 := HashTOTPSeed(seed)
if h1 != h2 {
t.Fatal("same seed produced different hashes")
}
if h1 == HashTOTPSeed("DIFFERENTSEED123") {
t.Fatal("different seeds produced same hash")
}
// Case-insensitive: lowercase seed should hash the same.
if h1 != HashTOTPSeed("jbswy3dpehpk3pxp") {
t.Fatal("case-sensitive hash — should be case-insensitive")
}
}
func TestTOTPValidation(t *testing.T) {
seed := "JBSWY3DPEHPK3PXP"
// Generate a valid code for a fixed time and verify it passes.
fixed := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
code := CodeAt(seed, fixed)
if len(code) != 6 {
t.Fatalf("expected 6-digit code, got %q", code)
}
if !ValidateTOTPCodeAt(seed, code, fixed) {
t.Fatalf("code %q rejected at exact time", code)
}
// ±30 seconds should also pass.
if !ValidateTOTPCodeAt(seed, code, fixed.Add(29*time.Second)) {
t.Fatal("code rejected at +29s")
}
if !ValidateTOTPCodeAt(seed, code, fixed.Add(-29*time.Second)) {
t.Fatal("code rejected at -29s")
}
// ±61 seconds should fail (outside ±1 step window).
if ValidateTOTPCodeAt(seed, code, fixed.Add(61*time.Second)) {
t.Fatal("code accepted at +61s — should be outside tolerance")
}
if ValidateTOTPCodeAt(seed, code, fixed.Add(-61*time.Second)) {
t.Fatal("code accepted at -61s — should be outside tolerance")
}
}
func TestTOTPInvalidCode(t *testing.T) {
seed := "JBSWY3DPEHPK3PXP"
fixed := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
if ValidateTOTPCodeAt(seed, "000000", fixed) {
t.Fatal("all-zero code accepted")
}
if ValidateTOTPCodeAt(seed, "12345", fixed) {
t.Fatal("5-digit code accepted")
}
if ValidateTOTPCodeAt(seed, "1234567", fixed) {
t.Fatal("7-digit code accepted")
}
}
// CodeAt generates the TOTP code at a specific time (exposed for testing).
func CodeAt(seed string, t time.Time) string {
seedBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(seed)
counter := uint64(t.Unix()) / TotpPeriod
return totpAt(seedBytes, counter)
}