From cd17eb0fa742612ecaed964b92ced447eaa5ddab Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Sun, 4 Aug 2024 15:34:31 +0200 Subject: [PATCH 1/2] activitypub: Sign the Host header too Mastodon with `AUTHORIZED_FETCH` enabled requires the `Host` header to be signed too, add it to the default for `setting.Federation.GetHeaders` and `setting.Federation.PostHeaders`. For this to work, we need to sign the request later: not immediately after `NewRequest`, but just before sending them out with `client.Do`. Doing so also lets us use `setting.Federation.GetHeaders` (we were using `.PostHeaders` even for GET requests before). Signed-off-by: Gergely Nagy --- modules/activitypub/client.go | 39 +++++++++++++++++++++++++---------- modules/setting/federation.go | 4 ++-- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go index 38ccc58eb5..f07d3bc7d6 100644 --- a/modules/activitypub/client.go +++ b/modules/activitypub/client.go @@ -36,16 +36,19 @@ func CurrentTime() string { } func containsRequiredHTTPHeaders(method string, headers []string) error { - var hasRequestTarget, hasDate, hasDigest bool + var hasRequestTarget, hasDate, hasDigest, hasHost bool for _, header := range headers { hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget hasDate = hasDate || header == "Date" hasDigest = hasDigest || header == "Digest" + hasHost = hasHost || header == "Host" } if !hasRequestTarget { return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget) } else if !hasDate { return fmt.Errorf("missing http header for %s: Date", method) + } else if !hasHost { + return fmt.Errorf("missing http header for %s: Host", method) } else if !hasDigest && method != http.MethodGet { return fmt.Errorf("missing http header for %s: Digest", method) } @@ -99,29 +102,36 @@ func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Cli } // NewRequest function -func (c *Client) NewRequest(method string, b []byte, to string) (req *http.Request, err error) { +func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) { buf := bytes.NewBuffer(b) req, err = http.NewRequest(method, to, buf) if err != nil { return nil, err } - req.Header.Add("Content-Type", ActivityStreamsContentType) + req.Header.Add("Accept", "application/json, "+ActivityStreamsContentType) req.Header.Add("Date", CurrentTime()) + req.Header.Add("Host", req.URL.Host) req.Header.Add("User-Agent", "Gitea/"+setting.AppVer) - signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) - if err != nil { - return nil, err - } - err = signer.SignRequest(c.priv, c.pubID, req, b) + req.Header.Add("Content-Type", ActivityStreamsContentType) + return req, err } // Post function func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { var req *http.Request - if req, err = c.NewRequest(http.MethodPost, b, to); err != nil { + if req, err = c.newRequest(http.MethodPost, b, to); err != nil { return nil, err } + + signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) + if err != nil { + return nil, err + } + if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil { + return nil, err + } + resp, err = c.client.Do(req) return resp, err } @@ -129,10 +139,17 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { // Create an http GET request with forgejo/gitea specific headers func (c *Client) Get(to string) (resp *http.Response, err error) { var req *http.Request - emptyBody := []byte{0} - if req, err = c.NewRequest(http.MethodGet, emptyBody, to); err != nil { + if req, err = c.newRequest(http.MethodGet, nil, to); err != nil { return nil, err } + signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime) + if err != nil { + return nil, err + } + if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil { + return nil, err + } + resp, err = c.client.Do(req) return resp, err } diff --git a/modules/setting/federation.go b/modules/setting/federation.go index 2bea900633..aeb30683ea 100644 --- a/modules/setting/federation.go +++ b/modules/setting/federation.go @@ -25,8 +25,8 @@ var ( MaxSize: 4, Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, DigestAlgorithm: "SHA-256", - GetHeaders: []string{"(request-target)", "Date"}, - PostHeaders: []string{"(request-target)", "Date", "Digest"}, + GetHeaders: []string{"(request-target)", "Date", "Host"}, + PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"}, } ) From f121e87aa6c00be18025a702aa7112ed15d5d0ab Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Mon, 5 Aug 2024 10:50:26 +0200 Subject: [PATCH 2/2] activitypub: Implement an instance-wide actor An instance-wide actor is required for outgoing signed requests that are done on behalf of the instance, rather than on behalf of other actors. Such things include updating profile information, or fetching public keys. Signed-off-by: Gergely Nagy --- models/user/user_system.go | 27 ++++++ routers/api/v1/activitypub/actor.go | 83 +++++++++++++++++++ routers/api/v1/api.go | 4 + templates/swagger/v1_json.tmpl | 34 ++++++++ .../integration/api_activitypub_actor_test.go | 50 +++++++++++ 5 files changed, 198 insertions(+) create mode 100644 routers/api/v1/activitypub/actor.go create mode 100644 tests/integration/api_activitypub_actor_test.go diff --git a/models/user/user_system.go b/models/user/user_system.go index ac2505dd14..ba9a2131b2 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -4,8 +4,10 @@ package user import ( + "net/url" "strings" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" ) @@ -68,3 +70,28 @@ func NewActionsUser() *User { func (u *User) IsActions() bool { return u != nil && u.ID == ActionsUserID } + +const ( + APActorUserID = -3 + APActorUserName = "actor" + APActorEmail = "noreply@forgejo.org" +) + +func NewAPActorUser() *User { + return &User{ + ID: APActorUserID, + Name: APActorUserName, + LowerName: APActorUserName, + IsActive: true, + Email: APActorEmail, + KeepEmailPrivate: true, + LoginName: APActorUserName, + Type: UserTypeIndividual, + Visibility: structs.VisibleTypePublic, + } +} + +func APActorUserAPActorID() string { + path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor") + return path +} diff --git a/routers/api/v1/activitypub/actor.go b/routers/api/v1/activitypub/actor.go new file mode 100644 index 0000000000..4f128e74c4 --- /dev/null +++ b/routers/api/v1/activitypub/actor.go @@ -0,0 +1,83 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Actor function returns the instance's Actor +func Actor(ctx *context.APIContext) { + // swagger:operation GET /activitypub/actor activitypub activitypubInstanceActor + // --- + // summary: Returns the instance's Actor + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := user_model.APActorUserAPActorID() + actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) + + actor.PreferredUsername = ap.NaturalLanguageValuesNew() + err := actor.PreferredUsername.Set("en", ap.Content(setting.Domain)) + if err != nil { + ctx.ServerError("PreferredUsername.Set", err) + return + } + + actor.URL = ap.IRI(setting.AppURL) + + actor.Inbox = ap.IRI(link + "/inbox") + actor.Outbox = ap.IRI(link + "/outbox") + + actor.PublicKey.ID = ap.IRI(link + "#main-key") + actor.PublicKey.Owner = ap.IRI(link) + + publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPActorUser()) + if err != nil { + ctx.ServerError("GetPublicKey", err) + return + } + actor.PublicKey.PublicKeyPem = publicKeyPem + + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + jsonld.IRI(ap.SecurityContextURI), + ).Marshal(actor) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} + +// ActorInbox function handles the incoming data for the instance Actor +func ActorInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/actor/inbox activitypub activitypubInstanceActorInbox + // --- + // summary: Send to the inbox + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index fa0cd6c753..c65e738715 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -805,6 +805,10 @@ func Routes() *web.Route { m.Get("", activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) }, context.UserIDAssignmentAPI()) + m.Group("/actor", func() { + m.Get("", activitypub.Actor) + m.Post("/inbox", activitypub.ActorInbox) + }) m.Group("/repository-id/{repository-id}", func() { m.Get("", activitypub.Repository) m.Post("/inbox", diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 39b92f4e79..628e8d5c99 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -23,6 +23,40 @@ }, "basePath": "{{AppSubUrl | JSEscape}}/api/v1", "paths": { + "/activitypub/actor": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the instance's Actor", + "operationId": "activitypubInstanceActor", + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/actor/inbox": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Send to the inbox", + "operationId": "activitypubInstanceActorInbox", + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/activitypub/repository-id/{repository-id}": { "get": { "produces": [ diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go new file mode 100644 index 0000000000..7506c786da --- /dev/null +++ b/tests/integration/api_activitypub_actor_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + + ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubActor(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(*testing.T, *url.URL) { + req := NewRequest(t, "GET", "/api/v1/activitypub/actor") + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + assert.Contains(t, string(body), "@context") + + var actor ap.Actor + err := actor.UnmarshalJSON(body) + require.NoError(t, err) + + assert.Equal(t, ap.ApplicationType, actor.Type) + assert.Equal(t, setting.Domain, actor.PreferredUsername.String()) + keyID := actor.GetID().String() + assert.Regexp(t, "activitypub/actor$", keyID) + assert.Regexp(t, "activitypub/actor/outbox$", actor.Outbox.GetID().String()) + assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String()) + + pubKey := actor.PublicKey + assert.NotNil(t, pubKey) + publicKeyID := keyID + "#main-key" + assert.Equal(t, pubKey.ID.String(), publicKeyID) + + pubKeyPem := pubKey.PublicKeyPem + assert.NotNil(t, pubKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) + }) +}