···
···
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).
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)
return nil, fmt.Errorf("failed to parse DPoP proof: %w", err)
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)
189
-
alg, ok := token.Header["alg"].(string)
186
+
alg, ok := header["alg"].(string)
return nil, fmt.Errorf("invalid DPoP proof: missing alg header")
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"]
return nil, fmt.Errorf("invalid DPoP proof: missing jwk header")
···
return nil, fmt.Errorf("invalid DPoP proof: jwk must be an object")
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)
208
+
// Parse the public key using indigo's crypto package
209
+
// This supports all atProto curves including secp256k1 (ES256K)
210
+
publicKey, err := parseJWKToIndigoPublicKey(jwkMap)
return nil, fmt.Errorf("invalid DPoP proof JWK: %w", err)
···
return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
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
222
-
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
223
-
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
225
-
case "ES384", "ES512":
226
-
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
227
-
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
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)
233
-
return nil, fmt.Errorf("unsupported DPoP algorithm: %s", alg)
235
-
return publicKey, 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 {
return nil, fmt.Errorf("DPoP proof signature verification failed: %w", err)
241
-
claims, ok := verifiedToken.Claims.(*DPoPClaims)
243
-
return nil, fmt.Errorf("invalid DPoP claims type")
···
return fmt.Errorf("DPoP proof is too old (issued %v ago, max %v)", now.Sub(iat), v.MaxProofAge)
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)
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)
// SECURITY: Check for replay attack using jti
// Per RFC 9449 Section 11.1, servers SHOULD prevent replay attacks
···
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)
424
+
return fmt.Errorf("JWK missing kty")
427
+
// ECDSA algorithms require EC key type
429
+
case "ES256K", "ES256", "ES384", "ES512":
431
+
return fmt.Errorf("algorithm %s requires EC key type, got %s", alg, kty)
433
+
case "RS256", "RS384", "RS512", "PS256", "PS384", "PS512":
434
+
return fmt.Errorf("RSA algorithms not yet supported for DPoP: %s", alg)
436
+
return fmt.Errorf("unsupported DPoP algorithm: %s", alg)
439
+
// Validate curve matches algorithm
440
+
crv, ok := jwkMap["crv"].(string)
442
+
return fmt.Errorf("EC JWK missing crv")
445
+
var expectedCurve string
448
+
expectedCurve = "secp256k1"
450
+
expectedCurve = "P-256"
452
+
expectedCurve = "P-384"
454
+
expectedCurve = "P-521"
457
+
if crv != expectedCurve {
458
+
return fmt.Errorf("algorithm %s requires curve %s, got %s", alg, expectedCurve, crv)
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) {
// Convert map to JSON bytes for indigo's parser
jwkBytes, err := json.Marshal(jwkMap)
return nil, fmt.Errorf("failed to serialize JWK: %w", err)
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
pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes)
return nil, fmt.Errorf("failed to parse JWK: %w", err)
434
-
// Convert indigo's PublicKey to Go's ecdsa.PublicKey
435
-
jwk, err := pubKey.JWK()
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))
493
+
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
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)
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)
504
+
claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
506
+
return nil, nil, fmt.Errorf("failed to decode JWT claims: %w", err)
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)
515
+
// Build DPoPClaims struct
516
+
claims := &DPoPClaims{}
519
+
if jti, ok := rawClaims["jti"].(string); ok {
440
-
// Use our existing conversion function
441
-
return atcryptoJWKToECDSAFromIndigoJWK(jwk)
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)
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)
450
-
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
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)
454
-
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
456
-
return nil, fmt.Errorf("invalid JWK Y coordinate: %w", err)
541
+
// Extract htm (HTTP method)
542
+
if htm, ok := rawClaims["htm"].(string); ok {
543
+
claims.HTTPMethod = htm
459
-
var curve ecdsa.PublicKey
462
-
curve.Curve = ecdsaP256Curve()
464
-
curve.Curve = ecdsaP384Curve()
466
-
curve.Curve = ecdsaP521Curve()
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
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
556
+
return header, claims, nil
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.
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))
574
+
// The signing input is "header.payload" (without decoding)
575
+
signingInput := parts[0] + "." + parts[1]
577
+
// Decode the signature from base64url
578
+
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
580
+
return fmt.Errorf("failed to decode JWT signature: %w", err)
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)
587
+
return fmt.Errorf("signature verification failed: %w", err)
// stripQueryFragment removes query and fragment from a URI
func stripQueryFragment(uri string) string {