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>

+580 -79
+4 -1
Caddyfile
··· 75 75 health_interval 30s 76 76 health_timeout 5s 77 77 78 - # Headers 78 + # Headers for proper DPoP verification 79 + # Host headers are critical for DPoP htu (HTTP URI) matching 80 + header_up Host {host} 79 81 header_up X-Real-IP {remote_host} 80 82 header_up X-Forwarded-For {remote_host} 81 83 header_up X-Forwarded-Proto {scheme} 84 + header_up X-Forwarded-Host {host} 82 85 } 83 86 } 84 87
+189 -78
internal/atproto/auth/dpop.go
··· 1 1 package auth 2 2 3 3 import ( 4 - "crypto/ecdsa" 5 - "crypto/elliptic" 6 4 "crypto/sha256" 7 5 "encoding/base64" 8 6 "encoding/json" 9 7 "fmt" 10 - "math/big" 11 8 "strings" 12 9 "sync" 13 10 "time" ··· 171 168 } 172 169 } 173 170 174 - // VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof 171 + // VerifyDPoPProof verifies a DPoP proof JWT and returns the parsed proof. 172 + // This supports all atProto-compatible ECDSA algorithms including ES256K (secp256k1). 175 173 func (v *DPoPVerifier) VerifyDPoPProof(dpopProof, httpMethod, httpURI string) (*DPoPProof, error) { 176 - // Parse the DPoP JWT without verification first to extract the header 177 - parser := jwt.NewParser(jwt.WithoutClaimsValidation()) 178 - token, _, err := parser.ParseUnverified(dpopProof, &DPoPClaims{}) 174 + // Manually parse the JWT to support ES256K (which golang-jwt doesn't recognize) 175 + header, claims, err := parseJWTHeaderAndClaims(dpopProof) 179 176 if err != nil { 180 177 return nil, fmt.Errorf("failed to parse DPoP proof: %w", err) 181 178 } 182 179 183 - // Extract and validate the header 184 - header, ok := token.Header["typ"].(string) 185 - if !ok || header != "dpop+jwt" { 186 - return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", header) 180 + // Extract and validate the typ header 181 + typ, ok := header["typ"].(string) 182 + if !ok || typ != "dpop+jwt" { 183 + return nil, fmt.Errorf("invalid DPoP proof: typ must be 'dpop+jwt', got '%s'", typ) 187 184 } 188 185 189 - alg, ok := token.Header["alg"].(string) 186 + alg, ok := header["alg"].(string) 190 187 if !ok { 191 188 return nil, fmt.Errorf("invalid DPoP proof: missing alg header") 192 189 } 193 190 194 - // Extract the JWK from the header 195 - jwkRaw, ok := token.Header["jwk"] 191 + // Extract the JWK from the header first (needed for algorithm-curve validation) 192 + jwkRaw, ok := header["jwk"] 196 193 if !ok { 197 194 return nil, fmt.Errorf("invalid DPoP proof: missing jwk header") 198 195 } ··· 202 199 return nil, fmt.Errorf("invalid DPoP proof: jwk must be an object") 203 200 } 204 201 205 - // Parse the public key from JWK 206 - publicKey, err := parseJWKToPublicKey(jwkMap) 202 + // Validate the algorithm is supported and matches the JWK curve 203 + // This is critical for security - prevents algorithm confusion attacks 204 + if err := validateAlgorithmCurveBinding(alg, jwkMap); err != nil { 205 + return nil, fmt.Errorf("invalid DPoP proof: %w", err) 206 + } 207 + 208 + // Parse the public key using indigo's crypto package 209 + // This supports all atProto curves including secp256k1 (ES256K) 210 + publicKey, err := parseJWKToIndigoPublicKey(jwkMap) 207 211 if err != nil { 208 212 return nil, fmt.Errorf("invalid DPoP proof JWK: %w", err) 209 213 } ··· 214 218 return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err) 215 219 } 216 220 217 - // Now verify the signature 218 - verifiedToken, err := jwt.ParseWithClaims(dpopProof, &DPoPClaims{}, func(token *jwt.Token) (interface{}, error) { 219 - // Verify the signing method matches what we expect 220 - switch alg { 221 - case "ES256": 222 - if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { 223 - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 224 - } 225 - case "ES384", "ES512": 226 - if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { 227 - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 228 - } 229 - case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512": 230 - // RSA methods - we primarily support ES256 for atproto 231 - return nil, fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg) 232 - default: 233 - return nil, fmt.Errorf("unsupported DPoP algorithm: %s", alg) 234 - } 235 - return publicKey, nil 236 - }) 237 - if err != nil { 221 + // Verify the signature using indigo's crypto package 222 + // This works for all ECDSA algorithms including ES256K 223 + if err := verifyJWTSignatureWithIndigo(dpopProof, publicKey); err != nil { 238 224 return nil, fmt.Errorf("DPoP proof signature verification failed: %w", err) 239 - } 240 - 241 - claims, ok := verifiedToken.Claims.(*DPoPClaims) 242 - if !ok { 243 - return nil, fmt.Errorf("invalid DPoP claims type") 244 225 } 245 226 246 227 // Validate the claims ··· 293 274 return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge) 294 275 } 295 276 277 + // SECURITY: Validate exp claim if present (RFC standard JWT validation) 278 + // While DPoP proofs typically use iat + MaxProofAge, if exp is included it must be honored 279 + if claims.ExpiresAt != nil { 280 + expWithSkew := claims.ExpiresAt.Time.Add(v.MaxClockSkew) 281 + if now.After(expWithSkew) { 282 + return fmt.Errorf("DPoP proof expired at %v", claims.ExpiresAt.Time) 283 + } 284 + } 285 + 286 + // SECURITY: Validate nbf claim if present (RFC standard JWT validation) 287 + if claims.NotBefore != nil { 288 + nbfWithSkew := claims.NotBefore.Time.Add(-v.MaxClockSkew) 289 + if now.Before(nbfWithSkew) { 290 + return fmt.Errorf("DPoP proof not valid before %v", claims.NotBefore.Time) 291 + } 292 + } 293 + 296 294 // SECURITY: Check for replay attack using jti 297 295 // Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks 298 296 if v.NonceCache != nil { ··· 417 415 return thumbprint, nil 418 416 } 419 417 420 - // parseJWKToPublicKey parses a JWK map to a Go public key 421 - func parseJWKToPublicKey(jwkMap map[string]interface{}) (interface{}, error) { 418 + // validateAlgorithmCurveBinding validates that the JWT algorithm matches the JWK curve. 419 + // This is critical for security - an attacker could claim alg: "ES256K" but provide 420 + // a P-256 key, potentially bypassing algorithm binding requirements. 421 + func validateAlgorithmCurveBinding(alg string, jwkMap map[string]interface{}) error { 422 + kty, ok := jwkMap["kty"].(string) 423 + if !ok { 424 + return fmt.Errorf("JWK missing kty") 425 + } 426 + 427 + // ECDSA algorithms require EC key type 428 + switch alg { 429 + case "ES256K", "ES256", "ES384", "ES512": 430 + if kty != "EC" { 431 + return fmt.Errorf("algorithm %s requires EC key type, got %s", alg, kty) 432 + } 433 + case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512": 434 + return fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg) 435 + default: 436 + return fmt.Errorf("unsupported DPoP algorithm: %s", alg) 437 + } 438 + 439 + // Validate curve matches algorithm 440 + crv, ok := jwkMap["crv"].(string) 441 + if !ok { 442 + return fmt.Errorf("EC JWK missing crv") 443 + } 444 + 445 + var expectedCurve string 446 + switch alg { 447 + case "ES256K": 448 + expectedCurve = "secp256k1" 449 + case "ES256": 450 + expectedCurve = "P-256" 451 + case "ES384": 452 + expectedCurve = "P-384" 453 + case "ES512": 454 + expectedCurve = "P-521" 455 + } 456 + 457 + if crv != expectedCurve { 458 + return fmt.Errorf("algorithm %s requires curve %s, got %s", alg, expectedCurve, crv) 459 + } 460 + 461 + return nil 462 + } 463 + 464 + // parseJWKToIndigoPublicKey parses a JWK map to an indigo PublicKey. 465 + // This returns indigo's PublicKey interface which supports all atProto curves 466 + // including secp256k1 (ES256K), P-256 (ES256), P-384 (ES384), and P-521 (ES512). 467 + func parseJWKToIndigoPublicKey(jwkMap map[string]interface{}) (indigoCrypto.PublicKey, error) { 422 468 // Convert map to JSON bytes for indigo's parser 423 469 jwkBytes, err := json.Marshal(jwkMap) 424 470 if err != nil { 425 471 return nil, fmt.Errorf("failed to serialize JWK: %w", err) 426 472 } 427 473 428 - // Try to parse with indigo's crypto package 474 + // Parse with indigo's crypto package - this supports all atProto curves 475 + // including secp256k1 (ES256K) which Go's crypto/elliptic doesn't support 429 476 pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes) 430 477 if err != nil { 431 478 return nil, fmt.Errorf("failed to parse JWK: %w", err) 432 479 } 433 480 434 - // Convert indigo's PublicKey to Go's ecdsa.PublicKey 435 - jwk, err := pubKey.JWK() 481 + return pubKey, nil 482 + } 483 + 484 + // parseJWTHeaderAndClaims manually parses a JWT's header and claims without using golang-jwt. 485 + // This is necessary to support ES256K (secp256k1) which golang-jwt doesn't recognize. 486 + func parseJWTHeaderAndClaims(tokenString string) (map[string]interface{}, *DPoPClaims, error) { 487 + parts := strings.Split(tokenString, ".") 488 + if len(parts) != 3 { 489 + return nil, nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) 490 + } 491 + 492 + // Decode header 493 + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) 436 494 if err != nil { 437 - return nil, fmt.Errorf("failed to get JWK from public key: %w", err) 495 + return nil, nil, fmt.Errorf("failed to decode JWT header: %w", err) 496 + } 497 + 498 + var header map[string]interface{} 499 + if err := json.Unmarshal(headerBytes, &header); err != nil { 500 + return nil, nil, fmt.Errorf("failed to parse JWT header: %w", err) 501 + } 502 + 503 + // Decode claims 504 + claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) 505 + if err != nil { 506 + return nil, nil, fmt.Errorf("failed to decode JWT claims: %w", err) 507 + } 508 + 509 + // Parse into raw map first to extract standard claims 510 + var rawClaims map[string]interface{} 511 + if err := json.Unmarshal(claimsBytes, &rawClaims); err != nil { 512 + return nil, nil, fmt.Errorf("failed to parse JWT claims: %w", err) 513 + } 514 + 515 + // Build DPoPClaims struct 516 + claims := &DPoPClaims{} 517 + 518 + // Extract jti 519 + if jti, ok := rawClaims["jti"].(string); ok { 520 + claims.ID = jti 438 521 } 439 522 440 - // Use our existing conversion function 441 - return atcryptoJWKToECDSAFromIndigoJWK(jwk) 442 - } 523 + // Extract iat (issued at) 524 + if iat, ok := rawClaims["iat"].(float64); ok { 525 + t := time.Unix(int64(iat), 0) 526 + claims.IssuedAt = jwt.NewNumericDate(t) 527 + } 443 528 444 - // atcryptoJWKToECDSAFromIndigoJWK converts an indigo JWK to Go ecdsa.PublicKey 445 - func atcryptoJWKToECDSAFromIndigoJWK(jwk *indigoCrypto.JWK) (*ecdsa.PublicKey, error) { 446 - if jwk.KeyType != "EC" { 447 - return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType) 529 + // Extract exp (expiration) if present 530 + if exp, ok := rawClaims["exp"].(float64); ok { 531 + t := time.Unix(int64(exp), 0) 532 + claims.ExpiresAt = jwt.NewNumericDate(t) 448 533 } 449 534 450 - xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) 451 - if err != nil { 452 - return nil, fmt.Errorf("invalid JWK X coordinate: %w", err) 535 + // Extract nbf (not before) if present 536 + if nbf, ok := rawClaims["nbf"].(float64); ok { 537 + t := time.Unix(int64(nbf), 0) 538 + claims.NotBefore = jwt.NewNumericDate(t) 453 539 } 454 - yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) 455 - if err != nil { 456 - return nil, fmt.Errorf("invalid JWK Y coordinate: %w", err) 540 + 541 + // Extract htm (HTTP method) 542 + if htm, ok := rawClaims["htm"].(string); ok { 543 + claims.HTTPMethod = htm 457 544 } 458 545 459 - var curve ecdsa.PublicKey 460 - switch jwk.Curve { 461 - case "P-256": 462 - curve.Curve = ecdsaP256Curve() 463 - case "P-384": 464 - curve.Curve = ecdsaP384Curve() 465 - case "P-521": 466 - curve.Curve = ecdsaP521Curve() 467 - default: 468 - return nil, fmt.Errorf("unsupported curve: %s", jwk.Curve) 546 + // Extract htu (HTTP URI) 547 + if htu, ok := rawClaims["htu"].(string); ok { 548 + claims.HTTPURI = htu 469 549 } 470 550 471 - curve.X = new(big.Int).SetBytes(xBytes) 472 - curve.Y = new(big.Int).SetBytes(yBytes) 551 + // Extract ath (access token hash) if present 552 + if ath, ok := rawClaims["ath"].(string); ok { 553 + claims.AccessTokenHash = ath 554 + } 473 555 474 - return &curve, nil 556 + return header, claims, nil 475 557 } 476 558 477 - // Helper functions for elliptic curves 478 - func ecdsaP256Curve() elliptic.Curve { return elliptic.P256() } 479 - func ecdsaP384Curve() elliptic.Curve { return elliptic.P384() } 480 - func ecdsaP521Curve() elliptic.Curve { return elliptic.P521() } 559 + // verifyJWTSignatureWithIndigo verifies a JWT signature using indigo's crypto package. 560 + // This is used instead of golang-jwt for algorithms not supported by golang-jwt (like ES256K). 561 + // It parses the JWT, extracts the signing input and signature, and uses indigo's 562 + // PublicKey.HashAndVerifyLenient() for verification. 563 + // 564 + // JWT format: header.payload.signature (all base64url-encoded) 565 + // Signature is verified over the raw bytes of "header.payload" 566 + // (indigo's HashAndVerifyLenient handles SHA-256 hashing internally) 567 + func verifyJWTSignatureWithIndigo(tokenString string, pubKey indigoCrypto.PublicKey) error { 568 + // Split the JWT into parts 569 + parts := strings.Split(tokenString, ".") 570 + if len(parts) != 3 { 571 + return fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) 572 + } 573 + 574 + // The signing input is "header.payload" (without decoding) 575 + signingInput := parts[0] + "." + parts[1] 576 + 577 + // Decode the signature from base64url 578 + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) 579 + if err != nil { 580 + return fmt.Errorf("failed to decode JWT signature: %w", err) 581 + } 582 + 583 + // Use indigo's verification - HashAndVerifyLenient handles hashing internally 584 + // and accepts both low-S and high-S signatures for maximum compatibility 585 + err = pubKey.HashAndVerifyLenient([]byte(signingInput), signature) 586 + if err != nil { 587 + return fmt.Errorf("signature verification failed: %w", err) 588 + } 589 + 590 + return nil 591 + } 481 592 482 593 // stripQueryFragment removes query and fragment from a URI 483 594 func stripQueryFragment(uri string) string {
+387
internal/atproto/auth/dpop_test.go
··· 11 11 "testing" 12 12 "time" 13 13 14 + indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto" 14 15 "github.com/golang-jwt/jwt/v5" 15 16 "github.com/google/uuid" 16 17 ) ··· 919 920 } 920 921 return base64.RawURLEncoding.EncodeToString(data) 921 922 } 923 + 924 + // === ES256K (secp256k1) Test Helpers === 925 + 926 + // testES256KKey holds a test ES256K key pair using indigo 927 + type testES256KKey struct { 928 + privateKey indigoCrypto.PrivateKey 929 + publicKey indigoCrypto.PublicKey 930 + jwk map[string]interface{} 931 + thumbprint string 932 + } 933 + 934 + // generateTestES256KKey generates a test ES256K (secp256k1) key pair and JWK 935 + func generateTestES256KKey(t *testing.T) *testES256KKey { 936 + t.Helper() 937 + 938 + privateKey, err := indigoCrypto.GeneratePrivateKeyK256() 939 + if err != nil { 940 + t.Fatalf("Failed to generate ES256K test key: %v", err) 941 + } 942 + 943 + publicKey, err := privateKey.PublicKey() 944 + if err != nil { 945 + t.Fatalf("Failed to get public key from ES256K private key: %v", err) 946 + } 947 + 948 + // Get the JWK representation 949 + jwkStruct, err := publicKey.JWK() 950 + if err != nil { 951 + t.Fatalf("Failed to get JWK from ES256K public key: %v", err) 952 + } 953 + jwk := map[string]interface{}{ 954 + "kty": jwkStruct.KeyType, 955 + "crv": jwkStruct.Curve, 956 + "x": jwkStruct.X, 957 + "y": jwkStruct.Y, 958 + } 959 + 960 + // Calculate thumbprint 961 + thumbprint, err := CalculateJWKThumbprint(jwk) 962 + if err != nil { 963 + t.Fatalf("Failed to calculate ES256K thumbprint: %v", err) 964 + } 965 + 966 + return &testES256KKey{ 967 + privateKey: privateKey, 968 + publicKey: publicKey, 969 + jwk: jwk, 970 + thumbprint: thumbprint, 971 + } 972 + } 973 + 974 + // createES256KDPoPProof creates a DPoP proof JWT using ES256K for testing 975 + func createES256KDPoPProof(t *testing.T, key *testES256KKey, method, uri string, iat time.Time, jti string) string { 976 + t.Helper() 977 + 978 + // Build claims 979 + claims := map[string]interface{}{ 980 + "jti": jti, 981 + "iat": iat.Unix(), 982 + "htm": method, 983 + "htu": uri, 984 + } 985 + 986 + // Build header 987 + header := map[string]interface{}{ 988 + "typ": "dpop+jwt", 989 + "alg": "ES256K", 990 + "jwk": key.jwk, 991 + } 992 + 993 + // Encode header and claims 994 + headerJSON, err := json.Marshal(header) 995 + if err != nil { 996 + t.Fatalf("Failed to marshal header: %v", err) 997 + } 998 + claimsJSON, err := json.Marshal(claims) 999 + if err != nil { 1000 + t.Fatalf("Failed to marshal claims: %v", err) 1001 + } 1002 + 1003 + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 1004 + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) 1005 + 1006 + // Sign with indigo 1007 + signingInput := headerB64 + "." + claimsB64 1008 + signature, err := key.privateKey.HashAndSign([]byte(signingInput)) 1009 + if err != nil { 1010 + t.Fatalf("Failed to sign ES256K proof: %v", err) 1011 + } 1012 + 1013 + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) 1014 + return signingInput + "." + signatureB64 1015 + } 1016 + 1017 + // === ES256K Tests === 1018 + 1019 + func TestVerifyDPoPProof_ES256K_Valid(t *testing.T) { 1020 + verifier := NewDPoPVerifier() 1021 + key := generateTestES256KKey(t) 1022 + 1023 + method := "POST" 1024 + uri := "https://api.example.com/resource" 1025 + iat := time.Now() 1026 + jti := uuid.New().String() 1027 + 1028 + proof := createES256KDPoPProof(t, key, method, uri, iat, jti) 1029 + 1030 + result, err := verifier.VerifyDPoPProof(proof, method, uri) 1031 + if err != nil { 1032 + t.Fatalf("VerifyDPoPProof failed for valid ES256K proof: %v", err) 1033 + } 1034 + 1035 + if result == nil { 1036 + t.Fatal("Expected non-nil proof result") 1037 + } 1038 + 1039 + if result.Claims.HTTPMethod != method { 1040 + t.Errorf("Expected method %s, got %s", method, result.Claims.HTTPMethod) 1041 + } 1042 + 1043 + if result.Claims.HTTPURI != uri { 1044 + t.Errorf("Expected URI %s, got %s", uri, result.Claims.HTTPURI) 1045 + } 1046 + 1047 + if result.Thumbprint != key.thumbprint { 1048 + t.Errorf("Expected thumbprint %s, got %s", key.thumbprint, result.Thumbprint) 1049 + } 1050 + } 1051 + 1052 + func TestVerifyDPoPProof_ES256K_InvalidSignature(t *testing.T) { 1053 + verifier := NewDPoPVerifier() 1054 + key := generateTestES256KKey(t) 1055 + wrongKey := generateTestES256KKey(t) 1056 + 1057 + method := "POST" 1058 + uri := "https://api.example.com/resource" 1059 + iat := time.Now() 1060 + jti := uuid.New().String() 1061 + 1062 + // Create proof with one key 1063 + proof := createES256KDPoPProof(t, key, method, uri, iat, jti) 1064 + 1065 + // Tamper by replacing JWK with wrong key 1066 + parts := splitJWT(proof) 1067 + header := parseJWTHeader(t, parts[0]) 1068 + header["jwk"] = wrongKey.jwk 1069 + modifiedHeader := encodeJSON(t, header) 1070 + tamperedProof := modifiedHeader + "." + parts[1] + "." + parts[2] 1071 + 1072 + _, err := verifier.VerifyDPoPProof(tamperedProof, method, uri) 1073 + if err == nil { 1074 + t.Error("Expected error for invalid ES256K signature, got nil") 1075 + } 1076 + if err != nil && !contains(err.Error(), "signature verification failed") { 1077 + t.Errorf("Expected signature verification error, got: %v", err) 1078 + } 1079 + } 1080 + 1081 + func TestCalculateJWKThumbprint_ES256K(t *testing.T) { 1082 + // Test thumbprint calculation for secp256k1 keys 1083 + key := generateTestES256KKey(t) 1084 + 1085 + thumbprint, err := CalculateJWKThumbprint(key.jwk) 1086 + if err != nil { 1087 + t.Fatalf("CalculateJWKThumbprint failed for ES256K: %v", err) 1088 + } 1089 + 1090 + if thumbprint == "" { 1091 + t.Error("Expected non-empty thumbprint for ES256K key") 1092 + } 1093 + 1094 + // Verify it's valid base64url 1095 + _, err = base64.RawURLEncoding.DecodeString(thumbprint) 1096 + if err != nil { 1097 + t.Errorf("ES256K thumbprint is not valid base64url: %v", err) 1098 + } 1099 + 1100 + // Verify length (SHA-256 produces 32 bytes = 43 base64url chars) 1101 + if len(thumbprint) != 43 { 1102 + t.Errorf("Expected ES256K thumbprint length 43, got %d", len(thumbprint)) 1103 + } 1104 + } 1105 + 1106 + // === Algorithm-Curve Binding Tests === 1107 + 1108 + func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256KWithP256Key(t *testing.T) { 1109 + verifier := NewDPoPVerifier() 1110 + key := generateTestES256Key(t) // P-256 key 1111 + 1112 + method := "POST" 1113 + uri := "https://api.example.com/resource" 1114 + iat := time.Now() 1115 + jti := uuid.New().String() 1116 + 1117 + // Create a proof claiming ES256K but using P-256 key 1118 + claims := &DPoPClaims{ 1119 + RegisteredClaims: jwt.RegisteredClaims{ 1120 + ID: jti, 1121 + IssuedAt: jwt.NewNumericDate(iat), 1122 + }, 1123 + HTTPMethod: method, 1124 + HTTPURI: uri, 1125 + } 1126 + 1127 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1128 + token.Header["typ"] = "dpop+jwt" 1129 + token.Header["alg"] = "ES256K" // Claim ES256K 1130 + token.Header["jwk"] = key.jwk // But use P-256 key 1131 + 1132 + proof, err := token.SignedString(key.privateKey) 1133 + if err != nil { 1134 + t.Fatalf("Failed to create test proof: %v", err) 1135 + } 1136 + 1137 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 1138 + if err == nil { 1139 + t.Error("Expected error for ES256K algorithm with P-256 curve, got nil") 1140 + } 1141 + if err != nil && !contains(err.Error(), "requires curve secp256k1") { 1142 + t.Errorf("Expected curve mismatch error, got: %v", err) 1143 + } 1144 + } 1145 + 1146 + func TestVerifyDPoPProof_AlgorithmCurveMismatch_ES256WithSecp256k1Key(t *testing.T) { 1147 + verifier := NewDPoPVerifier() 1148 + key := generateTestES256KKey(t) // secp256k1 key 1149 + 1150 + method := "POST" 1151 + uri := "https://api.example.com/resource" 1152 + iat := time.Now() 1153 + jti := uuid.New().String() 1154 + 1155 + // Build claims 1156 + claims := map[string]interface{}{ 1157 + "jti": jti, 1158 + "iat": iat.Unix(), 1159 + "htm": method, 1160 + "htu": uri, 1161 + } 1162 + 1163 + // Build header claiming ES256 but using secp256k1 key 1164 + header := map[string]interface{}{ 1165 + "typ": "dpop+jwt", 1166 + "alg": "ES256", // Claim ES256 1167 + "jwk": key.jwk, // But use secp256k1 key 1168 + } 1169 + 1170 + headerJSON, _ := json.Marshal(header) 1171 + claimsJSON, _ := json.Marshal(claims) 1172 + 1173 + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 1174 + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) 1175 + 1176 + signingInput := headerB64 + "." + claimsB64 1177 + signature, err := key.privateKey.HashAndSign([]byte(signingInput)) 1178 + if err != nil { 1179 + t.Fatalf("Failed to sign: %v", err) 1180 + } 1181 + 1182 + proof := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) 1183 + 1184 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 1185 + if err == nil { 1186 + t.Error("Expected error for ES256 algorithm with secp256k1 curve, got nil") 1187 + } 1188 + if err != nil && !contains(err.Error(), "requires curve P-256") { 1189 + t.Errorf("Expected curve mismatch error, got: %v", err) 1190 + } 1191 + } 1192 + 1193 + // === exp/nbf Validation Tests === 1194 + 1195 + func TestVerifyDPoPProof_ExpiredWithExpClaim(t *testing.T) { 1196 + verifier := NewDPoPVerifier() 1197 + key := generateTestES256Key(t) 1198 + 1199 + method := "POST" 1200 + uri := "https://api.example.com/resource" 1201 + iat := time.Now().Add(-2 * time.Minute) 1202 + exp := time.Now().Add(-1 * time.Minute) // Expired 1 minute ago 1203 + jti := uuid.New().String() 1204 + 1205 + claims := &DPoPClaims{ 1206 + RegisteredClaims: jwt.RegisteredClaims{ 1207 + ID: jti, 1208 + IssuedAt: jwt.NewNumericDate(iat), 1209 + ExpiresAt: jwt.NewNumericDate(exp), 1210 + }, 1211 + HTTPMethod: method, 1212 + HTTPURI: uri, 1213 + } 1214 + 1215 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1216 + token.Header["typ"] = "dpop+jwt" 1217 + token.Header["jwk"] = key.jwk 1218 + 1219 + proof, err := token.SignedString(key.privateKey) 1220 + if err != nil { 1221 + t.Fatalf("Failed to create test proof: %v", err) 1222 + } 1223 + 1224 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 1225 + if err == nil { 1226 + t.Error("Expected error for expired proof with exp claim, got nil") 1227 + } 1228 + if err != nil && !contains(err.Error(), "expired") { 1229 + t.Errorf("Expected expiration error, got: %v", err) 1230 + } 1231 + } 1232 + 1233 + func TestVerifyDPoPProof_NotYetValidWithNbfClaim(t *testing.T) { 1234 + verifier := NewDPoPVerifier() 1235 + key := generateTestES256Key(t) 1236 + 1237 + method := "POST" 1238 + uri := "https://api.example.com/resource" 1239 + iat := time.Now() 1240 + nbf := time.Now().Add(5 * time.Minute) // Not valid for another 5 minutes 1241 + jti := uuid.New().String() 1242 + 1243 + claims := &DPoPClaims{ 1244 + RegisteredClaims: jwt.RegisteredClaims{ 1245 + ID: jti, 1246 + IssuedAt: jwt.NewNumericDate(iat), 1247 + NotBefore: jwt.NewNumericDate(nbf), 1248 + }, 1249 + HTTPMethod: method, 1250 + HTTPURI: uri, 1251 + } 1252 + 1253 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1254 + token.Header["typ"] = "dpop+jwt" 1255 + token.Header["jwk"] = key.jwk 1256 + 1257 + proof, err := token.SignedString(key.privateKey) 1258 + if err != nil { 1259 + t.Fatalf("Failed to create test proof: %v", err) 1260 + } 1261 + 1262 + _, err = verifier.VerifyDPoPProof(proof, method, uri) 1263 + if err == nil { 1264 + t.Error("Expected error for not-yet-valid proof with nbf claim, got nil") 1265 + } 1266 + if err != nil && !contains(err.Error(), "not valid before") { 1267 + t.Errorf("Expected not-before error, got: %v", err) 1268 + } 1269 + } 1270 + 1271 + func TestVerifyDPoPProof_ValidWithExpClaimInFuture(t *testing.T) { 1272 + verifier := NewDPoPVerifier() 1273 + key := generateTestES256Key(t) 1274 + 1275 + method := "POST" 1276 + uri := "https://api.example.com/resource" 1277 + iat := time.Now() 1278 + exp := time.Now().Add(5 * time.Minute) // Valid for 5 more minutes 1279 + jti := uuid.New().String() 1280 + 1281 + claims := &DPoPClaims{ 1282 + RegisteredClaims: jwt.RegisteredClaims{ 1283 + ID: jti, 1284 + IssuedAt: jwt.NewNumericDate(iat), 1285 + ExpiresAt: jwt.NewNumericDate(exp), 1286 + }, 1287 + HTTPMethod: method, 1288 + HTTPURI: uri, 1289 + } 1290 + 1291 + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 1292 + token.Header["typ"] = "dpop+jwt" 1293 + token.Header["jwk"] = key.jwk 1294 + 1295 + proof, err := token.SignedString(key.privateKey) 1296 + if err != nil { 1297 + t.Fatalf("Failed to create test proof: %v", err) 1298 + } 1299 + 1300 + result, err := verifier.VerifyDPoPProof(proof, method, uri) 1301 + if err != nil { 1302 + t.Fatalf("VerifyDPoPProof failed for valid proof with exp in future: %v", err) 1303 + } 1304 + 1305 + if result == nil { 1306 + t.Error("Expected non-nil result for valid proof") 1307 + } 1308 + }