Watch
1
0
Fork
You've already forked RedFlag
0

sec: encrypt TOTP seed at rest (migration 058)

Store the fleet-join TOTP seed as AES-256-GCM ciphertext instead of
a SHA-256 hash. Hash-only can never verify a time code without the
host disclosing the seed — which made the 2FA a second cleartext
shared secret. Seed crosses the wire once, at token creation over
the admin-authenticated channel; the join request carries only the
6-digit code.
This commit is contained in:
Fimeg 2026-06-11 17:47:23 -04:00
commit 75346d4c68
4 changed files with 91 additions and 22 deletions

View file

@ -2,12 +2,14 @@ package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/Fimeg/RedFlag/server/internal/config"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/security"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
)
@ -33,12 +35,13 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
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"`
// FleetJoin + TOTPSeed: 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). This is the one admin-
// authenticated transfer the seed ever makes: the server stores it
// encrypted, and the join request later carries only the 6-digit code.
FleetJoin bool `json:"fleet_join"`
TOTPSeed string `json:"totp_seed"`
}
if err := c.ShouldBindJSON(&request); err != nil {
@ -102,14 +105,24 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
}
// 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
// provides the TOTP seed from the standalone host; it must be valid
// base32 or neither an authenticator nor join validation can use it.
var totpSeed string
if request.FleetJoin {
if request.TOTPSeed == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "fleet-join tokens require a TOTP seed"})
return
}
if !security.ValidTOTPSeed(request.TOTPSeed) {
c.JSON(http.StatusBadRequest, gin.H{"error": "TOTP seed must be base32"})
return
}
totpSeed = request.TOTPSeed
}
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, maxSeats, metadata, totpSeedHash)
err = h.tokenQueries.CreateRegistrationToken(token, request.Label, expiresAt, maxSeats, metadata, totpSeed)
if err != nil {
log.Printf("[ERROR] [server] [registration-tokens] create_failed error=%q", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"})
return
}

View file

@ -0,0 +1,9 @@
ALTER TABLE registration_tokens
DROP COLUMN IF EXISTS totp_seed_encrypted;
ALTER TABLE registration_tokens
ADD COLUMN IF NOT EXISTS totp_seed_hash TEXT;
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

@ -0,0 +1,15 @@
-- SEC-025 rework: store the fleet-join TOTP seed encrypted, not hashed.
-- A hash-only server can never verify a time code without the host
-- disclosing the seed in the join request itself — which reduced the
-- "2FA" to a second cleartext shared secret. The seed now crosses the
-- wire once, at token creation over the admin-authenticated channel,
-- is stored AES-256-GCM encrypted (same key as token_encrypted), and
-- the join request carries only the 6-digit code.
ALTER TABLE registration_tokens
DROP COLUMN IF EXISTS totp_seed_hash;
DROP INDEX IF EXISTS idx_registration_tokens_totp_seed_hash;
ALTER TABLE registration_tokens
ADD COLUMN IF NOT EXISTS totp_seed_encrypted BYTEA;

View file

@ -51,11 +51,13 @@ 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"`
// TOTPSeedEncrypted is the TOTP seed for fleet-join 2FA (SEC-025), stored
// as AES-256-GCM ciphertext (nonce||ct) of the base32 seed. Nil for
// standard registration tokens; set only when the operator creates a
// fleet-join token that requires the standalone host to prove possession
// of the seed via a TOTP code. Never serialised; decrypted only at join
// validation. The join request carries the code, never the seed.
TOTPSeedEncrypted []byte `json:"-" db:"totp_seed_encrypted"`
}
// isLive reports whether a token is still usable for enrollment — the only state
@ -115,9 +117,12 @@ func NewRegistrationTokenQueries(db *sqlx.DB, encKeyB64 string) (*RegistrationTo
// validation and (when a key is configured) the reversible ciphertext is stored
// for one-liner rebuilds.
//
// 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 {
// totpSeed is optional (SEC-025 fleet-join 2FA). When non-empty, the seed is
// stored AES-256-GCM encrypted and the token requires a TOTP code at
// fleet-join time. The seed must be retrievable to verify codes, so creating
// a fleet-join token without an encryption key configured is an error —
// fail closed rather than store a seed we can never use or one in plaintext.
func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string, expiresAt time.Time, maxSeats int, metadata map[string]interface{}, totpSeed string) error {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
@ -135,13 +140,24 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
}
}
var totpSeedEncrypted []byte
if totpSeed != "" {
if len(q.encKey) == 0 {
return fmt.Errorf("fleet-join tokens require the settings encryption key (TOTP seed must be stored encrypted)")
}
totpSeedEncrypted, err = crypto.Encrypt(q.encKey, []byte(totpSeed))
if err != nil {
return fmt.Errorf("failed to encrypt TOTP seed: %w", err)
}
}
tokenHash := HashRegistrationToken(token)
query := `
INSERT INTO registration_tokens (token_hash, token_encrypted, label, expires_at, max_seats, metadata, totp_seed_hash)
INSERT INTO registration_tokens (token_hash, token_encrypted, label, expires_at, max_seats, metadata, totp_seed_encrypted)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err = q.db.Exec(query, tokenHash, encrypted, label, expiresAt, maxSeats, metadataJSON, totpSeedHash)
_, err = q.db.Exec(query, tokenHash, encrypted, label, expiresAt, maxSeats, metadataJSON, totpSeedEncrypted)
if err != nil {
return fmt.Errorf("failed to create registration token: %w", err)
}
@ -149,6 +165,22 @@ func (q *RegistrationTokenQueries) CreateRegistrationToken(token, label string,
return nil
}
// DecryptTOTPSeed returns the plaintext TOTP seed for a fleet-join token.
// Errors when the token has no seed or no encryption key is configured.
func (q *RegistrationTokenQueries) DecryptTOTPSeed(t *RegistrationToken) (string, error) {
if len(t.TOTPSeedEncrypted) == 0 {
return "", fmt.Errorf("token has no TOTP seed")
}
if len(q.encKey) == 0 {
return "", fmt.Errorf("no encryption key configured")
}
plaintext, err := crypto.Decrypt(q.encKey, t.TOTPSeedEncrypted)
if err != nil {
return "", fmt.Errorf("failed to decrypt TOTP seed: %w", err)
}
return string(plaintext), nil
}
// ValidateRegistrationToken checks if a token is valid and has available seats.
// The caller passes the plaintext token; this function hashes it before querying.
func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*RegistrationToken, error) {
@ -157,7 +189,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, totp_seed_hash
max_seats, seats_used, totp_seed_encrypted
FROM registration_tokens
WHERE token_hash = $1 AND status = 'active' AND expires_at > NOW() AND seats_used < max_seats
`