SEC-025: fleet-join 2FA — TOTP validation, host-generated seed, migration 057
This commit is contained in:
parent
47fe315e8f
commit
9c43698b00
7 changed files with 454 additions and 11 deletions
219
server/internal/api/handlers/fleet_join.go
Normal file
219
server/internal/api/handlers/fleet_join.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
`
|
||||
|
|
|
|||
96
server/internal/security/totp.go
Normal file
96
server/internal/security/totp.go
Normal 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)
|
||||
}
|
||||
94
server/internal/security/totp_test.go
Normal file
94
server/internal/security/totp_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue