Skip to content

File tree

9 files changed

+449
-1
lines changed

9 files changed

+449
-1
lines changed

internal/botauth/botauth.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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+
keyValidity = 3600 // 1 hour validity
30+
)
31+
32+
var GlobalInstance *bothAuth
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 bothAuth struct {
45+
directoryURL string
46+
privateKey []byte
47+
publicJWK *jsonWebKey
48+
thumbprint string
49+
}
50+
51+
func NewBothAuth(directoryURL string, privateKey, publicKey []byte) (*bothAuth, error) {
52+
if !strings.HasPrefix(directoryURL, "https://") {
53+
return nil, fmt.Errorf("directory URL %q must start with https://", directoryURL)
54+
}
55+
56+
if privateKeyLength := len(privateKey); privateKeyLength != ed25519.PrivateKeySize {
57+
return nil, fmt.Errorf("invalid private key size: got %d instead of %d", privateKeyLength, ed25519.PrivateKeySize)
58+
}
59+
60+
publicJWK := &jsonWebKey{
61+
KeyType: "OKP",
62+
Curve: "Ed25519",
63+
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
64+
}
65+
66+
thumbprint, err := computeJWKThumbprint(publicJWK)
67+
if err != nil {
68+
return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
69+
}
70+
return &bothAuth{
71+
directoryURL: directoryURL,
72+
privateKey: privateKey,
73+
publicJWK: publicJWK,
74+
thumbprint: thumbprint,
75+
}, nil
76+
}
77+
78+
func (ba *bothAuth) DirectoryURL() string {
79+
absoluteURL, err := url.JoinPath(ba.directoryURL, "/.well-known/http-message-signatures-directory")
80+
if err != nil {
81+
return ba.directoryURL
82+
}
83+
return absoluteURL
84+
}
85+
86+
func (ba *bothAuth) ServeKeyDirectory(w http.ResponseWriter, r *http.Request) {
87+
body, err := json.Marshal(jsonWebKeySet{Keys: []jsonWebKey{*ba.publicJWK}})
88+
if err != nil {
89+
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
90+
return
91+
}
92+
93+
created := time.Now().Unix()
94+
expires := created + keyValidity
95+
96+
signatureMetadata := []signatureMetadata{
97+
{name: "alg", value: "ed25519"},
98+
99+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.6.1
100+
{name: "keyid", value: ba.thumbprint},
101+
102+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.2.1
103+
{name: "created", value: created},
104+
105+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.4.1
106+
{name: "expires", value: expires},
107+
108+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-anti-replay
109+
{name: "nonce", value: base64.StdEncoding.EncodeToString(crypto.GenerateRandomBytes(64))},
110+
111+
// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.8.1
112+
{name: "tag", value: "http-message-signatures-directory"},
113+
}
114+
115+
signatureComponents := []signatureComponent{
116+
{name: "@authority", value: r.Host},
117+
}
118+
119+
signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
120+
signatureInput := `sig1=` + signatureParams
121+
122+
signature, err := signComponents(ba.privateKey, signatureComponents, signatureParams)
123+
if err != nil {
124+
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
125+
return
126+
}
127+
128+
// Set headers
129+
w.Header().Set("Content-Type", "application/http-message-signatures-directory+json")
130+
w.Header().Set("Signature", `sig1=:`+signature+`:`)
131+
w.Header().Set("Signature-Input", signatureInput)
132+
w.Header().Set("Cache-Control", "max-age=86400")
133+
134+
w.WriteHeader(http.StatusOK)
135+
w.Write(body)
136+
}
137+
138+
func (ba *bothAuth) SignRequest(req *http.Request) error {
139+
created := time.Now().Unix()
140+
expires := created + keyValidity
141+
142+
// @authority component
143+
// https://www.rfc-editor.org/rfc/rfc9421#section-2.2.3
144+
authority := req.Host
145+
if authority == "" {
146+
authority = req.URL.Host
147+
}
148+
149+
signatureAgent := `"` + ba.directoryURL + `"`
150+
151+
signatureMetadata := []signatureMetadata{
152+
{name: "alg", value: "ed25519"},
153+
154+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.6.1
155+
{name: "keyid", value: ba.thumbprint},
156+
157+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.2.1
158+
{name: "created", value: created},
159+
160+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.4.1
161+
{name: "expires", value: expires},
162+
163+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-anti-replay
164+
{name: "nonce", value: base64.StdEncoding.EncodeToString(crypto.GenerateRandomBytes(64))},
165+
166+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.8.1
167+
{name: "tag", value: "web-bot-auth"},
168+
}
169+
170+
// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-signature-agent
171+
signatureComponents := []signatureComponent{
172+
{name: "@authority", value: authority},
173+
{name: "signature-agent", value: signatureAgent},
174+
}
175+
176+
signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
177+
signatureInput := `sig1=` + signatureParams
178+
179+
signature, err := signComponents(ba.privateKey, signatureComponents, signatureParams)
180+
if err != nil {
181+
return fmt.Errorf("failed to sign request: %w", err)
182+
}
183+
184+
// Add headers to request
185+
req.Header.Set("Signature-Agent", signatureAgent)
186+
req.Header.Set("Signature-Input", signatureInput)
187+
req.Header.Set("Signature", `sig1=:`+signature+`:`)
188+
189+
return nil
190+
}
191+
192+
// computeJWKThumbprint computes RFC 7638 JWK thumbprint
193+
// https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A.3
194+
func computeJWKThumbprint(jwk *jsonWebKey) (string, error) {
195+
canonical := `{"crv":"` + jwk.Curve + `","kty":"` + jwk.KeyType + `","x":"` + jwk.PublicKey + `"}`
196+
hash := sha256.Sum256([]byte(canonical))
197+
return base64.RawURLEncoding.EncodeToString(hash[:]), nil
198+
}
199+
200+
type signatureMetadata struct {
201+
name string
202+
value any
203+
}
204+
205+
// https://www.rfc-editor.org/rfc/rfc9421#name-signature-parameters
206+
func generateSignatureParams(components []signatureComponent, signatureMetadata []signatureMetadata) string {
207+
var componentNames []string
208+
209+
for _, component := range components {
210+
componentNames = append(componentNames, `"`+component.name+`"`)
211+
}
212+
213+
var metadataParts []string
214+
for _, meta := range signatureMetadata {
215+
switch v := meta.value.(type) {
216+
case string:
217+
metadataParts = append(metadataParts, meta.name+`="`+v+`"`)
218+
case int64:
219+
metadataParts = append(metadataParts, meta.name+`=`+strconv.FormatInt(v, 10))
220+
}
221+
}
222+
223+
return `(` + strings.Join(componentNames, ` `) + `);` + strings.Join(metadataParts, ";")
224+
}
225+
226+
type signatureComponent struct {
227+
name string
228+
value string
229+
}
230+
231+
// https://www.rfc-editor.org/rfc/rfc9421#name-signing-request-components-
232+
func signComponents(privateKey ed25519.PrivateKey, components []signatureComponent, signatureParams string) (string, error) {
233+
var signatureBase strings.Builder
234+
235+
// Build signature base
236+
for _, comp := range components {
237+
signatureBase.WriteString(`"` + comp.name + `": ` + comp.value + "\n")
238+
}
239+
240+
signatureBase.WriteString(`"@signature-params": ` + signatureParams)
241+
242+
// Sign the signature base
243+
signature := ed25519.Sign(privateKey, []byte(signatureBase.String()))
244+
245+
return base64.StdEncoding.EncodeToString(signature), nil
246+
}

internal/botauth/botauth_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package botauth
5+
6+
import (
7+
"crypto/ed25519"
8+
"encoding/base64"
9+
"testing"
10+
)
11+
12+
func TestComputeThumbprint(t *testing.T) {
13+
// Test values taken from https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A.3
14+
jwk := &jsonWebKey{
15+
KeyType: "OKP",
16+
Curve: "Ed25519",
17+
PublicKey: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
18+
}
19+
20+
expectedThumbprint := "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"
21+
22+
thumbprint, err := computeJWKThumbprint(jwk)
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
27+
if thumbprint != expectedThumbprint {
28+
t.Fatalf("Invalid thumbprint, got %q instead of %q", thumbprint, expectedThumbprint)
29+
}
30+
}
31+
32+
func TestGenerateSignatureParams(t *testing.T) {
33+
// Example taken from https://www.rfc-editor.org/rfc/rfc9421#name-signing-a-request-using-ed2
34+
signatureComponents := []signatureComponent{
35+
{name: "date", value: "Tue, 20 Apr 2021 02:07:55 GMT"},
36+
{name: "@method", value: "POST"},
37+
{name: "@path", value: "/foo"},
38+
{name: "@authority", value: "example.com"},
39+
{name: "content-type", value: "application/json"},
40+
{name: "content-length", value: "18"},
41+
}
42+
43+
signatureMetadata := []signatureMetadata{
44+
{name: "created", value: int64(1618884473)},
45+
{name: "keyid", value: "test-key-ed25519"},
46+
}
47+
48+
generatedSignatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
49+
expectedSignatureParams := `("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"`
50+
51+
if generatedSignatureParams != expectedSignatureParams {
52+
t.Fatalf("Invalid signature params, got %s instead of %s", generatedSignatureParams, expectedSignatureParams)
53+
}
54+
}
55+
56+
func TestSignComponents(t *testing.T) {
57+
// Test key from https://www.rfc-editor.org/rfc/rfc9421#name-example-ed25519-test-key
58+
privateKeyBase64 := "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU"
59+
privateKey, err := base64.RawURLEncoding.DecodeString(privateKeyBase64)
60+
if err != nil {
61+
t.Fatal(err)
62+
}
63+
64+
// Example taken from https://www.rfc-editor.org/rfc/rfc9421#name-signing-a-request-using-ed2
65+
signatureComponents := []signatureComponent{
66+
{name: "date", value: "Tue, 20 Apr 2021 02:07:55 GMT"},
67+
{name: "@method", value: "POST"},
68+
{name: "@path", value: "/foo"},
69+
{name: "@authority", value: "example.com"},
70+
{name: "content-type", value: "application/json"},
71+
{name: "content-length", value: "18"},
72+
}
73+
74+
signatureMetadata := []signatureMetadata{
75+
{name: "created", value: int64(1618884473)},
76+
{name: "keyid", value: "test-key-ed25519"},
77+
}
78+
79+
generatedSignatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
80+
signature, err := signComponents(ed25519.NewKeyFromSeed(privateKey), signatureComponents, generatedSignatureParams)
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
85+
// Expected signature taken from https://www.rfc-editor.org/rfc/rfc9421#name-signing-a-request-using-ed2
86+
expectedSignature := "wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw=="
87+
88+
if signature != expectedSignature {
89+
t.Fatalf("Invalid signature, got %q instead of %q", signature, expectedSignature)
90+
}
91+
}

internal/cli/cli.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111
"log/slog"
1212
"os"
1313

14+
"miniflux.app/v2/internal/botauth"
1415
"miniflux.app/v2/internal/config"
16+
"miniflux.app/v2/internal/crypto"
1517
"miniflux.app/v2/internal/database"
1618
"miniflux.app/v2/internal/proxyrotator"
1719
"miniflux.app/v2/internal/storage"
@@ -249,6 +251,44 @@ func Parse() {
249251
}
250252
}
251253

254+
if config.Opts.WebBotAuth() {
255+
hasKeys, err := store.HasWebAuthBothKeys()
256+
if err != nil {
257+
printErrorAndExit(fmt.Errorf("unable to check for existing web bot auth keys: %v", err))
258+
}
259+
260+
var privateKey, publicKey []byte
261+
if !hasKeys {
262+
slog.Info("Web bot authentication is enabled but no keys are present in the database, generating a new key pair")
263+
privateKey, publicKey, err := crypto.GenerateEd25519Keys()
264+
if err != nil {
265+
printErrorAndExit(fmt.Errorf("unable to generate web bot auth keys: %v", err))
266+
}
267+
268+
if err := store.SaveWebAuthBothKeys(privateKey, publicKey); err != nil {
269+
printErrorAndExit(fmt.Errorf("unable to save web bot auth keys: %v", err))
270+
}
271+
272+
slog.Info("A new Ed25519 key pair has been generated for web bot authentication")
273+
} else {
274+
privateKey, publicKey, err = store.WebAuthBothKeys()
275+
if err != nil {
276+
printErrorAndExit(fmt.Errorf("unable to fetch web bot auth keys: %v", err))
277+
}
278+
}
279+
280+
botauth.GlobalInstance, err = botauth.NewBothAuth(
281+
config.Opts.BaseURL(),
282+
privateKey,
283+
publicKey,
284+
)
285+
if err != nil {
286+
printErrorAndExit(fmt.Errorf("unable to initialize web bot auth: %v", err))
287+
}
288+
289+
slog.Info("Web bot authentication is enabled", slog.String("directory_url", botauth.GlobalInstance.DirectoryURL()))
290+
}
291+
252292
if flagRefreshFeeds {
253293
refreshFeeds(store)
254294
return

0 commit comments

Comments
 (0)