Skip to content

File tree

9 files changed

+658
-16
lines changed

9 files changed

+658
-16
lines changed

internal/botauth/botauth.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package botauth // import "miniflux.app/v2/internal/botauth"
5+
6+
// Resources:
7+
//
8+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory
9+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture
10+
// https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/
11+
// https://github.com/thibmeu/http-message-signatures-directory
12+
13+
import (
14+
"crypto/ed25519"
15+
"crypto/sha256"
16+
"encoding/base64"
17+
"encoding/json"
18+
"fmt"
19+
"net/http"
20+
"net/url"
21+
"strconv"
22+
"strings"
23+
"time"
24+
25+
"miniflux.app/v2/internal/crypto"
26+
)
27+
28+
const (
29+
signatureValidity = 3600 // 1 hour validity
30+
)
31+
32+
var GlobalInstance *botAuth
33+
34+
type jsonWebKey struct {
35+
KeyType string `json:"kty"`
36+
Curve string `json:"crv"`
37+
PublicKey string `json:"x"`
38+
}
39+
40+
type jsonWebKeySet struct {
41+
Keys []jsonWebKey `json:"keys"`
42+
}
43+
44+
type keyPair struct {
45+
privateKey []byte
46+
publicKey []byte
47+
publicJWK *jsonWebKey
48+
thumbprint string
49+
}
50+
51+
func NewKeyPair(privateKey, publicKey []byte) (*keyPair, error) {
52+
if len(privateKey) != ed25519.PrivateKeySize {
53+
return nil, fmt.Errorf("invalid private key size: got %d instead of %d", len(privateKey), ed25519.PrivateKeySize)
54+
}
55+
if len(publicKey) != ed25519.PublicKeySize {
56+
return nil, fmt.Errorf("invalid public key size: got %d instead of %d", len(publicKey), ed25519.PublicKeySize)
57+
}
58+
59+
publicJWK := &jsonWebKey{
60+
KeyType: "OKP",
61+
Curve: "Ed25519",
62+
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
63+
}
64+
65+
thumbprint, err := computeJWKThumbprint(publicJWK)
66+
if err != nil {
67+
return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
68+
}
69+
70+
return &keyPair{
71+
privateKey: privateKey,
72+
publicKey: publicKey,
73+
publicJWK: publicJWK,
74+
thumbprint: thumbprint,
75+
}, nil
76+
}
77+
78+
type KeyPairs []*keyPair
79+
80+
func (kps KeyPairs) jsonWebKeySet() jsonWebKeySet {
81+
var keys []jsonWebKey
82+
for _, kp := range kps {
83+
keys = append(keys, *kp.publicJWK)
84+
}
85+
return jsonWebKeySet{Keys: keys}
86+
}
87+
88+
type botAuth struct {
89+
directoryURL string
90+
keys KeyPairs
91+
}
92+
93+
func NewBothAuth(directoryURL string, keys KeyPairs) (*botAuth, error) {
94+
if !strings.HasPrefix(directoryURL, "https://") {
95+
return nil, fmt.Errorf("directory URL %q must start with https://", directoryURL)
96+
}
97+
98+
if len(keys) == 0 {
99+
return nil, fmt.Errorf("at least one key pair is required")
100+
}
101+
102+
return &botAuth{
103+
directoryURL: directoryURL,
104+
keys: keys,
105+
}, nil
106+
}
107+
108+
func (ba *botAuth) DirectoryURL() string {
109+
absoluteURL, err := url.JoinPath(ba.directoryURL, "/.well-known/http-message-signatures-directory")
110+
if err != nil {
111+
return ba.directoryURL
112+
}
113+
return absoluteURL
114+
}
115+
116+
func (ba *botAuth) ServeKeyDirectory(w http.ResponseWriter, r *http.Request) {
117+
body, err := json.Marshal(ba.keys.jsonWebKeySet())
118+
if err != nil {
119+
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
120+
return
121+
}
122+
123+
created := time.Now().Unix()
124+
expires := created + signatureValidity
125+
signatures := make([]string, len(ba.keys))
126+
signatureInputs := make([]string, len(ba.keys))
127+
128+
for i, key := range ba.keys {
129+
signatureMetadata := []signatureMetadata{
130+
{name: "alg", value: "ed25519"},
131+
132+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.6.1
133+
{name: "keyid", value: key.thumbprint},
134+
135+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.8.1
136+
{name: "tag", value: "http-message-signatures-directory"},
137+
138+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.2.1
139+
{name: "created", value: created},
140+
141+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.4.1
142+
{name: "expires", value: expires},
143+
}
144+
145+
signatureComponents := []signatureComponent{
146+
{name: "@authority", value: r.Host},
147+
}
148+
149+
signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
150+
151+
signature, err := signComponents(key.privateKey, signatureComponents, signatureParams)
152+
if err != nil {
153+
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
154+
return
155+
}
156+
157+
signatureLabel := `sig` + strconv.Itoa(i+1)
158+
signatureInputs[i] = signatureLabel + `=` + signatureParams
159+
signatures[i] = signatureLabel + `=:` + signature + `:`
160+
}
161+
162+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#name-application-http-message-si
163+
w.Header().Set("Content-Type", "application/http-message-signatures-directory+json")
164+
w.Header().Set("Signature-Input", strings.Join(signatureInputs, ", "))
165+
w.Header().Set("Signature", strings.Join(signatures, ", "))
166+
167+
// Verifiers can cache keys directory for 1 day.
168+
w.Header().Set("Cache-Control", "max-age=86400")
169+
170+
w.WriteHeader(http.StatusOK)
171+
w.Write(body)
172+
}
173+
174+
func (ba *botAuth) SignRequest(req *http.Request) error {
175+
if len(ba.keys) == 0 {
176+
return fmt.Errorf("no key pairs available to sign the request")
177+
}
178+
179+
firstKeyPair := ba.keys[0]
180+
created := time.Now().Unix()
181+
expires := created + signatureValidity
182+
183+
// @authority component
184+
// https://www.rfc-editor.org/rfc/rfc9421#section-2.2.3
185+
authority := req.Host
186+
if authority == "" {
187+
authority = req.URL.Host
188+
}
189+
190+
signatureAgent := `"` + ba.directoryURL + `"`
191+
192+
signatureMetadata := []signatureMetadata{
193+
{name: "alg", value: "ed25519"},
194+
195+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.6.1
196+
{name: "keyid", value: firstKeyPair.thumbprint},
197+
198+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.8.1
199+
{name: "tag", value: "web-bot-auth"},
200+
201+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.2.1
202+
{name: "created", value: created},
203+
204+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.4.1
205+
{name: "expires", value: expires},
206+
207+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-anti-replay
208+
{name: "nonce", value: base64.StdEncoding.EncodeToString(crypto.GenerateRandomBytes(64))},
209+
}
210+
211+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-signature-agent
212+
signatureComponents := []signatureComponent{
213+
{name: "@authority", value: authority},
214+
{name: "signature-agent", value: signatureAgent},
215+
}
216+
217+
signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
218+
signatureInput := `sig1=` + signatureParams
219+
220+
signature, err := signComponents(firstKeyPair.privateKey, signatureComponents, signatureParams)
221+
if err != nil {
222+
return fmt.Errorf("failed to sign request: %w", err)
223+
}
224+
225+
// Add headers to request
226+
req.Header.Set("Signature-Agent", signatureAgent)
227+
req.Header.Set("Signature-Input", signatureInput)
228+
req.Header.Set("Signature", `sig1=:`+signature+`:`)
229+
230+
return nil
231+
}
232+
233+
// https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A.3
234+
func computeJWKThumbprint(jwk *jsonWebKey) (string, error) {
235+
canonical := `{"crv":"` + jwk.Curve + `","kty":"` + jwk.KeyType + `","x":"` + jwk.PublicKey + `"}`
236+
hash := sha256.Sum256([]byte(canonical))
237+
return base64.RawURLEncoding.EncodeToString(hash[:]), nil
238+
}
239+
240+
type signatureMetadata struct {
241+
name string
242+
value any
243+
}
244+
245+
// https://www.rfc-editor.org/rfc/rfc9421#name-signature-parameters
246+
func generateSignatureParams(components []signatureComponent, signatureMetadata []signatureMetadata) string {
247+
var componentNames []string
248+
249+
for _, component := range components {
250+
componentNames = append(componentNames, `"`+component.name+`"`)
251+
}
252+
253+
var metadataParts []string
254+
for _, meta := range signatureMetadata {
255+
switch v := meta.value.(type) {
256+
case string:
257+
metadataParts = append(metadataParts, meta.name+`="`+v+`"`)
258+
case int64:
259+
metadataParts = append(metadataParts, meta.name+`=`+strconv.FormatInt(v, 10))
260+
}
261+
}
262+
263+
return `(` + strings.Join(componentNames, ` `) + `);` + strings.Join(metadataParts, ";")
264+
}
265+
266+
type signatureComponent struct {
267+
name string
268+
value string
269+
}
270+
271+
// https://www.rfc-editor.org/rfc/rfc9421#name-signing-request-components-
272+
func signComponents(privateKey ed25519.PrivateKey, components []signatureComponent, signatureParams string) (string, error) {
273+
var signatureBase strings.Builder
274+
275+
// Build signature base
276+
for _, comp := range components {
277+
signatureBase.WriteString(`"` + comp.name + `": ` + comp.value + "\n")
278+
}
279+
280+
signatureBase.WriteString(`"@signature-params": ` + signatureParams)
281+
282+
// Sign the signature base
283+
signature := ed25519.Sign(privateKey, []byte(signatureBase.String()))
284+
285+
return base64.StdEncoding.EncodeToString(signature), nil
286+
}

0 commit comments

Comments
 (0)