A community based topic aggregation platform built on atproto

fix(auth): add ES256K support and security hardening for DPoP verification

- Add ES256K (secp256k1) algorithm support using indigo's crypto package
- Add algorithm-curve binding validation to prevent algorithm confusion attacks
- Restore exp/nbf claim validation for DPoP proofs (security regression fix)
- Replace golang-jwt parsing with manual JWT parsing to support ES256K
- Add comprehensive test coverage for ES256K and security validations
- Update Caddyfile with proper Host headers for DPoP htu matching

Security fixes:
- Validate JWK curve matches claimed algorithm (ES256K->secp256k1, ES256->P-256, etc.)
- Validate exp claim if present (with clock skew tolerance)
- Validate nbf claim if present (with clock skew tolerance)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+580 -79
internal
atproto
+4 -1
Caddyfile
···
health_interval 30s
health_timeout 5s
-
# Headers
+
# Headers for proper DPoP verification
+
# Host headers are critical for DPoP htu (HTTP URI) matching
+
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
+
header_up X-Forwarded-Host {host}
}
}
+189 -78
internal/atproto/auth/dpop.go
···
package auth
import (
-
"crypto/ecdsa"
-
"crypto/elliptic"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
-
"math/big"
"strings"
"sync"
"time"
···
}
}
-
// VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof
+
// VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof.
+
// This supports all atProto-compatible ECDSA algorithms including ES256K (secp256k1).
func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) {
-
// Parse the DPoP JWT without verification first to extract the header
-
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
-
token, _, err := parser.ParseUnverified(dpopProof, &DPoPClaims{})
+
// Manually parse the JWT to support ES256K (which golang-jwt doesn't recognize)
+
header, claims, err := parseJWTHeaderAndClaims(dpopProof)
if err != nil {
return nil, fmt.Errorf("failed to parse DPoP proof: %w", err)
}
-
// Extract and validate the header
-
header, ok := token.Header["typ"].(string)
-
if !ok || header != "dpop+jwt" {
-
return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", header)
+
// Extract and validate the typ header
+
typ, ok := header["typ"].(string)
+
if !ok || typ != "dpop+jwt" {
+
return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", typ)
}
-
alg, ok := token.Header["alg"].(string)
+
alg, ok := header["alg"].(string)
if !ok {
return nil, fmt.Errorf("invalid DPoP proof: missing alg header")
}
-
// Extract the JWK from the header
-
jwkRaw, ok := token.Header["jwk"]
+
// Extract the JWK from the header first (needed for algorithm-curve validation)
+
jwkRaw, ok := header["jwk"]
if !ok {
return nil, fmt.Errorf("invalid DPoP proof: missing jwk header")
}
···
return nil, fmt.Errorf("invalid DPoP proof: jwk must be an object")
}
-
// Parse the public key from JWK
-
publicKey, err := parseJWKToPublicKey(jwkMap)
+
// Validate the algorithm is supported and matches the JWK curve
+
// This is critical for security - prevents algorithm confusion attacks
+
if err := validateAlgorithmCurveBinding(alg, jwkMap); err != nil {
+
return nil, fmt.Errorf("invalid DPoP proof: %w", err)
+
}
+
+
// Parse the public key using indigo's crypto package
+
// This supports all atProto curves including secp256k1 (ES256K)
+
publicKey, err := parseJWKToIndigoPublicKey(jwkMap)
if err != nil {
return nil, fmt.Errorf("invalid DPoP proof JWK: %w", err)
}
···
return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
}
-
// Now verify the signature
-
verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) {
-
// Verify the signing method matches what we expect
-
switch alg {
-
case "ES256":
-
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
-
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
-
}
-
case "ES384", "ES512":
-
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
-
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
-
}
-
case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
-
// RSA methods - we primarily support ES256 for atproto
-
return nil, fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg)
-
default:
-
return nil, fmt.Errorf("unsupported DPoP algorithm: %s", alg)
-
}
-
return publicKey, nil
-
})
-
if err != nil {
+
// Verify the signature using indigo's crypto package
+
// This works for all ECDSA algorithms including ES256K
+
if err := verifyJWTSignatureWithIndigo(dpopProof, publicKey); err != nil {
return nil, fmt.Errorf("DPoP proof signature verification failed: %w", err)
-
}
-
-
claims, ok := verifiedToken.Claims.(*DPoPClaims)
-
if !ok {
-
return nil, fmt.Errorf("invalid DPoP claims type")
}
// Validate the claims
···
return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge)
}
+
// SECURITY: Validate exp claim if present (RFC standard JWT validation)
+
// While DPoP proofs typically use iat + MaxProofAge, if exp is included it must be honored
+
if claims.ExpiresAt != nil {
+
expWithSkew := claims.ExpiresAt.Time.Add(v.MaxClockSkew)
+
if now.After(expWithSkew) {
+
return fmt.Errorf("DPoP proof expired at %v", claims.ExpiresAt.Time)
+
}
+
}
+
+
// SECURITY: Validate nbf claim if present (RFC standard JWT validation)
+
if claims.NotBefore != nil {
+
nbfWithSkew := claims.NotBefore.Time.Add(-v.MaxClockSkew)
+
if now.Before(nbfWithSkew) {
+
return fmt.Errorf("DPoP proof not valid before %v", claims.NotBefore.Time)
+
}
+
}
+
// SECURITY: Check for replay attack using jti
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks
if v.NonceCache != nil {
···
return thumbprint, nil
}
-
// parseJWKToPublicKey parses a JWK map to a Go public key
-
func parseJWKToPublicKey(jwkMap map[string]interface{}) (interface{}, error) {
+
// validateAlgorithmCurveBinding validates that the JWT algorithm matches the JWK curve.
+
// This is critical for security - an attacker could claim alg: "ES256K" but provide
+
// a P-256 key, potentially bypassing algorithm binding requirements.
+
func validateAlgorithmCurveBinding(alg string, jwkMap map[string]interface{}) error {
+
kty, ok := jwkMap["kty"].(string)
+
if !ok {
+
return fmt.Errorf("JWK missing kty")
+
}
+
+
// ECDSA algorithms require EC key type
+
switch alg {
+
case "ES256K", "ES256", "ES384", "ES512":
+
if kty != "EC" {
+
return fmt.Errorf("algorithm %s requires EC key type, got %s", alg, kty)
+
}
+
case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
+
return fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg)
+
default:
+
return fmt.Errorf("unsupported DPoP algorithm: %s", alg)
+
}
+
+
// Validate curve matches algorithm
+
crv, ok := jwkMap["crv"].(string)
+
if !ok {
+
return fmt.Errorf("EC JWK missing crv")
+
}
+
+
var expectedCurve string
+
switch alg {
+
case "ES256K":
+
expectedCurve = "secp256k1"
+
case "ES256":
+
expectedCurve = "P-256"
+
case "ES384":
+
expectedCurve = "P-384"
+
case "ES512":
+
expectedCurve = "P-521"
+
}
+
+
if crv != expectedCurve {
+
return fmt.Errorf("algorithm %s requires curve %s, got %s", alg, expectedCurve, crv)
+
}
+
+
return nil
+
}
+
+
// parseJWKToIndigoPublicKey parses a JWK map to an indigo PublicKey.
+
// This returns indigo's PublicKey interface which supports all atProto curves
+
// including secp256k1 (ES256K), P-256 (ES256), P-384 (ES384), and P-521 (ES512).
+
func parseJWKToIndigoPublicKey(jwkMap map[string]interface{}) (indigoCrypto.PublicKey, error) {
// Convert map to JSON bytes for indigo's parser
jwkBytes, err := json.Marshal(jwkMap)
if err != nil {
return nil, fmt.Errorf("failed to serialize JWK: %w", err)
}
-
// Try to parse with indigo's crypto package
+
// Parse with indigo's crypto package - this supports all atProto curves
+
// including secp256k1 (ES256K) which Go's crypto/elliptic doesn't support
pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse JWK: %w", err)
}
-
// Convert indigo's PublicKey to Go's ecdsa.PublicKey
-
jwk, err := pubKey.JWK()
+
return pubKey, nil
+
}
+
+
// parseJWTHeaderAndClaims manually parses a JWT's header and claims without using golang-jwt.
+
// This is necessary to support ES256K (secp256k1) which golang-jwt doesn't recognize.
+
func parseJWTHeaderAndClaims(tokenString string) (map[string]interface{}, *DPoPClaims, error) {
+
parts := strings.Split(tokenString, ".")
+
if len(parts) != 3 {
+
return nil, nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
+
}
+
+
// Decode header
+
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
-
return nil, fmt.Errorf("failed to get JWK from public key: %w", err)
+
return nil, nil, fmt.Errorf("failed to decode JWT header: %w", err)
+
}
+
+
var header map[string]interface{}
+
if err := json.Unmarshal(headerBytes, &header); err != nil {
+
return nil, nil, fmt.Errorf("failed to parse JWT header: %w", err)
+
}
+
+
// Decode claims
+
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to decode JWT claims: %w", err)
+
}
+
+
// Parse into raw map first to extract standard claims
+
var rawClaims map[string]interface{}
+
if err := json.Unmarshal(claimsBytes, &rawClaims); err != nil {
+
return nil, nil, fmt.Errorf("failed to parse JWT claims: %w", err)
+
}
+
+
// Build DPoPClaims struct
+
claims := &DPoPClaims{}
+
+
// Extract jti
+
if jti, ok := rawClaims["jti"].(string); ok {
+
claims.ID = jti
}
-
// Use our existing conversion function
-
return atcryptoJWKToECDSAFromIndigoJWK(jwk)
-
}
+
// Extract iat (issued at)
+
if iat, ok := rawClaims["iat"].(float64); ok {
+
t := time.Unix(int64(iat), 0)
+
claims.IssuedAt = jwt.NewNumericDate(t)
+
}
-
// atcryptoJWKToECDSAFromIndigoJWK converts an indigo JWK to Go ecdsa.PublicKey
-
func atcryptoJWKToECDSAFromIndigoJWK(jwk *indigoCrypto.JWK) (*ecdsa.PublicKey, error) {
-
if jwk.KeyType != "EC" {
-
return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
+
// Extract exp (expiration) if present
+
if exp, ok := rawClaims["exp"].(float64); ok {
+
t := time.Unix(int64(exp), 0)
+
claims.ExpiresAt = jwt.NewNumericDate(t)
}
-
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
-
if err != nil {
-
return nil, fmt.Errorf("invalid JWK X coordinate: %w", err)
+
// Extract nbf (not before) if present
+
if nbf, ok := rawClaims["nbf"].(float64); ok {
+
t := time.Unix(int64(nbf), 0)
+
claims.NotBefore = jwt.NewNumericDate(t)
}
-
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
-
if err != nil {
-
return nil, fmt.Errorf("invalid JWK Y coordinate: %w", err)
+
+
// Extract htm (HTTP method)
+
if htm, ok := rawClaims["htm"].(string); ok {
+
claims.HTTPMethod = htm
}
-
var curve ecdsa.PublicKey
-
switch jwk.Curve {
-
case "P-256":
-
curve.Curve = ecdsaP256Curve()
-
case "P-384":
-
curve.Curve = ecdsaP384Curve()
-
case "P-521":
-
curve.Curve = ecdsaP521Curve()
-
default:
-
return nil, fmt.Errorf("unsupported curve: %s", jwk.Curve)
+
// Extract htu (HTTP URI)
+
if htu, ok := rawClaims["htu"].(string); ok {
+
claims.HTTPURI = htu
}
-
curve.X = new(big.Int).SetBytes(xBytes)
-
curve.Y = new(big.Int).SetBytes(yBytes)
+
// Extract ath (access token hash) if present
+
if ath, ok := rawClaims["ath"].(string); ok {
+
claims.AccessTokenHash = ath
+
}
-
return &curve, nil
+
return header, claims, nil
}
-
// Helper functions for elliptic curves
-
func ecdsaP256Curve() elliptic.Curve { return elliptic.P256() }
-
func ecdsaP384Curve() elliptic.Curve { return elliptic.P384() }
-
func ecdsaP521Curve() elliptic.Curve { return elliptic.P521() }
+
// verifyJWTSignatureWithIndigo verifies a JWT signature using indigo's crypto package.
+
// This is used instead of golang-jwt for algorithms not supported by golang-jwt (like ES256K).
+
// It parses the JWT, extracts the signing input and signature, and uses indigo's
+
// PublicKey.HashAndVerifyLenient() for verification.
+
//
+
// JWT format: header.payload.signature (all base64url-encoded)
+
// Signature is verified over the raw bytes of "header.payload"
+
// (indigo's HashAndVerifyLenient handles SHA-256 hashing internally)
+
func verifyJWTSignatureWithIndigo(tokenString string, pubKey indigoCrypto.PublicKey) error {
+
// Split the JWT into parts
+
parts := strings.Split(tokenString, ".")
+
if len(parts) != 3 {
+
return fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
+
}
+
+
// The signing input is "header.payload" (without decoding)
+
signingInput := parts[0] + "." + parts[1]
+
+
// Decode the signature from base64url
+
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
+
if err != nil {
+
return fmt.Errorf("failed to decode JWT signature: %w", err)
+
}
+
+
// Use indigo's verification - HashAndVerifyLenient handles hashing internally
+
// and accepts both low-S and high-S signatures for maximum compatibility
+
err = pubKey.HashAndVerifyLenient([]byte(signingInput), signature)
+
if err != nil {
+
return fmt.Errorf("signature verification failed: %w", err)
+
}
+
+
return nil
+
}
// stripQueryFragment removes query and fragment from a URI
func stripQueryFragment(uri string) string {
+387
internal/atproto/auth/dpop_test.go
···
"testing"
"time"
+
indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
···
}
return base64.RawURLEncoding.EncodeToString(data)
}
+
+
// === ES256K (secp256k1) Test Helpers ===
+
+
// testES256KKey holds a test ES256K key pair using indigo
+
type testES256KKey struct {
+
privateKey indigoCrypto.PrivateKey
+
publicKey indigoCrypto.PublicKey
+
jwk map[string]interface{}
+
thumbprint string
+
}
+
+
// generateTestES256KKey generates a test ES256K (secp256k1) key pair and JWK
+
func generateTestES256KKey(t *testing.T) *testES256KKey {
+
t.Helper()
+
+
privateKey, err := indigoCrypto.GeneratePrivateKeyK256()
+
if err != nil {
+
t.Fatalf("Failed to generate ES256K test key: %v", err)
+
}
+
+
publicKey, err := privateKey.PublicKey()
+
if err != nil {
+
t.Fatalf("Failed to get public key from ES256K private key: %v", err)
+
}
+
+
// Get the JWK representation
+
jwkStruct, err := publicKey.JWK()
+
if err != nil {
+
t.Fatalf("Failed to get JWK from ES256K public key: %v", err)
+
}
+
jwk := map[string]interface{}{
+
"kty": jwkStruct.KeyType,
+
"crv": jwkStruct.Curve,
+
"x": jwkStruct.X,
+
"y": jwkStruct.Y,
+
}
+
+
// Calculate thumbprint
+
thumbprint, err := CalculateJWKThumbprint(jwk)
+
if err != nil {
+
t.Fatalf("Failed to calculate ES256K thumbprint: %v", err)
+
}
+
+
return &testES256KKey{
+
privateKey: privateKey,
+
publicKey: publicKey,
+
jwk: jwk,
+
thumbprint: thumbprint,
+
}
+
}
+
+
// createES256KDPoPProof creates a DPoP proof JWT using ES256K for testing
+
func createES256KDPoPProof(t *testing.T, key *testES256KKey, method, uri string, iat time.Time, jti string) string {
+
t.Helper()
+
+
// Build claims
+
claims := map[string]interface{}{
+
"jti": jti,
+
"iat": iat.Unix(),
+
"htm": method,
+
"htu": uri,
+
}
+
+
// Build header
+
header := map[string]interface{}{
+
"typ": "dpop+jwt",
+
"alg": "ES256K",
+
"jwk": key.jwk,
+
}
+
+
// Encode header and claims
+
headerJSON, err := json.Marshal(header)
+
if err != nil {
+
t.Fatalf("Failed to marshal header: %v", err)
+
}
+
claimsJSON, err := json.Marshal(claims)
+
if err != nil {
+
t.Fatalf("Failed to marshal claims: %v", err)
+
}
+
+
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
+
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
+
+
// Sign with indigo
+
signingInput := headerB64 + "." + claimsB64
+
signature, err := key.privateKey.HashAndSign([]byte(signingInput))
+
if err != nil {
+
t.Fatalf("Failed to sign ES256K proof: %v", err)
+
}
+
+
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
+
return signingInput + "." + signatureB64
+
}
+
+
// === ES256K Tests ===
+
+
func TestVerifyDPoPProof_ES256K_Valid(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256KKey(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
proof := createES256KDPoPProof(t, key, method, uri, iat, jti)
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for valid ES256K proof: %v", err)
+
}
+
+
if result == nil {
+
t.Fatal("Expected non-nil proof result")
+
}
+
+
if result.Claims.HTTPMethod != method {
+
t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod)
+
}
+
+
if result.Claims.HTTPURI != uri {
+
t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI)
+
}
+
+
if result.Thumbprint != key.thumbprint {
+
t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint)
+
}
+
}
+
+
func TestVerifyDPoPProof_ES256K_InvalidSignature(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256KKey(t)
+
wrongKey := generateTestES256KKey(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
// Create proof with one key
+
proof := createES256KDPoPProof(t, key, method, uri, iat, jti)
+
+
// Tamper by replacing JWK with wrong key
+
parts := splitJWT(proof)
+
header := parseJWTHeader(t, parts[0])
+
header["jwk"] = wrongKey.jwk
+
modifiedHeader := encodeJSON(t, header)
+
tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2]
+
+
_, err := verifier.VerifyDPoPProof(tamperedProof, method, uri)
+
if err == nil {
+
t.Error("Expected error for invalid ES256K signature, got nil")
+
}
+
if err != nil && !contains(err.Error(), "signature verification failed") {
+
t.Errorf("Expected signature verification error, got: %v", err)
+
}
+
}
+
+
func TestCalculateJWKThumbprint_ES256K(t *testing.T) {
+
// Test thumbprint calculation for secp256k1 keys
+
key := generateTestES256KKey(t)
+
+
thumbprint, err := CalculateJWKThumbprint(key.jwk)
+
if err != nil {
+
t.Fatalf("CalculateJWKThumbprint failed for ES256K: %v", err)
+
}
+
+
if thumbprint == "" {
+
t.Error("Expected non-empty thumbprint for ES256K key")
+
}
+
+
// Verify it's valid base64url
+
_, err = base64.RawURLEncoding.DecodeString(thumbprint)
+
if err != nil {
+
t.Errorf("ES256K thumbprint is not valid base64url: %v", err)
+
}
+
+
// Verify length (SHA-256 produces 32 bytes = 43 base64url chars)
+
if len(thumbprint) != 43 {
+
t.Errorf("Expected ES256K thumbprint length 43, got %d", len(thumbprint))
+
}
+
}
+
+
// === Algorithm-Curve Binding Tests ===
+
+
func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256KWithP256Key(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t) // P-256 key
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
// Create a proof claiming ES256K but using P-256 key
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["alg"] = "ES256K" // Claim ES256K
+
token.Header["jwk"] = key.jwk // But use P-256 key
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for ES256K algorithm with P-256 curve, got nil")
+
}
+
if err != nil && !contains(err.Error(), "requires curve secp256k1") {
+
t.Errorf("Expected curve mismatch error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256WithSecp256k1Key(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256KKey(t) // secp256k1 key
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
jti := uuid.New().String()
+
+
// Build claims
+
claims := map[string]interface{}{
+
"jti": jti,
+
"iat": iat.Unix(),
+
"htm": method,
+
"htu": uri,
+
}
+
+
// Build header claiming ES256 but using secp256k1 key
+
header := map[string]interface{}{
+
"typ": "dpop+jwt",
+
"alg": "ES256", // Claim ES256
+
"jwk": key.jwk, // But use secp256k1 key
+
}
+
+
headerJSON, _ := json.Marshal(header)
+
claimsJSON, _ := json.Marshal(claims)
+
+
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
+
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
+
+
signingInput := headerB64 + "." + claimsB64
+
signature, err := key.privateKey.HashAndSign([]byte(signingInput))
+
if err != nil {
+
t.Fatalf("Failed to sign: %v", err)
+
}
+
+
proof := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for ES256 algorithm with secp256k1 curve, got nil")
+
}
+
if err != nil && !contains(err.Error(), "requires curve P-256") {
+
t.Errorf("Expected curve mismatch error, got: %v", err)
+
}
+
}
+
+
// === exp/nbf Validation Tests ===
+
+
func TestVerifyDPoPProof_ExpiredWithExpClaim(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now().Add(-2 * time.Minute)
+
exp := time.Now().Add(-1 * time.Minute) // Expired 1 minute ago
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
ExpiresAt: jwt.NewNumericDate(exp),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for expired proof with exp claim, got nil")
+
}
+
if err != nil && !contains(err.Error(), "expired") {
+
t.Errorf("Expected expiration error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_NotYetValidWithNbfClaim(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
nbf := time.Now().Add(5 * time.Minute) // Not valid for another 5 minutes
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
NotBefore: jwt.NewNumericDate(nbf),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
_, err = verifier.VerifyDPoPProof(proof, method, uri)
+
if err == nil {
+
t.Error("Expected error for not-yet-valid proof with nbf claim, got nil")
+
}
+
if err != nil && !contains(err.Error(), "not valid before") {
+
t.Errorf("Expected not-before error, got: %v", err)
+
}
+
}
+
+
func TestVerifyDPoPProof_ValidWithExpClaimInFuture(t *testing.T) {
+
verifier := NewDPoPVerifier()
+
key := generateTestES256Key(t)
+
+
method := "POST"
+
uri := "https://api.example.com/resource"
+
iat := time.Now()
+
exp := time.Now().Add(5 * time.Minute) // Valid for 5 more minutes
+
jti := uuid.New().String()
+
+
claims := &DPoPClaims{
+
RegisteredClaims: jwt.RegisteredClaims{
+
ID: jti,
+
IssuedAt: jwt.NewNumericDate(iat),
+
ExpiresAt: jwt.NewNumericDate(exp),
+
},
+
HTTPMethod: method,
+
HTTPURI: uri,
+
}
+
+
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+
token.Header["typ"] = "dpop+jwt"
+
token.Header["jwk"] = key.jwk
+
+
proof, err := token.SignedString(key.privateKey)
+
if err != nil {
+
t.Fatalf("Failed to create test proof: %v", err)
+
}
+
+
result, err := verifier.VerifyDPoPProof(proof, method, uri)
+
if err != nil {
+
t.Fatalf("VerifyDPoPProof failed for valid proof with exp in future: %v", err)
+
}
+
+
if result == nil {
+
t.Error("Expected non-nil result for valid proof")
+
}
+
}