checkpoint mciassrv
This commit is contained in:
181
internal/token/token.go
Normal file
181
internal/token/token.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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()
|
||||
|
||||
jc := jwtClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
IssuedAt: 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(),
|
||||
)
|
||||
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
|
||||
}
|
||||
222
internal/token/token_test.go
Normal file
222
internal/token/token_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
// b64url encodes a string as base64url without padding.
|
||||
func b64url(s string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
const testIssuer = "https://auth.example.com"
|
||||
|
||||
func TestIssueAndValidateToken(t *testing.T) {
|
||||
pub, priv := generateTestKey(t)
|
||||
roles := []string{"admin", "reader"}
|
||||
|
||||
tokenStr, claims, err := IssueToken(priv, testIssuer, "user-uuid-1", roles, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
if tokenStr == "" {
|
||||
t.Fatal("IssueToken returned empty token string")
|
||||
}
|
||||
if claims.JTI == "" {
|
||||
t.Error("JTI must not be empty")
|
||||
}
|
||||
if claims.Subject != "user-uuid-1" {
|
||||
t.Errorf("Subject = %q, want %q", claims.Subject, "user-uuid-1")
|
||||
}
|
||||
|
||||
// Validate the token.
|
||||
got, err := ValidateToken(pub, tokenStr, testIssuer)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken: %v", err)
|
||||
}
|
||||
if got.Subject != "user-uuid-1" {
|
||||
t.Errorf("validated Subject = %q, want %q", got.Subject, "user-uuid-1")
|
||||
}
|
||||
if got.JTI != claims.JTI {
|
||||
t.Errorf("validated JTI = %q, want %q", got.JTI, claims.JTI)
|
||||
}
|
||||
if len(got.Roles) != 2 {
|
||||
t.Errorf("validated Roles = %v, want 2 roles", got.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenWrongAlgorithm verifies that tokens with non-EdDSA alg are
|
||||
// rejected immediately, before any signature verification.
|
||||
// Security: This tests the core defence against algorithm-confusion attacks.
|
||||
func TestValidateTokenWrongAlgorithm(t *testing.T) {
|
||||
_, priv := generateTestKey(t)
|
||||
pub, _ := generateTestKey(t) // different key — but alg check should fail first
|
||||
|
||||
// Forge a token signed with HMAC-SHA256 (alg: HS256).
|
||||
hmacToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "attacker",
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"jti": "fake-jti",
|
||||
})
|
||||
// Use the Ed25519 public key bytes as the HMAC secret (classic alg confusion).
|
||||
hs256Signed, err := hmacToken.SignedString([]byte(priv.Public().(ed25519.PublicKey)))
|
||||
if err != nil {
|
||||
t.Fatalf("sign HS256 token: %v", err)
|
||||
}
|
||||
|
||||
_, err = ValidateToken(pub, hs256Signed, testIssuer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HS256 token, got nil")
|
||||
}
|
||||
if err != ErrWrongAlgorithm {
|
||||
t.Errorf("expected ErrWrongAlgorithm, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenAlgNone verifies that "none" algorithm is rejected.
|
||||
// Security: "none" algorithm tokens have no signature and must always be
|
||||
// rejected regardless of payload content.
|
||||
func TestValidateTokenAlgNone(t *testing.T) {
|
||||
pub, _ := generateTestKey(t)
|
||||
|
||||
// Construct a "none" algorithm token manually.
|
||||
// golang-jwt/v5 disallows signing with "none" directly, so we craft it
|
||||
// using raw base64url encoding.
|
||||
header := `{"alg":"none","typ":"JWT"}`
|
||||
payload := `{"iss":"https://auth.example.com","sub":"evil","iat":1000000,"exp":9999999999,"jti":"evil-jti"}`
|
||||
noneToken := b64url(header) + "." + b64url(payload) + "."
|
||||
|
||||
_, err := ValidateToken(pub, noneToken, testIssuer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 'none' algorithm token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenExpired verifies that expired tokens are rejected.
|
||||
func TestValidateTokenExpired(t *testing.T) {
|
||||
pub, priv := generateTestKey(t)
|
||||
|
||||
// Issue a token with a negative expiry (already expired).
|
||||
tokenStr, _, err := IssueToken(priv, testIssuer, "user", nil, -time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
_, err = ValidateToken(pub, tokenStr, testIssuer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token, got nil")
|
||||
}
|
||||
if err != ErrExpiredToken {
|
||||
t.Errorf("expected ErrExpiredToken, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenTamperedSignature verifies that signature tampering is caught.
|
||||
func TestValidateTokenTamperedSignature(t *testing.T) {
|
||||
pub, priv := generateTestKey(t)
|
||||
|
||||
tokenStr, _, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
// Tamper: flip a byte in the signature (last segment).
|
||||
parts := strings.Split(tokenStr, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("unexpected token format: %d parts", len(parts))
|
||||
}
|
||||
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
t.Fatalf("decode signature: %v", err)
|
||||
}
|
||||
sigBytes[0] ^= 0x01 // flip one bit
|
||||
parts[2] = base64.RawURLEncoding.EncodeToString(sigBytes)
|
||||
tampered := strings.Join(parts, ".")
|
||||
|
||||
_, err = ValidateToken(pub, tampered, testIssuer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tampered signature, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenWrongKey verifies that a token signed with a different key
|
||||
// is rejected.
|
||||
func TestValidateTokenWrongKey(t *testing.T) {
|
||||
_, priv := generateTestKey(t)
|
||||
wrongPub, _ := generateTestKey(t)
|
||||
|
||||
tokenStr, _, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
_, err = ValidateToken(wrongPub, tokenStr, testIssuer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong key, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenWrongIssuer verifies that tokens from a different issuer
|
||||
// are rejected.
|
||||
func TestValidateTokenWrongIssuer(t *testing.T) {
|
||||
pub, priv := generateTestKey(t)
|
||||
|
||||
tokenStr, _, err := IssueToken(priv, "https://evil.example.com", "user", nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
_, err = ValidateToken(pub, tokenStr, testIssuer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong issuer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestJTIsAreUnique verifies that two issued tokens have different JTIs.
|
||||
func TestJTIsAreUnique(t *testing.T) {
|
||||
_, priv := generateTestKey(t)
|
||||
|
||||
_, c1, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken (1): %v", err)
|
||||
}
|
||||
_, c2, err := IssueToken(priv, testIssuer, "user", nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken (2): %v", err)
|
||||
}
|
||||
if c1.JTI == c2.JTI {
|
||||
t.Error("two issued tokens have the same JTI")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimsHasRole verifies role checking.
|
||||
func TestClaimsHasRole(t *testing.T) {
|
||||
c := &Claims{Roles: []string{"admin", "reader"}}
|
||||
if !c.HasRole("admin") {
|
||||
t.Error("expected HasRole(admin) = true")
|
||||
}
|
||||
if !c.HasRole("reader") {
|
||||
t.Error("expected HasRole(reader) = true")
|
||||
}
|
||||
if c.HasRole("writer") {
|
||||
t.Error("expected HasRole(writer) = false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user