Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 125 lines 3.2 kB view raw
1package dpop 2 3import ( 4 "crypto/ecdsa" 5 "crypto/elliptic" 6 "crypto/rand" 7 "crypto/sha256" 8 "encoding/base64" 9 "encoding/json" 10 "fmt" 11 "math/big" 12 "time" 13 14 "github.com/golang-jwt/jwt/v5" 15) 16 17// KeyPair holds an ES256 key pair for DPoP use. 18type KeyPair struct { 19 Private *ecdsa.PrivateKey 20} 21 22// Generate creates a new ES256 key pair. 23func Generate() (*KeyPair, error) { 24 priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 25 if err != nil { 26 return nil, fmt.Errorf("generate DPoP key: %w", err) 27 } 28 return &KeyPair{Private: priv}, nil 29} 30 31// PublicJWK returns the public key as a JWK map (for embedding in DPoP JWT header). 32func (kp *KeyPair) PublicJWK() map[string]interface{} { 33 pub := kp.Private.PublicKey 34 return map[string]interface{}{ 35 "kty": "EC", 36 "crv": "P-256", 37 "x": base64.RawURLEncoding.EncodeToString(pub.X.Bytes()), 38 "y": base64.RawURLEncoding.EncodeToString(pub.Y.Bytes()), 39 } 40} 41 42// MarshalPrivate serializes the private key to JWK JSON for session storage. 43func (kp *KeyPair) MarshalPrivate() ([]byte, error) { 44 pub := kp.Private.PublicKey 45 jwk := map[string]interface{}{ 46 "kty": "EC", 47 "crv": "P-256", 48 "x": base64.RawURLEncoding.EncodeToString(pub.X.Bytes()), 49 "y": base64.RawURLEncoding.EncodeToString(pub.Y.Bytes()), 50 "d": base64.RawURLEncoding.EncodeToString(kp.Private.D.Bytes()), 51 } 52 return json.Marshal(jwk) 53} 54 55// UnmarshalPrivate deserializes a private key from JWK JSON. 56func UnmarshalPrivate(data []byte) (*KeyPair, error) { 57 var jwk map[string]json.RawMessage 58 if err := json.Unmarshal(data, &jwk); err != nil { 59 return nil, err 60 } 61 62 decode := func(key string) ([]byte, error) { 63 var s string 64 if err := json.Unmarshal(jwk[key], &s); err != nil { 65 return nil, err 66 } 67 return base64.RawURLEncoding.DecodeString(s) 68 } 69 70 xb, err := decode("x") 71 if err != nil { 72 return nil, err 73 } 74 yb, err := decode("y") 75 if err != nil { 76 return nil, err 77 } 78 db, err := decode("d") 79 if err != nil { 80 return nil, err 81 } 82 83 priv := &ecdsa.PrivateKey{ 84 PublicKey: ecdsa.PublicKey{ 85 Curve: elliptic.P256(), 86 X: new(big.Int).SetBytes(xb), 87 Y: new(big.Int).SetBytes(yb), 88 }, 89 D: new(big.Int).SetBytes(db), 90 } 91 return &KeyPair{Private: priv}, nil 92} 93 94// randJTI generates a random 16-byte hex string for use as the JWT ID. 95func randJTI() string { 96 b := make([]byte, 16) 97 rand.Read(b) 98 return fmt.Sprintf("%x", b) 99} 100 101// Proof builds a DPoP proof JWT for the given HTTP method and URL. 102// nonce is optional. accessToken, when non-empty, causes an "ath" claim to be 103// included (base64url SHA-256 of the token) as required by RFC 9449 §4.2 when 104// presenting a DPoP-bound access token. 105func (kp *KeyPair) Proof(method, htu, nonce, accessToken string) (string, error) { 106 claims := jwt.MapClaims{ 107 "jti": randJTI(), 108 "htm": method, 109 "htu": htu, 110 "iat": time.Now().Unix(), 111 } 112 if nonce != "" { 113 claims["nonce"] = nonce 114 } 115 if accessToken != "" { 116 h := sha256.Sum256([]byte(accessToken)) 117 claims["ath"] = base64.RawURLEncoding.EncodeToString(h[:]) 118 } 119 120 token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) 121 token.Header["typ"] = "dpop+jwt" 122 token.Header["jwk"] = kp.PublicJWK() 123 124 return token.SignedString(kp.Private) 125}