فهرست منبع

feat: add Web Bot Auth support

https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory

https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture
Frédéric Guillot 6 ماه پیش
والد
کامیت
08138eb5a4

+ 286 - 0
internal/botauth/botauth.go

@@ -0,0 +1,286 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package botauth // import "miniflux.app/v2/internal/botauth"
+
+// Resources:
+//
+// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory
+// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture
+// https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/
+// https://github.com/thibmeu/http-message-signatures-directory
+
+import (
+	"crypto/ed25519"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"miniflux.app/v2/internal/crypto"
+)
+
+const (
+	signatureValidity = 3600 // 1 hour validity
+)
+
+var GlobalInstance *botAuth
+
+type jsonWebKey struct {
+	KeyType   string `json:"kty"`
+	Curve     string `json:"crv"`
+	PublicKey string `json:"x"`
+}
+
+type jsonWebKeySet struct {
+	Keys []jsonWebKey `json:"keys"`
+}
+
+type keyPair struct {
+	privateKey []byte
+	publicKey  []byte
+	publicJWK  *jsonWebKey
+	thumbprint string
+}
+
+func NewKeyPair(privateKey, publicKey []byte) (*keyPair, error) {
+	if len(privateKey) != ed25519.PrivateKeySize {
+		return nil, fmt.Errorf("invalid private key size: got %d instead of %d", len(privateKey), ed25519.PrivateKeySize)
+	}
+	if len(publicKey) != ed25519.PublicKeySize {
+		return nil, fmt.Errorf("invalid public key size: got %d instead of %d", len(publicKey), ed25519.PublicKeySize)
+	}
+
+	publicJWK := &jsonWebKey{
+		KeyType:   "OKP",
+		Curve:     "Ed25519",
+		PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
+	}
+
+	thumbprint, err := computeJWKThumbprint(publicJWK)
+	if err != nil {
+		return nil, fmt.Errorf("failed to calculate JWK thumbprint: %w", err)
+	}
+
+	return &keyPair{
+		privateKey: privateKey,
+		publicKey:  publicKey,
+		publicJWK:  publicJWK,
+		thumbprint: thumbprint,
+	}, nil
+}
+
+type KeyPairs []*keyPair
+
+func (kps KeyPairs) jsonWebKeySet() jsonWebKeySet {
+	var keys []jsonWebKey
+	for _, kp := range kps {
+		keys = append(keys, *kp.publicJWK)
+	}
+	return jsonWebKeySet{Keys: keys}
+}
+
+type botAuth struct {
+	directoryURL string
+	keys         KeyPairs
+}
+
+func NewBothAuth(directoryURL string, keys KeyPairs) (*botAuth, error) {
+	if !strings.HasPrefix(directoryURL, "https://") {
+		return nil, fmt.Errorf("directory URL %q must start with https://", directoryURL)
+	}
+
+	if len(keys) == 0 {
+		return nil, fmt.Errorf("at least one key pair is required")
+	}
+
+	return &botAuth{
+		directoryURL: directoryURL,
+		keys:         keys,
+	}, nil
+}
+
+func (ba *botAuth) DirectoryURL() string {
+	absoluteURL, err := url.JoinPath(ba.directoryURL, "/.well-known/http-message-signatures-directory")
+	if err != nil {
+		return ba.directoryURL
+	}
+	return absoluteURL
+}
+
+func (ba *botAuth) ServeKeyDirectory(w http.ResponseWriter, r *http.Request) {
+	body, err := json.Marshal(ba.keys.jsonWebKeySet())
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+
+	created := time.Now().Unix()
+	expires := created + signatureValidity
+	signatures := make([]string, len(ba.keys))
+	signatureInputs := make([]string, len(ba.keys))
+
+	for i, key := range ba.keys {
+		signatureMetadata := []signatureMetadata{
+			{name: "alg", value: "ed25519"},
+
+			// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.6.1
+			{name: "keyid", value: key.thumbprint},
+
+			// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.8.1
+			{name: "tag", value: "http-message-signatures-directory"},
+
+			// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.2.1
+			{name: "created", value: created},
+
+			// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#section-5.2-6.4.1
+			{name: "expires", value: expires},
+		}
+
+		signatureComponents := []signatureComponent{
+			{name: "@authority", value: r.Host},
+		}
+
+		signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
+
+		signature, err := signComponents(key.privateKey, signatureComponents, signatureParams)
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		signatureLabel := `sig` + strconv.Itoa(i+1)
+		signatureInputs[i] = signatureLabel + `=` + signatureParams
+		signatures[i] = signatureLabel + `=:` + signature + `:`
+	}
+
+	// https://datatracker.ietf.org/doc/html/draft-meunier-http-message-signatures-directory-01#name-application-http-message-si
+	w.Header().Set("Content-Type", "application/http-message-signatures-directory+json")
+	w.Header().Set("Signature-Input", strings.Join(signatureInputs, ", "))
+	w.Header().Set("Signature", strings.Join(signatures, ", "))
+
+	// Verifiers can cache keys directory for 1 day.
+	w.Header().Set("Cache-Control", "max-age=86400")
+
+	w.WriteHeader(http.StatusOK)
+	w.Write(body)
+}
+
+func (ba *botAuth) SignRequest(req *http.Request) error {
+	if len(ba.keys) == 0 {
+		return fmt.Errorf("no key pairs available to sign the request")
+	}
+
+	firstKeyPair := ba.keys[0]
+	created := time.Now().Unix()
+	expires := created + signatureValidity
+
+	// @authority component
+	// https://www.rfc-editor.org/rfc/rfc9421#section-2.2.3
+	authority := req.Host
+	if authority == "" {
+		authority = req.URL.Host
+	}
+
+	signatureAgent := `"` + ba.directoryURL + `"`
+
+	signatureMetadata := []signatureMetadata{
+		{name: "alg", value: "ed25519"},
+
+		// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.6.1
+		{name: "keyid", value: firstKeyPair.thumbprint},
+
+		// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.8.1
+		{name: "tag", value: "web-bot-auth"},
+
+		// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.2.1
+		{name: "created", value: created},
+
+		// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#section-4.2-5.4.1
+		{name: "expires", value: expires},
+
+		// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-anti-replay
+		{name: "nonce", value: base64.StdEncoding.EncodeToString(crypto.GenerateRandomBytes(64))},
+	}
+
+	// https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture-02#name-signature-agent
+	signatureComponents := []signatureComponent{
+		{name: "@authority", value: authority},
+		{name: "signature-agent", value: signatureAgent},
+	}
+
+	signatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
+	signatureInput := `sig1=` + signatureParams
+
+	signature, err := signComponents(firstKeyPair.privateKey, signatureComponents, signatureParams)
+	if err != nil {
+		return fmt.Errorf("failed to sign request: %w", err)
+	}
+
+	// Add headers to request
+	req.Header.Set("Signature-Agent", signatureAgent)
+	req.Header.Set("Signature-Input", signatureInput)
+	req.Header.Set("Signature", `sig1=:`+signature+`:`)
+
+	return nil
+}
+
+// https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A.3
+func computeJWKThumbprint(jwk *jsonWebKey) (string, error) {
+	canonical := `{"crv":"` + jwk.Curve + `","kty":"` + jwk.KeyType + `","x":"` + jwk.PublicKey + `"}`
+	hash := sha256.Sum256([]byte(canonical))
+	return base64.RawURLEncoding.EncodeToString(hash[:]), nil
+}
+
+type signatureMetadata struct {
+	name  string
+	value any
+}
+
+// https://www.rfc-editor.org/rfc/rfc9421#name-signature-parameters
+func generateSignatureParams(components []signatureComponent, signatureMetadata []signatureMetadata) string {
+	var componentNames []string
+
+	for _, component := range components {
+		componentNames = append(componentNames, `"`+component.name+`"`)
+	}
+
+	var metadataParts []string
+	for _, meta := range signatureMetadata {
+		switch v := meta.value.(type) {
+		case string:
+			metadataParts = append(metadataParts, meta.name+`="`+v+`"`)
+		case int64:
+			metadataParts = append(metadataParts, meta.name+`=`+strconv.FormatInt(v, 10))
+		}
+	}
+
+	return `(` + strings.Join(componentNames, ` `) + `);` + strings.Join(metadataParts, ";")
+}
+
+type signatureComponent struct {
+	name  string
+	value string
+}
+
+// https://www.rfc-editor.org/rfc/rfc9421#name-signing-request-components-
+func signComponents(privateKey ed25519.PrivateKey, components []signatureComponent, signatureParams string) (string, error) {
+	var signatureBase strings.Builder
+
+	// Build signature base
+	for _, comp := range components {
+		signatureBase.WriteString(`"` + comp.name + `": ` + comp.value + "\n")
+	}
+
+	signatureBase.WriteString(`"@signature-params": ` + signatureParams)
+
+	// Sign the signature base
+	signature := ed25519.Sign(privateKey, []byte(signatureBase.String()))
+
+	return base64.StdEncoding.EncodeToString(signature), nil
+}

+ 221 - 0
internal/botauth/botauth_test.go

@@ -0,0 +1,221 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package botauth
+
+import (
+	"crypto/ed25519"
+	"encoding/base64"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestComputeThumbprint(t *testing.T) {
+	// Test values taken from https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A.3
+	jwk := &jsonWebKey{
+		KeyType:   "OKP",
+		Curve:     "Ed25519",
+		PublicKey: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
+	}
+
+	expectedThumbprint := "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"
+
+	thumbprint, err := computeJWKThumbprint(jwk)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if thumbprint != expectedThumbprint {
+		t.Fatalf("Invalid thumbprint, got %q instead of %q", thumbprint, expectedThumbprint)
+	}
+}
+
+func TestGenerateSignatureParams(t *testing.T) {
+	// Example taken from https://www.rfc-editor.org/rfc/rfc9421#name-signing-a-request-using-ed2
+	signatureComponents := []signatureComponent{
+		{name: "date", value: "Tue, 20 Apr 2021 02:07:55 GMT"},
+		{name: "@method", value: "POST"},
+		{name: "@path", value: "/foo"},
+		{name: "@authority", value: "example.com"},
+		{name: "content-type", value: "application/json"},
+		{name: "content-length", value: "18"},
+	}
+
+	signatureMetadata := []signatureMetadata{
+		{name: "created", value: int64(1618884473)},
+		{name: "keyid", value: "test-key-ed25519"},
+	}
+
+	generatedSignatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
+	expectedSignatureParams := `("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"`
+
+	if generatedSignatureParams != expectedSignatureParams {
+		t.Fatalf("Invalid signature params, got %s instead of %s", generatedSignatureParams, expectedSignatureParams)
+	}
+}
+
+func TestSignComponents(t *testing.T) {
+	// Test key from https://www.rfc-editor.org/rfc/rfc9421#name-example-ed25519-test-key
+	privateKeyBase64 := "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU"
+	privateKey, err := base64.RawURLEncoding.DecodeString(privateKeyBase64)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Example taken from https://www.rfc-editor.org/rfc/rfc9421#name-signing-a-request-using-ed2
+	signatureComponents := []signatureComponent{
+		{name: "date", value: "Tue, 20 Apr 2021 02:07:55 GMT"},
+		{name: "@method", value: "POST"},
+		{name: "@path", value: "/foo"},
+		{name: "@authority", value: "example.com"},
+		{name: "content-type", value: "application/json"},
+		{name: "content-length", value: "18"},
+	}
+
+	signatureMetadata := []signatureMetadata{
+		{name: "created", value: int64(1618884473)},
+		{name: "keyid", value: "test-key-ed25519"},
+	}
+
+	generatedSignatureParams := generateSignatureParams(signatureComponents, signatureMetadata)
+	signature, err := signComponents(ed25519.NewKeyFromSeed(privateKey), signatureComponents, generatedSignatureParams)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Expected signature taken from https://www.rfc-editor.org/rfc/rfc9421#name-signing-a-request-using-ed2
+	expectedSignature := "wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw=="
+
+	if signature != expectedSignature {
+		t.Fatalf("Invalid signature, got %q instead of %q", signature, expectedSignature)
+	}
+}
+
+func TestServeDirectoryHandler(t *testing.T) {
+	// Test keys from https://www.rfc-editor.org/rfc/rfc9421#name-example-ed25519-test-key
+	privateKeyBase64 := "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU"
+	privateKeyDecoded, err := base64.RawURLEncoding.DecodeString(privateKeyBase64)
+	if err != nil {
+		t.Fatal(err)
+	}
+	privateKey := ed25519.NewKeyFromSeed(privateKeyDecoded)
+
+	publicKeyBase64 := "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
+	publicKeyDecoded, err := base64.RawURLEncoding.DecodeString(publicKeyBase64)
+	if err != nil {
+		t.Fatal(err)
+	}
+	publicKey := ed25519.PublicKey(publicKeyDecoded)
+
+	keyPair, err := NewKeyPair(privateKey, publicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	botAuth, err := NewBothAuth("https://example.com/", KeyPairs{keyPair})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, err := http.NewRequest("GET", "/.well-known/http-message-signatures-directory", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+	handler := http.HandlerFunc(botAuth.ServeKeyDirectory)
+	handler.ServeHTTP(rr, req)
+
+	if status := rr.Code; status != http.StatusOK {
+		t.Fatalf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
+	}
+
+	expectedBody := `{"keys":[{"kty":"OKP","crv":"Ed25519","x":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}]}`
+	if rr.Body.String() != expectedBody {
+		t.Fatalf("handler returned unexpected body: got %v want %v", rr.Body.String(), expectedBody)
+	}
+
+	expectedContentType := "application/http-message-signatures-directory+json"
+	if rr.Header().Get("Content-Type") != expectedContentType {
+		t.Fatalf("handler returned unexpected content type: got %v want %v", rr.Header().Get("Content-Type"), expectedContentType)
+	}
+
+	expectedCacheControl := "max-age=86400"
+	if rr.Header().Get("Cache-Control") != expectedCacheControl {
+		t.Fatalf("handler returned unexpected cache control: got %v want %v", rr.Header().Get("Cache-Control"), expectedCacheControl)
+	}
+
+	signatureHeaderValue := rr.Header().Get("Signature")
+	if signatureHeaderValue == "" {
+		t.Fatal("handler did not return a Signature header")
+	}
+
+	if !strings.HasPrefix(signatureHeaderValue, "sig1=:") || !strings.HasSuffix(signatureHeaderValue, ":") {
+		t.Fatalf("handler returned unexpected signature: got %v", signatureHeaderValue)
+	}
+
+	expectedSignatureInputPrefix := `sig1=("@authority");alg="ed25519";keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";tag="http-message-signatures-directory";created=`
+	signatureInput := rr.Header().Get("Signature-Input")
+	if !strings.HasPrefix(signatureInput, expectedSignatureInputPrefix) {
+		t.Fatalf("handler returned unexpected signature input: got %v want prefix %v", signatureInput, expectedSignatureInputPrefix)
+	}
+}
+
+func TestSignRequest(t *testing.T) {
+	// Test keys from https://www.rfc-editor.org/rfc/rfc9421#name-example-ed25519-test-key
+	privateKeyBase64 := "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU"
+	privateKeyDecoded, err := base64.RawURLEncoding.DecodeString(privateKeyBase64)
+	if err != nil {
+		t.Fatal(err)
+	}
+	privateKey := ed25519.NewKeyFromSeed(privateKeyDecoded)
+
+	publicKeyBase64 := "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
+	publicKeyDecoded, err := base64.RawURLEncoding.DecodeString(publicKeyBase64)
+	if err != nil {
+		t.Fatal(err)
+	}
+	publicKey := ed25519.PublicKey(publicKeyDecoded)
+
+	keyPair, err := NewKeyPair(privateKey, publicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	botAuth, err := NewBothAuth("https://signature-agent.test", KeyPairs{keyPair})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, err := http.NewRequest("GET", "https://example.org", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = botAuth.SignRequest(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	signatureAgentHeaderValue := req.Header.Get("Signature-Agent")
+	if signatureAgentHeaderValue != `"https://signature-agent.test"` {
+		t.Fatalf("request has unexpected Signature-Agent header: got %v", signatureAgentHeaderValue)
+	}
+
+	signatureHeaderValue := req.Header.Get("Signature")
+	if signatureHeaderValue == "" {
+		t.Fatal("request did not get a Signature header")
+	}
+
+	if !strings.HasPrefix(signatureHeaderValue, "sig1=:") || !strings.HasSuffix(signatureHeaderValue, ":") {
+		t.Fatalf("request has unexpected signature: got %v", signatureHeaderValue)
+	}
+
+	expectedSignatureInputPrefix := `sig1=("@authority" "signature-agent");alg="ed25519";keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";tag="web-bot-auth";created=`
+	signatureInput := req.Header.Get("Signature-Input")
+	if !strings.HasPrefix(signatureInput, expectedSignatureInputPrefix) {
+		t.Fatalf("request has unexpected signature input: got %v want prefix %v", signatureInput, expectedSignatureInputPrefix)
+	}
+}

+ 59 - 15
internal/cli/cli.go

@@ -11,6 +11,7 @@ import (
 	"log/slog"
 	"os"
 
+	"miniflux.app/v2/internal/botauth"
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/database"
 	"miniflux.app/v2/internal/proxyrotator"
@@ -20,21 +21,22 @@ import (
 )
 
 const (
-	flagInfoHelp             = "Show build information"
-	flagVersionHelp          = "Show application version"
-	flagMigrateHelp          = "Run SQL migrations"
-	flagFlushSessionsHelp    = "Flush all sessions (disconnect users)"
-	flagCreateAdminHelp      = "Create an admin user from an interactive terminal"
-	flagResetPasswordHelp    = "Reset user password"
-	flagResetFeedErrorsHelp  = "Clear all feed errors for all users"
-	flagDebugModeHelp        = "Show debug logs"
-	flagConfigFileHelp       = "Load configuration file"
-	flagConfigDumpHelp       = "Print parsed configuration values"
-	flagHealthCheckHelp      = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
-	flagRefreshFeedsHelp     = "Refresh a batch of feeds and exit"
-	flagRunCleanupTasksHelp  = "Run cleanup tasks (delete old sessions and archives old entries)"
-	flagExportUserFeedsHelp  = "Export user feeds (provide the username as argument)"
-	flagResetNextCheckAtHelp = "Reset the next check time for all feeds"
+	flagInfoHelp               = "Show build information"
+	flagVersionHelp            = "Show application version"
+	flagMigrateHelp            = "Run SQL migrations"
+	flagFlushSessionsHelp      = "Flush all sessions (disconnect users)"
+	flagCreateAdminHelp        = "Create an admin user from an interactive terminal"
+	flagResetPasswordHelp      = "Reset user password"
+	flagResetFeedErrorsHelp    = "Clear all feed errors for all users"
+	flagDebugModeHelp          = "Show debug logs"
+	flagConfigFileHelp         = "Load configuration file"
+	flagConfigDumpHelp         = "Print parsed configuration values"
+	flagHealthCheckHelp        = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
+	flagRefreshFeedsHelp       = "Refresh a batch of feeds and exit"
+	flagRunCleanupTasksHelp    = "Run cleanup tasks (delete old sessions and archives old entries)"
+	flagExportUserFeedsHelp    = "Export user feeds (provide the username as argument)"
+	flagResetNextCheckAtHelp   = "Reset the next check time for all feeds"
+	flagGenerateNewBotKeysHelp = "Generate a new Ed25519 key pair for web bot authentication and exit"
 )
 
 // Parse parses command line arguments.
@@ -56,6 +58,7 @@ func Parse() {
 		flagRefreshFeeds         bool
 		flagRunCleanupTasks      bool
 		flagExportUserFeeds      string
+		flagGenerateNewBotKeys   bool
 	)
 
 	flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
@@ -76,6 +79,7 @@ func Parse() {
 	flag.BoolVar(&flagRefreshFeeds, "refresh-feeds", false, flagRefreshFeedsHelp)
 	flag.BoolVar(&flagRunCleanupTasks, "run-cleanup-tasks", false, flagRunCleanupTasksHelp)
 	flag.StringVar(&flagExportUserFeeds, "export-user-feeds", "", flagExportUserFeedsHelp)
+	flag.BoolVar(&flagGenerateNewBotKeys, "generate-new-bot-keys", false, flagGenerateNewBotKeysHelp)
 	flag.Parse()
 
 	cfg := config.NewConfigParser()
@@ -226,6 +230,15 @@ func Parse() {
 		return
 	}
 
+	if flagGenerateNewBotKeys {
+		slog.Info("Generating a new Ed25519 key pair for web bot authentication")
+		if err := store.CreateWebAuthBothKeys(); err != nil {
+			printErrorAndExit(fmt.Errorf("unable to create web bot auth keys: %v", err))
+		}
+		slog.Info("A new Ed25519 key pair has been generated for web bot authentication")
+		return
+	}
+
 	// Run migrations and start the daemon.
 	if config.Opts.RunMigrations() {
 		if err := database.Migrate(db); err != nil {
@@ -249,6 +262,37 @@ func Parse() {
 		}
 	}
 
+	if config.Opts.WebBotAuth() {
+		hasKeys, err := store.HasWebAuthBothKeys()
+		if err != nil {
+			printErrorAndExit(fmt.Errorf("unable to check for existing web bot auth keys: %v", err))
+		}
+
+		if !hasKeys {
+			slog.Info("Web bot authentication is enabled but no keys are present in the database, generating a new key pair")
+			if err := store.CreateWebAuthBothKeys(); err != nil {
+				printErrorAndExit(fmt.Errorf("unable to create web bot auth keys: %v", err))
+			}
+
+			slog.Info("A new Ed25519 key pair has been generated for web bot authentication")
+		}
+
+		keys, err := store.WebAuthBothKeys()
+		if err != nil {
+			printErrorAndExit(fmt.Errorf("unable to fetch web bot auth keys: %v", err))
+		}
+
+		botauth.GlobalInstance, err = botauth.NewBothAuth(
+			config.Opts.BaseURL(),
+			keys,
+		)
+		if err != nil {
+			printErrorAndExit(fmt.Errorf("unable to initialize web bot auth: %v", err))
+		}
+
+		slog.Info("Web bot authentication is enabled", slog.String("directory_url", botauth.GlobalInstance.DirectoryURL()))
+	}
+
 	if flagRefreshFeeds {
 		refreshFeeds(store)
 		return

+ 9 - 0
internal/config/options.go

@@ -564,6 +564,11 @@ func NewConfigOptions() *configOptions {
 				RawValue:        "0",
 				ValueType:       boolType,
 			},
+			"WEB_BOT_AUTH": {
+				ParsedBoolValue: false,
+				RawValue:        "0",
+				ValueType:       boolType,
+			},
 			"WORKER_POOL_SIZE": {
 				ParsedIntValue: 16,
 				RawValue:       "16",
@@ -952,6 +957,10 @@ func (c *configOptions) WebAuthn() bool {
 	return c.options["WEBAUTHN"].ParsedBoolValue
 }
 
+func (c *configOptions) WebBotAuth() bool {
+	return c.options["WEB_BOT_AUTH"].ParsedBoolValue
+}
+
 func (c *configOptions) WorkerPoolSize() int {
 	return c.options["WORKER_POOL_SIZE"].ParsedIntValue
 }

+ 9 - 0
internal/crypto/crypto.go

@@ -4,6 +4,7 @@
 package crypto // import "miniflux.app/v2/internal/crypto"
 
 import (
+	"crypto/ed25519"
 	"crypto/hmac"
 	"crypto/rand"
 	"crypto/sha256"
@@ -59,3 +60,11 @@ func GenerateUUID() string {
 func ConstantTimeCmp(a, b string) bool {
 	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
 }
+
+func GenerateEd25519Keys() (privateKey, publicKey []byte, err error) {
+	publicKey, privateKey, err = ed25519.GenerateKey(nil)
+	if err != nil {
+		return nil, nil, err
+	}
+	return privateKey, publicKey, nil
+}

+ 6 - 1
internal/database/migrations.go

@@ -435,7 +435,7 @@ var migrations = [...]func(tx *sql.Tx) error{
 
 		hasExtra := false
 		if err := tx.QueryRow(`
-			SELECT true 
+			SELECT true
 			FROM information_schema.columns
 			WHERE
 				table_name='users' AND
@@ -1366,4 +1366,9 @@ var migrations = [...]func(tx *sql.Tx) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `CREATE TABLE web_bot_auth (private_key bytea, public_key bytea, created_at timestamp with time zone default now());`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 5 - 0
internal/http/server/httpd.go

@@ -14,6 +14,7 @@ import (
 	"strings"
 
 	"miniflux.app/v2/internal/api"
+	"miniflux.app/v2/internal/botauth"
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/fever"
 	"miniflux.app/v2/internal/googlereader"
@@ -224,6 +225,10 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
 	router.HandleFunc("/readiness", readinessProbe).Name("readiness")
 	router.HandleFunc("/readyz", readinessProbe).Name("readyz")
 
+	if botauth.GlobalInstance != nil {
+		router.HandleFunc("/.well-known/http-message-signatures-directory", botauth.GlobalInstance.ServeKeyDirectory).Name("botauth_key_directory")
+	}
+
 	var subrouter *mux.Router
 	if config.Opts.BasePath() != "" {
 		subrouter = router.PathPrefix(config.Opts.BasePath()).Subrouter()

+ 5 - 0
internal/reader/fetcher/request_builder.go

@@ -14,6 +14,7 @@ import (
 	"slices"
 	"time"
 
+	"miniflux.app/v2/internal/botauth"
 	"miniflux.app/v2/internal/proxyrotator"
 )
 
@@ -214,6 +215,10 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
 		req.Header.Set("Accept", defaultAcceptHeader)
 	}
 
+	if botauth.GlobalInstance != nil {
+		botauth.GlobalInstance.SignRequest(req)
+	}
+
 	req.Header.Set("Connection", "close")
 
 	slog.Debug("Making outgoing request", slog.Group("request",

+ 58 - 0
internal/storage/web_bot_auth.go

@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package storage // import "miniflux.app/v2/internal/storage"
+
+import (
+	"miniflux.app/v2/internal/botauth"
+	"miniflux.app/v2/internal/crypto"
+)
+
+func (s *Storage) CreateWebAuthBothKeys() error {
+	privateKey, publicKey, err := crypto.GenerateEd25519Keys()
+	if err != nil {
+		return err
+	}
+
+	query := `INSERT INTO web_bot_auth (private_key, public_key) VALUES ($1, $2)`
+	if _, err := s.db.Exec(query, privateKey, publicKey); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (s *Storage) WebAuthBothKeys() (keyPairs botauth.KeyPairs, err error) {
+	query := `SELECT private_key, public_key FROM web_bot_auth ORDER BY created_at DESC`
+	rows, err := s.db.Query(query)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var privateKey, publicKey []byte
+		if err := rows.Scan(&privateKey, &publicKey); err != nil {
+			return nil, err
+		}
+		keyPair, err := botauth.NewKeyPair(privateKey, publicKey)
+		if err != nil {
+			return nil, err
+		}
+		keyPairs = append(keyPairs, keyPair)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return keyPairs, nil
+}
+
+func (s *Storage) HasWebAuthBothKeys() (bool, error) {
+	var count int
+	if err := s.db.QueryRow(`SELECT COUNT(*) FROM web_bot_auth`).Scan(&count); err != nil {
+		return false, err
+	}
+	return count > 0, nil
+}