diff --git a/internal/botauth/botauth.go b/internal/botauth/botauth.go new file mode 100644 index 00000000000..f833c0ac61c --- /dev/null +++ b/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 +} diff --git a/internal/botauth/botauth_test.go b/internal/botauth/botauth_test.go new file mode 100644 index 00000000000..981c4c75571 --- /dev/null +++ b/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) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 062b85e92b2..10ad3a273ae 100644 --- a/internal/cli/cli.go +++ b/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 diff --git a/internal/config/options.go b/internal/config/options.go index be8768a603e..6d6c2c41e76 100644 --- a/internal/config/options.go +++ b/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 } diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 79a838abb48..7efa6e09996 100644 --- a/internal/crypto/crypto.go +++ b/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 +} diff --git a/internal/database/migrations.go b/internal/database/migrations.go index aeb43414072..a3c1f392a7a 100644 --- a/internal/database/migrations.go +++ b/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 + }, } diff --git a/internal/http/server/httpd.go b/internal/http/server/httpd.go index 839bae90973..00d49ebcd21 100644 --- a/internal/http/server/httpd.go +++ b/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() diff --git a/internal/reader/fetcher/request_builder.go b/internal/reader/fetcher/request_builder.go index a05d7edfc58..c563b86979b 100644 --- a/internal/reader/fetcher/request_builder.go +++ b/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", diff --git a/internal/storage/web_bot_auth.go b/internal/storage/web_bot_auth.go new file mode 100644 index 00000000000..fda554de822 --- /dev/null +++ b/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 +}