A community based topic aggregation platform built on atproto
1package auth
2
3import (
4 "context"
5 "crypto/ecdsa"
6 "crypto/elliptic"
7 "encoding/base64"
8 "fmt"
9 "math/big"
10 "strings"
11
12 "github.com/bluesky-social/indigo/atproto/atcrypto"
13 indigoIdentity "github.com/bluesky-social/indigo/atproto/identity"
14 "github.com/bluesky-social/indigo/atproto/syntax"
15)
16
17// DIDKeyFetcher fetches public keys from DID documents for JWT verification.
18// This is the primary method for atproto service authentication, where:
19// - The JWT issuer is the user's DID (e.g., did:plc:abc123)
20// - The signing key is published in the user's DID document
21// - Verification happens by resolving the DID and checking the signature
22type DIDKeyFetcher struct {
23 directory indigoIdentity.Directory
24}
25
26// NewDIDKeyFetcher creates a new DID-based key fetcher.
27func NewDIDKeyFetcher(directory indigoIdentity.Directory) *DIDKeyFetcher {
28 return &DIDKeyFetcher{
29 directory: directory,
30 }
31}
32
33// FetchPublicKey fetches the public key for verifying a JWT from the issuer's DID document.
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.
36func (f *DIDKeyFetcher) FetchPublicKey(ctx context.Context, issuer, token string) (interface{}, error) {
37 // Only handle DID issuers
38 if !strings.HasPrefix(issuer, "did:") {
39 return nil, fmt.Errorf("DIDKeyFetcher only handles DID issuers, got: %s", issuer)
40 }
41
42 // Parse the DID
43 did, err := syntax.ParseDID(issuer)
44 if err != nil {
45 return nil, fmt.Errorf("invalid DID format: %w", err)
46 }
47
48 // Resolve the DID to get the identity (includes public keys)
49 ident, err := f.directory.LookupDID(ctx, did)
50 if err != nil {
51 return nil, fmt.Errorf("failed to resolve DID %s: %w", issuer, err)
52 }
53
54 // Get the atproto signing key from the DID document
55 pubKey, err := ident.PublicKey()
56 if err != nil {
57 return nil, fmt.Errorf("failed to get public key from DID document: %w", err)
58 }
59
60 // Convert to JWK format to extract coordinates
61 jwk, err := pubKey.JWK()
62 if err != nil {
63 return nil, fmt.Errorf("failed to convert public key to JWK: %w", err)
64 }
65
66 // Convert atcrypto JWK to Go ecdsa.PublicKey
67 return atcryptoJWKToECDSA(jwk)
68}
69
70// atcryptoJWKToECDSA converts an atcrypto.JWK to a Go ecdsa.PublicKey
71func atcryptoJWKToECDSA(jwk *atcrypto.JWK) (*ecdsa.PublicKey, error) {
72 if jwk.KeyType != "EC" {
73 return nil, fmt.Errorf("unsupported JWK key type: %s (expected EC)", jwk.KeyType)
74 }
75
76 // Decode X and Y coordinates (base64url, no padding)
77 xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
78 if err != nil {
79 return nil, fmt.Errorf("invalid JWK X coordinate encoding: %w", err)
80 }
81 yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
82 if err != nil {
83 return nil, fmt.Errorf("invalid JWK Y coordinate encoding: %w", err)
84 }
85
86 var ecCurve elliptic.Curve
87 switch jwk.Curve {
88 case "P-256":
89 ecCurve = elliptic.P256()
90 case "P-384":
91 ecCurve = elliptic.P384()
92 case "P-521":
93 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 default:
100 return nil, fmt.Errorf("unsupported JWK curve: %s", jwk.Curve)
101 }
102
103 // Create the public key
104 pubKey := &ecdsa.PublicKey{
105 Curve: ecCurve,
106 X: new(big.Int).SetBytes(xBytes),
107 Y: new(big.Int).SetBytes(yBytes),
108 }
109
110 // Validate point is on curve
111 if !ecCurve.IsOnCurve(pubKey.X, pubKey.Y) {
112 return nil, fmt.Errorf("invalid public key: point not on curve")
113 }
114
115 return pubKey, nil
116}