Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
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}