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:
parent
3a8ffe1643
commit
75346d4c68
4 changed files with 91 additions and 22 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
`
|
||||
|
|
|
|||
Loading…
Reference in a new issue