A community based topic aggregation platform built on atproto

feat(auth): add ES256K support for JWT access token verification

Add secp256k1 (ES256K) support to JWT access token verification using
Bluesky's indigo crypto package. This enables authentication from
external PDSes that use ES256K-signed tokens.

Changes:
- jwt.go: Add ES256K detection and verification using indigo's crypto
- New verifyES256KToken() for ES256K-specific verification
- New parseJWKMapToIndigoPublicKey() to convert JWK to indigo key
- New verifyJWTSignatureWithIndigoKey() for indigo signature verification
- New parseJWTClaimsManually() to parse claims without golang-jwt
- Update ToPublicKey() to return JWK map for secp256k1 curves

- did_key_fetcher.go: Return indigo PublicKey for secp256k1 keys
- FetchPublicKey now returns indigoCrypto.PublicKey for secp256k1
- NIST curves (P-256, P-384, P-521) still return *ecdsa.PublicKey

This complements the DPoP ES256K support added earlier, completing
full ES256K support across the authentication stack.

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

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

+246 -14
+18 -12
internal/atproto/auth/did_key_fetcher.go
··· 9 9 "math/big" 10 10 "strings" 11 11 12 - "github.com/bluesky-social/indigo/atproto/atcrypto" 12 + indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto" 13 13 indigoIdentity "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 ) ··· 32 32 33 33 // FetchPublicKey fetches the public key for verifying a JWT from the issuer's DID document. 34 34 // For DID issuers (did:plc: or did:web:), resolves the DID and extracts the signing key. 35 - // Returns an *ecdsa.PublicKey suitable for use with jwt-go. 35 + // 36 + // Returns: 37 + // - indigoCrypto.PublicKey for secp256k1 (ES256K) keys - use indigo for verification 38 + // - *ecdsa.PublicKey for NIST curves (P-256, P-384, P-521) - compatible with golang-jwt 36 39 func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) { 37 40 // Only handle DID issuers 38 41 if !strings.HasPrefix(issuer, "did:") { ··· 57 60 return nil, fmt.Errorf("failed to get public key from DID document: %w", err) 58 61 } 59 62 60 - // Convert to JWK format to extract coordinates 63 + // Convert to JWK format to check curve type 61 64 jwk, err := pubKey.JWK() 62 65 if err != nil { 63 66 return nil, fmt.Errorf("failed to convert public key to JWK: %w", err) 64 67 } 65 68 66 - // Convert atcrypto JWK to Go ecdsa.PublicKey 69 + // For secp256k1 (ES256K), return indigo's PublicKey directly 70 + // since Go's crypto/ecdsa doesn't support this curve 71 + if jwk.Curve == "secp256k1" { 72 + return pubKey, nil 73 + } 74 + 75 + // For NIST curves, convert to Go's ecdsa.PublicKey for golang-jwt compatibility 67 76 return atcryptoJWKToECDSA(jwk) 68 77 } 69 78 70 - // atcryptoJWKToECDSA converts an atcrypto.JWK to a Go ecdsa.PublicKey 71 - func atcryptoJWKToECDSA(jwk *atcrypto.JWK) (*ecdsa.PublicKey, error) { 79 + // atcryptoJWKToECDSA converts an indigoCrypto.JWK to a Go ecdsa.PublicKey. 80 + // Note: secp256k1 is handled separately in FetchPublicKey by returning indigo's PublicKey directly. 81 + func atcryptoJWKToECDSA(jwk *indigoCrypto.JWK) (*ecdsa.PublicKey, error) { 72 82 if jwk.KeyType != "EC" { 73 83 return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType) 74 84 } ··· 91 101 ecCurve = elliptic.P384() 92 102 case "P-521": 93 103 ecCurve = elliptic.P521() 94 - case "secp256k1": 95 - // secp256k1 (K-256) is used by some atproto implementations 96 - // Go's standard library doesn't include secp256k1, but we can still 97 - // construct the key - jwt-go may not support it directly 98 - return nil, fmt.Errorf("secp256k1 curve requires special handling for JWT verification") 99 104 default: 100 - return nil, fmt.Errorf("unsupported JWK curve: %s", jwk.Curve) 105 + // secp256k1 should be handled before calling this function 106 + return nil, fmt.Errorf("unsupported JWK curve for Go ecdsa: %s (secp256k1 uses indigo)", jwk.Curve) 101 107 } 102 108 103 109 // Create the public key
+228 -2
internal/atproto/auth/jwt.go
··· 15 15 "sync" 16 16 "time" 17 17 18 + indigoCrypto "github.com/bluesky-social/indigo/atproto/atcrypto" 18 19 "github.com/golang-jwt/jwt/v5" 19 20 ) 20 21 ··· 273 274 return verifiedClaims, nil 274 275 } 275 276 276 - // verifyAsymmetricToken verifies a JWT using RSA or ECDSA with a public key from JWKS 277 + // verifyAsymmetricToken verifies a JWT using RSA or ECDSA with a public key from JWKS. 278 + // For ES256K (secp256k1), uses indigo's crypto package since golang-jwt doesn't support it. 277 279 func verifyAsymmetricToken(ctx context.Context, tokenString, issuer string, keyFetcher JWKSFetcher) (*Claims, error) { 280 + // Parse header to check algorithm 281 + header, err := ParseJWTHeader(tokenString) 282 + if err != nil { 283 + return nil, fmt.Errorf("failed to parse JWT header: %w", err) 284 + } 285 + 286 + // ES256K (secp256k1) requires special handling via indigo's crypto package 287 + // golang-jwt doesn't recognize ES256K as a valid signing method 288 + if header.Alg == "ES256K" { 289 + return verifyES256KToken(ctx, tokenString, issuer, keyFetcher) 290 + } 291 + 292 + // For standard algorithms (ES256, ES384, ES512, RS256, etc.), use golang-jwt 278 293 publicKey, err := keyFetcher.FetchPublicKey(ctx, issuer, tokenString) 279 294 if err != nil { 280 295 return nil, fmt.Errorf("failed to fetch public key: %w", err) ··· 310 325 return verifiedClaims, nil 311 326 } 312 327 328 + // verifyES256KToken verifies a JWT signed with ES256K (secp256k1) using indigo's crypto package. 329 + // This is necessary because golang-jwt doesn't support ES256K as a signing method. 330 + func verifyES256KToken(ctx context.Context, tokenString, issuer string, keyFetcher JWKSFetcher) (*Claims, error) { 331 + // Fetch the public key - for ES256K, the fetcher returns a JWK map or indigo PublicKey 332 + keyData, err := keyFetcher.FetchPublicKey(ctx, issuer, tokenString) 333 + if err != nil { 334 + return nil, fmt.Errorf("failed to fetch public key for ES256K: %w", err) 335 + } 336 + 337 + // Convert to indigo PublicKey based on what the fetcher returned 338 + var pubKey indigoCrypto.PublicKey 339 + switch k := keyData.(type) { 340 + case indigoCrypto.PublicKey: 341 + // Already an indigo PublicKey (from DIDKeyFetcher or updated JWKSFetcher) 342 + pubKey = k 343 + case map[string]interface{}: 344 + // Raw JWK map - parse with indigo 345 + pubKey, err = parseJWKMapToIndigoPublicKey(k) 346 + if err != nil { 347 + return nil, fmt.Errorf("failed to parse ES256K JWK: %w", err) 348 + } 349 + default: 350 + return nil, fmt.Errorf("ES256K verification requires indigo PublicKey or JWK map, got %T", keyData) 351 + } 352 + 353 + // Verify signature using indigo 354 + if err := verifyJWTSignatureWithIndigoKey(tokenString, pubKey); err != nil { 355 + return nil, fmt.Errorf("ES256K signature verification failed: %w", err) 356 + } 357 + 358 + // Parse claims (signature already verified) 359 + claims, err := parseJWTClaimsManually(tokenString) 360 + if err != nil { 361 + return nil, fmt.Errorf("failed to parse ES256K JWT claims: %w", err) 362 + } 363 + 364 + if err := validateClaims(claims); err != nil { 365 + return nil, err 366 + } 367 + 368 + return claims, nil 369 + } 370 + 371 + // parseJWKMapToIndigoPublicKey converts a JWK map to an indigo PublicKey. 372 + // This uses indigo's crypto package which supports all atProto curves including secp256k1. 373 + func parseJWKMapToIndigoPublicKey(jwkMap map[string]interface{}) (indigoCrypto.PublicKey, error) { 374 + // Convert map to JSON bytes for indigo's parser 375 + jwkBytes, err := json.Marshal(jwkMap) 376 + if err != nil { 377 + return nil, fmt.Errorf("failed to serialize JWK: %w", err) 378 + } 379 + 380 + // Parse with indigo's crypto package - supports all atProto curves 381 + pubKey, err := indigoCrypto.ParsePublicJWKBytes(jwkBytes) 382 + if err != nil { 383 + return nil, fmt.Errorf("failed to parse JWK with indigo: %w", err) 384 + } 385 + 386 + return pubKey, nil 387 + } 388 + 389 + // verifyJWTSignatureWithIndigoKey verifies a JWT signature using indigo's crypto package. 390 + // This works for all ECDSA algorithms including ES256K (secp256k1). 391 + func verifyJWTSignatureWithIndigoKey(tokenString string, pubKey indigoCrypto.PublicKey) error { 392 + parts := strings.Split(tokenString, ".") 393 + if len(parts) != 3 { 394 + return fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) 395 + } 396 + 397 + // The signing input is "header.payload" (without decoding) 398 + signingInput := parts[0] + "." + parts[1] 399 + 400 + // Decode the signature from base64url 401 + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) 402 + if err != nil { 403 + return fmt.Errorf("failed to decode JWT signature: %w", err) 404 + } 405 + 406 + // Use indigo's verification - HashAndVerifyLenient handles hashing internally 407 + // and accepts both low-S and high-S signatures for maximum compatibility 408 + if err := pubKey.HashAndVerifyLenient([]byte(signingInput), signature); err != nil { 409 + return fmt.Errorf("signature verification failed: %w", err) 410 + } 411 + 412 + return nil 413 + } 414 + 415 + // parseJWTClaimsManually parses JWT claims without using golang-jwt. 416 + // This is used for ES256K tokens where golang-jwt would reject the algorithm. 417 + func parseJWTClaimsManually(tokenString string) (*Claims, error) { 418 + parts := strings.Split(tokenString, ".") 419 + if len(parts) != 3 { 420 + return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) 421 + } 422 + 423 + // Decode claims 424 + claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) 425 + if err != nil { 426 + return nil, fmt.Errorf("failed to decode JWT claims: %w", err) 427 + } 428 + 429 + // Parse into raw map first 430 + var rawClaims map[string]interface{} 431 + if err := json.Unmarshal(claimsBytes, &rawClaims); err != nil { 432 + return nil, fmt.Errorf("failed to parse JWT claims: %w", err) 433 + } 434 + 435 + // Build Claims struct 436 + claims := &Claims{} 437 + 438 + // Extract sub (subject/DID) 439 + if sub, ok := rawClaims["sub"].(string); ok { 440 + claims.Subject = sub 441 + } 442 + 443 + // Extract iss (issuer) 444 + if iss, ok := rawClaims["iss"].(string); ok { 445 + claims.Issuer = iss 446 + } 447 + 448 + // Extract aud (audience) - can be string or array 449 + switch aud := rawClaims["aud"].(type) { 450 + case string: 451 + claims.Audience = jwt.ClaimStrings{aud} 452 + case []interface{}: 453 + for _, a := range aud { 454 + if s, ok := a.(string); ok { 455 + claims.Audience = append(claims.Audience, s) 456 + } 457 + } 458 + } 459 + 460 + // Extract exp (expiration) 461 + if exp, ok := rawClaims["exp"].(float64); ok { 462 + t := time.Unix(int64(exp), 0) 463 + claims.ExpiresAt = jwt.NewNumericDate(t) 464 + } 465 + 466 + // Extract iat (issued at) 467 + if iat, ok := rawClaims["iat"].(float64); ok { 468 + t := time.Unix(int64(iat), 0) 469 + claims.IssuedAt = jwt.NewNumericDate(t) 470 + } 471 + 472 + // Extract nbf (not before) 473 + if nbf, ok := rawClaims["nbf"].(float64); ok { 474 + t := time.Unix(int64(nbf), 0) 475 + claims.NotBefore = jwt.NewNumericDate(t) 476 + } 477 + 478 + // Extract jti (JWT ID) 479 + if jti, ok := rawClaims["jti"].(string); ok { 480 + claims.ID = jti 481 + } 482 + 483 + // Extract scope 484 + if scope, ok := rawClaims["scope"].(string); ok { 485 + claims.Scope = scope 486 + } 487 + 488 + // Extract cnf (confirmation) for DPoP binding 489 + if cnf, ok := rawClaims["cnf"].(map[string]interface{}); ok { 490 + claims.Confirmation = cnf 491 + } 492 + 493 + return claims, nil 494 + } 495 + 313 496 // validateClaims performs additional validation on JWT claims 314 497 func validateClaims(claims *Claims) error { 315 498 now := time.Now() ··· 381 564 Y string `json:"y,omitempty"` // EC y coordinate 382 565 } 383 566 384 - // ToPublicKey converts a JWK to a public key (RSA or ECDSA) 567 + // ToPublicKey converts a JWK to a public key (RSA, ECDSA, or indigo for secp256k1). 568 + // 569 + // Returns: 570 + // - *rsa.PublicKey for RSA keys 571 + // - *ecdsa.PublicKey for NIST EC curves (P-256, P-384, P-521) 572 + // - map[string]interface{} for secp256k1 (ES256K) - parsed by indigo 385 573 func (j *JWK) ToPublicKey() (interface{}, error) { 386 574 switch j.Kty { 387 575 case "RSA": 388 576 return j.toRSAPublicKey() 389 577 case "EC": 578 + // For secp256k1, return raw JWK map for indigo to parse 579 + if j.Crv == "secp256k1" { 580 + return j.toJWKMap(), nil 581 + } 390 582 return j.toECPublicKey() 391 583 default: 392 584 return nil, fmt.Errorf("unsupported key type: %s", j.Kty) 393 585 } 586 + } 587 + 588 + // toJWKMap converts the JWK struct to a map for indigo parsing 589 + func (j *JWK) toJWKMap() map[string]interface{} { 590 + m := map[string]interface{}{ 591 + "kty": j.Kty, 592 + } 593 + if j.Kid != "" { 594 + m["kid"] = j.Kid 595 + } 596 + if j.Alg != "" { 597 + m["alg"] = j.Alg 598 + } 599 + if j.Use != "" { 600 + m["use"] = j.Use 601 + } 602 + // RSA fields 603 + if j.N != "" { 604 + m["n"] = j.N 605 + } 606 + if j.E != "" { 607 + m["e"] = j.E 608 + } 609 + // EC fields 610 + if j.Crv != "" { 611 + m["crv"] = j.Crv 612 + } 613 + if j.X != "" { 614 + m["x"] = j.X 615 + } 616 + if j.Y != "" { 617 + m["y"] = j.Y 618 + } 619 + return m 394 620 } 395 621 396 622 // toRSAPublicKey converts a JWK to an RSA public key