- Trusted proxy config option for proxy-aware IP extraction used by rate limiting and audit logs; validates proxy IP before trusting X-Forwarded-For / X-Real-IP headers - TOTP replay protection via counter-based validation to reject reused codes within the same time step (±30s) - RateLimit middleware updated to extract client IP from proxy headers without IP spoofing risk - New tests for ClientIP proxy logic (spoofed headers, fallback) and extended rate-limit proxy coverage - HTMX error banner script integrated into web UI base - .gitignore updated for mciasdb build artifact Security: resolves CRIT-01 (TOTP replay attack) and DEF-03 (proxy-unaware rate limiting); gRPC TOTP enrollment aligned with REST via StorePendingTOTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
190 lines
6.2 KiB
Go
190 lines
6.2 KiB
Go
// Package token handles JWT issuance, validation, and revocation for MCIAS.
|
|
//
|
|
// Security design:
|
|
// - Algorithm header is checked FIRST, before any signature verification.
|
|
// This prevents algorithm-confusion attacks (CVE-2022-21449 class).
|
|
// - Only "EdDSA" is accepted; "none", HS*, RS*, ES* are all rejected.
|
|
// - The signing key is taken from the server's keystore, never from the token.
|
|
// - All standard claims (exp, iat, iss, jti) are required and validated.
|
|
// - JTIs are UUIDs generated from crypto/rand (via google/uuid).
|
|
// - Token values are never stored; only JTIs are recorded for revocation.
|
|
package token
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
// requiredAlg is the only JWT algorithm accepted by MCIAS.
|
|
// Security: Hard-coding this as a constant rather than a variable ensures
|
|
// it cannot be changed at runtime and cannot be confused by token headers.
|
|
requiredAlg = "EdDSA"
|
|
)
|
|
|
|
// Claims holds the MCIAS-specific JWT claims.
|
|
type Claims struct {
|
|
// Standard registered claims.
|
|
Issuer string `json:"iss"`
|
|
Subject string `json:"sub"` // account UUID
|
|
IssuedAt time.Time `json:"iat"`
|
|
ExpiresAt time.Time `json:"exp"`
|
|
JTI string `json:"jti"`
|
|
|
|
// MCIAS-specific claims.
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
// jwtClaims adapts Claims to the golang-jwt MapClaims interface.
|
|
type jwtClaims struct {
|
|
jwt.RegisteredClaims
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
// ErrExpiredToken is returned when the token's exp claim is in the past.
|
|
var ErrExpiredToken = errors.New("token: expired")
|
|
|
|
// ErrInvalidSignature is returned when Ed25519 signature verification fails.
|
|
var ErrInvalidSignature = errors.New("token: invalid signature")
|
|
|
|
// ErrWrongAlgorithm is returned when the alg header is not EdDSA.
|
|
var ErrWrongAlgorithm = errors.New("token: algorithm must be EdDSA")
|
|
|
|
// ErrMissingClaim is returned when a required claim is absent or empty.
|
|
var ErrMissingClaim = errors.New("token: missing required claim")
|
|
|
|
// IssueToken creates and signs a new JWT with the given claims.
|
|
// The jti is generated automatically using crypto/rand via uuid.New().
|
|
// Returns the signed token string.
|
|
//
|
|
// Security: The signing key is provided by the caller from the server's
|
|
// keystore. The alg header is set explicitly to "EdDSA" by the jwt library
|
|
// when an ed25519.PrivateKey is passed to SignedString.
|
|
func IssueToken(key ed25519.PrivateKey, issuer, subject string, roles []string, expiry time.Duration) (string, *Claims, error) {
|
|
now := time.Now().UTC()
|
|
exp := now.Add(expiry)
|
|
jti := uuid.New().String()
|
|
|
|
// Security (DEF-04): set NotBefore = now so tokens are not valid before
|
|
// the instant of issuance. This is a defence-in-depth measure: without
|
|
// nbf, a clock-skewed client or intermediate could present a token
|
|
// before its intended validity window.
|
|
jc := jwtClaims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: issuer,
|
|
Subject: subject,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(exp),
|
|
ID: jti,
|
|
},
|
|
Roles: roles,
|
|
}
|
|
|
|
t := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jc)
|
|
signed, err := t.SignedString(key)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("token: sign JWT: %w", err)
|
|
}
|
|
|
|
claims := &Claims{
|
|
Issuer: issuer,
|
|
Subject: subject,
|
|
IssuedAt: now,
|
|
ExpiresAt: exp,
|
|
JTI: jti,
|
|
Roles: roles,
|
|
}
|
|
return signed, claims, nil
|
|
}
|
|
|
|
// ValidateToken parses and validates a JWT string.
|
|
//
|
|
// Security order of operations (all must pass):
|
|
// 1. Parse the token header and extract the alg field.
|
|
// 2. Reject immediately if alg != "EdDSA" (before any signature check).
|
|
// 3. Verify Ed25519 signature.
|
|
// 4. Validate exp, iat, iss, jti claims.
|
|
//
|
|
// Returns Claims on success, or a typed error on any failure.
|
|
// The caller is responsible for checking revocation status via the DB.
|
|
func ValidateToken(key ed25519.PublicKey, tokenString, expectedIssuer string) (*Claims, error) {
|
|
// Step 1+2: Parse the header to check alg BEFORE any crypto.
|
|
// Security: We use jwt.ParseWithClaims with an explicit key function that
|
|
// enforces the algorithm. The key function is called by the library after
|
|
// parsing the header but before verifying the signature, which is the
|
|
// correct point to enforce algorithm constraints.
|
|
var jc jwtClaims
|
|
t, err := jwt.ParseWithClaims(tokenString, &jc, func(t *jwt.Token) (interface{}, error) {
|
|
// Security: Check alg header first. This must happen in the key
|
|
// function — it is the only place where the parsed (but unverified)
|
|
// header is available before signature validation.
|
|
if t.Method.Alg() != requiredAlg {
|
|
return nil, fmt.Errorf("%w: got %q, want %q", ErrWrongAlgorithm, t.Method.Alg(), requiredAlg)
|
|
}
|
|
return key, nil
|
|
},
|
|
jwt.WithIssuedAt(),
|
|
jwt.WithIssuer(expectedIssuer),
|
|
jwt.WithExpirationRequired(),
|
|
// Security (DEF-04): nbf is validated automatically by the library
|
|
// when the claim is present; no explicit option is needed. If nbf is
|
|
// in the future the library returns ErrTokenNotValidYet.
|
|
)
|
|
if err != nil {
|
|
// Map library errors to our typed errors for consistent handling.
|
|
if errors.Is(err, ErrWrongAlgorithm) {
|
|
return nil, ErrWrongAlgorithm
|
|
}
|
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
|
return nil, ErrExpiredToken
|
|
}
|
|
if errors.Is(err, jwt.ErrSignatureInvalid) {
|
|
return nil, ErrInvalidSignature
|
|
}
|
|
return nil, fmt.Errorf("token: parse: %w", err)
|
|
}
|
|
if !t.Valid {
|
|
return nil, ErrInvalidSignature
|
|
}
|
|
|
|
// Step 4: Validate required custom claims.
|
|
if jc.ID == "" {
|
|
return nil, fmt.Errorf("%w: jti", ErrMissingClaim)
|
|
}
|
|
if jc.Subject == "" {
|
|
return nil, fmt.Errorf("%w: sub", ErrMissingClaim)
|
|
}
|
|
if jc.ExpiresAt == nil {
|
|
return nil, fmt.Errorf("%w: exp", ErrMissingClaim)
|
|
}
|
|
if jc.IssuedAt == nil {
|
|
return nil, fmt.Errorf("%w: iat", ErrMissingClaim)
|
|
}
|
|
|
|
claims := &Claims{
|
|
Issuer: jc.Issuer,
|
|
Subject: jc.Subject,
|
|
IssuedAt: jc.IssuedAt.Time,
|
|
ExpiresAt: jc.ExpiresAt.Time,
|
|
JTI: jc.ID,
|
|
Roles: jc.Roles,
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// HasRole reports whether the claims include the given role.
|
|
func (c *Claims) HasRole(role string) bool {
|
|
for _, r := range c.Roles {
|
|
if r == role {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|