From 97fedf26169bca28cb310014e73be494d495a650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Dachary?= Date: Wed, 10 Nov 2021 13:35:02 +0100 Subject: [PATCH] activitypub: implement the ReqSignature middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Loïc Dachary --- integrations/api_activitypub_person_test.go | 55 +++++-- modules/activitypub/client.go | 33 ++-- modules/activitypub/client_test.go | 11 +- modules/structs/activitypub.go | 1 + routers/api/v1/activitypub/person.go | 65 ++++---- routers/api/v1/activitypub/reqsignature.go | 158 ++++++++++++++++++++ routers/api/v1/api.go | 1 + templates/swagger/v1_json.tmpl | 31 +++- 8 files changed, 293 insertions(+), 62 deletions(-) create mode 100644 routers/api/v1/activitypub/reqsignature.go diff --git a/integrations/api_activitypub_person_test.go b/integrations/api_activitypub_person_test.go index e031e886dc..4f131fe4e0 100644 --- a/integrations/api_activitypub_person_test.go +++ b/integrations/api_activitypub_person_test.go @@ -9,9 +9,12 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "net/url" "testing" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" "code.gitea.io/gitea/modules/setting" "github.com/go-fed/activity/pub" "github.com/go-fed/activity/streams" @@ -32,7 +35,7 @@ func TestActivityPubPerson(t *testing.T) { username := "user2" req := NewRequestf(t, "GET", fmt.Sprintf("/api/v1/activitypub/user/%s", username)) resp := MakeRequest(t, req, http.StatusOK) - assert.Contains(t, string(resp.Body.Bytes()), "@context") + assert.Contains(t, resp.Body.String(), "@context") var m map[string]interface{} _ = json.Unmarshal(resp.Body.Bytes(), &m) @@ -46,26 +49,26 @@ func TestActivityPubPerson(t *testing.T) { assert.Equal(t, err, nil) assert.Equal(t, "Person", person.GetTypeName()) assert.Equal(t, username, person.GetActivityStreamsName().Begin().GetXMLSchemaString()) - keyId := person.GetJSONLDId().GetIRI().String() - assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyId) + keyID := person.GetJSONLDId().GetIRI().String() + assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyID) assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.GetActivityStreamsOutbox().GetIRI().String()) assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.GetActivityStreamsInbox().GetIRI().String()) pkp := person.GetW3IDSecurityV1PublicKey() - publicKeyId := keyId + "/#main-key" + publicKeyID := keyID + "/#main-key" var pkpFound vocab.W3IDSecurityV1PublicKey for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() { if !pkpIter.IsW3IDSecurityV1PublicKey() { continue } pkValue := pkpIter.Get() - var pkId *url.URL - pkId, err = pub.GetId(pkValue) + var pkID *url.URL + pkID, err = pub.GetId(pkValue) if err != nil { return } - assert.Equal(t, pkId.String(), publicKeyId) - if pkId.String() != publicKeyId { + assert.Equal(t, pkID.String(), publicKeyID) + if pkID.String() != publicKeyID { continue } pkpFound = pkValue @@ -91,6 +94,40 @@ func TestActivityPubMissingPerson(t *testing.T) { req := NewRequestf(t, "GET", "/api/v1/activitypub/user/nonexistentuser") resp := MakeRequest(t, req, http.StatusNotFound) - assert.Contains(t, string(resp.Body.Bytes()), "GetUserByName") + assert.Contains(t, resp.Body.String(), "GetUserByName") + }) +} + +func TestActivityPubPersonInbox(t *testing.T) { + srv := httptest.NewServer(c) + defer srv.Close() + + onGiteaRun(t, func(*testing.T, *url.URL) { + appURL := setting.AppURL + setting.Federation.Enabled = true + setting.Database.LogSQL = true + setting.AppURL = srv.URL + defer func() { + setting.Federation.Enabled = false + setting.Database.LogSQL = false + setting.AppURL = appURL + }() + username1 := "user1" + user1, err := user_model.GetUserByName(username1) + assert.NoError(t, err) + user1url := fmt.Sprintf("%s/api/v1/activitypub/user/%s/#main-key", srv.URL, username1) + c, err := activitypub.NewClient(user1, user1url) + assert.NoError(t, err) + username2 := "user2" + user2inboxurl := fmt.Sprintf("%s/api/v1/activitypub/user/%s/inbox", srv.URL, username2) + + // Signed request succeeds + resp, err := c.Post([]byte{}, user2inboxurl) + assert.NoError(t, err) + assert.Equal(t, 204, resp.StatusCode) + + // Unsigned request fails + req := NewRequest(t, "POST", user2inboxurl) + MakeRequest(t, req, 500) }) } diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go index c3c1d9e950..83d5ab72a1 100644 --- a/modules/activitypub/client.go +++ b/modules/activitypub/client.go @@ -19,10 +19,11 @@ import ( ) const ( - activityStreamsContentType = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + // ActivityStreamsContentType const + ActivityStreamsContentType = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" ) -func containsRequiredHttpHeaders(method string, headers []string) error { +func containsRequiredHTTPHeaders(method string, headers []string) error { var hasRequestTarget, hasDate, hasDigest bool for _, header := range headers { hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget @@ -39,6 +40,7 @@ func containsRequiredHttpHeaders(method string, headers []string) error { return nil } +// Client struct type Client struct { clock pub.Clock client *http.Client @@ -47,13 +49,14 @@ type Client struct { getHeaders []string postHeaders []string priv *rsa.PrivateKey - pubId string + pubID string } -func NewClient(user *user_model.User, pubId string) (c *Client, err error) { - if err = containsRequiredHttpHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil { +// NewClient function +func NewClient(user *user_model.User, pubID string) (c *Client, err error) { + if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil { return - } else if err = containsRequiredHttpHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil { + } else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil { return } else if !httpsig.IsSupportedDigestAlgorithm(setting.Federation.DigestAlgorithm) { err = fmt.Errorf("unsupported digest algorithm: %s", setting.Federation.DigestAlgorithm) @@ -86,21 +89,21 @@ func NewClient(user *user_model.User, pubId string) (c *Client, err error) { getHeaders: setting.Federation.GetHeaders, postHeaders: setting.Federation.PostHeaders, priv: privParsed, - pubId: pubId, + pubID: pubID, } return } -func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { +// NewRequest function +func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) { byteCopy := make([]byte, len(b)) copy(byteCopy, b) buf := bytes.NewBuffer(byteCopy) - var req *http.Request req, err = http.NewRequest(http.MethodPost, to, buf) if err != nil { return } - req.Header.Add("Content-Type", activityStreamsContentType) + req.Header.Add("Content-Type", ActivityStreamsContentType) req.Header.Add("Accept-Charset", "utf-8") req.Header.Add("Date", fmt.Sprintf("%s GMT", c.clock.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05"))) @@ -108,8 +111,14 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { if err != nil { return } - err = signer.SignRequest(c.priv, c.pubId, req, b) - if err != nil { + err = signer.SignRequest(c.priv, c.pubID, req, b) + return +} + +// Post function +func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { + var req *http.Request + if req, err = c.NewRequest(b, to); err != nil { return } resp, err = c.client.Do(req) diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go index e29117ea13..29a286d489 100644 --- a/modules/activitypub/client_test.go +++ b/modules/activitypub/client_test.go @@ -7,7 +7,6 @@ package activitypub import ( "fmt" "io" - "io/ioutil" "net/http" "net/http/httptest" "regexp" @@ -24,16 +23,16 @@ import ( func TestActivityPubSignedPost(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) - pubId := "https://example.com/pubId" - c, err := NewClient(user, pubId) + pubID := "https://example.com/pubID" + c, err := NewClient(user, pubID) assert.NoError(t, err) expected := "BODY" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest")) - assert.Contains(t, r.Header.Get("Signature"), pubId) - assert.Equal(t, r.Header.Get("Content-Type"), activityStreamsContentType) - body, err := ioutil.ReadAll(r.Body) + assert.Contains(t, r.Header.Get("Signature"), pubID) + assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType) + body, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.Equal(t, expected, string(body)) fmt.Fprintf(w, expected) diff --git a/modules/structs/activitypub.go b/modules/structs/activitypub.go index e1e2ec46a1..65db69ee20 100644 --- a/modules/structs/activitypub.go +++ b/modules/structs/activitypub.go @@ -4,6 +4,7 @@ package structs +// ActivityPub type type ActivityPub struct { Context string `json:"@context"` } diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go index 326629f8b2..60d0143ea2 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -9,7 +9,6 @@ import ( "net/url" "strings" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/activitypub" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" @@ -17,32 +16,9 @@ import ( "github.com/go-fed/activity/streams" ) -// hack waiting on https://github.com/go-gitea/gitea/pull/16834 -func GetPublicKey(user *models.User) (string, error) { - if settings, err := models.GetUserSetting(user.ID, []string{"activitypub_pubPem"}); err != nil { - return "", err - } else if len(settings) == 0 { - if priv, pub, err := activitypub.GenerateKeyPair(); err != nil { - return "", err - } else { - privPem := &models.UserSetting{UserID: user.ID, Name: "activitypub_privPem", Value: priv} - if err := models.SetUserSetting(privPem); err != nil { - return "", err - } - pubPem := &models.UserSetting{UserID: user.ID, Name: "activitypub_pubPem", Value: pub} - if err := models.SetUserSetting(pubPem); err != nil { - return "", err - } - return pubPem.Value, nil - } - } else { - return settings[0].Value, nil - } -} - -// NodeInfo returns the NodeInfo for the Gitea instance to allow for federation +// Person function func Person(ctx *context.APIContext) { - // swagger:operation GET /activitypub/user/{username} information + // swagger:operation GET /activitypub/user/{username} activitypub activitypubPerson // --- // summary: Returns the person // produces: @@ -73,30 +49,30 @@ func Person(ctx *context.APIContext) { person.SetActivityStreamsName(name) ibox := streams.NewActivityStreamsInboxProperty() - url_object, _ := url.Parse(link + "/inbox") - ibox.SetIRI(url_object) + urlObject, _ := url.Parse(link + "/inbox") + ibox.SetIRI(urlObject) person.SetActivityStreamsInbox(ibox) obox := streams.NewActivityStreamsOutboxProperty() - url_object, _ = url.Parse(link + "/outbox") - obox.SetIRI(url_object) + urlObject, _ = url.Parse(link + "/outbox") + obox.SetIRI(urlObject) person.SetActivityStreamsOutbox(obox) publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() publicKeyType := streams.NewW3IDSecurityV1PublicKey() - pubKeyIdProp := streams.NewJSONLDIdProperty() + pubKeyIDProp := streams.NewJSONLDIdProperty() pubKeyIRI, _ := url.Parse(link + "/#main-key") - pubKeyIdProp.SetIRI(pubKeyIRI) - publicKeyType.SetJSONLDId(pubKeyIdProp) + pubKeyIDProp.SetIRI(pubKeyIRI) + publicKeyType.SetJSONLDId(pubKeyIDProp) ownerProp := streams.NewW3IDSecurityV1OwnerProperty() ownerProp.SetIRI(idIRI) publicKeyType.SetW3IDSecurityV1Owner(ownerProp) publicKeyPemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() - if publicKeyPem, err := GetPublicKey(user); err != nil { + if publicKeyPem, err := activitypub.GetPublicKey(user); err != nil { ctx.Error(http.StatusInternalServerError, "GetPublicKey", err) } else { publicKeyPemProp.Set(publicKeyPem) @@ -110,3 +86,24 @@ func Person(ctx *context.APIContext) { jsonmap, _ = streams.Serialize(person) ctx.JSON(http.StatusOK, jsonmap) } + +// PersonInbox function +func PersonInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/user/{username}/inbox activitypub activitypubPersonInbox + // --- + // summary: Send to the inbox + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // responses: + // "204": + // "$ref": "#/responses/empty" + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go new file mode 100644 index 0000000000..d422d26fac --- /dev/null +++ b/routers/api/v1/activitypub/reqsignature.go @@ -0,0 +1,158 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "context" + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/url" + + "code.gitea.io/gitea/modules/activitypub" + gitea_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/go-fed/httpsig" +) + +type publicKeyer interface { + GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty +} + +func getPublicKeyFromResponse(ctx context.Context, b []byte, keyID *url.URL) (p crypto.PublicKey, err error) { + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + if err != nil { + return + } + var t vocab.Type + t, err = streams.ToType(ctx, m) + if err != nil { + return + } + pker, ok := t.(publicKeyer) + if !ok { + err = fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %T", t) + return + } + pkp := pker.GetW3IDSecurityV1PublicKey() + if pkp == nil { + err = fmt.Errorf("publicKey property is not provided") + return + } + var pkpFound vocab.W3IDSecurityV1PublicKey + for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() { + if !pkpIter.IsW3IDSecurityV1PublicKey() { + continue + } + pkValue := pkpIter.Get() + var pkID *url.URL + pkID, err = pub.GetId(pkValue) + if err != nil { + return + } + if pkID.String() != keyID.String() { + continue + } + pkpFound = pkValue + break + } + if pkpFound == nil { + err = fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, b) + return + } + pkPemProp := pkpFound.GetW3IDSecurityV1PublicKeyPem() + if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { + err = fmt.Errorf("publicKeyPem property is not provided or it is not embedded as a value") + return + } + pubKeyPem := pkPemProp.Get() + var block *pem.Block + block, _ = pem.Decode([]byte(pubKeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + err = fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type") + return + } + p, err = x509.ParsePKIXPublicKey(block.Bytes) + return +} + +func fetch(iri *url.URL) (b []byte, err error) { + var req *http.Request + req, err = http.NewRequest(http.MethodGet, iri.String(), nil) + if err != nil { + return + } + req.Header.Add("Accept", activitypub.ActivityStreamsContentType) + req.Header.Add("Accept-Charset", "utf-8") + clock, err := activitypub.NewClock() + if err != nil { + return + } + req.Header.Add("Date", fmt.Sprintf("%s GMT", clock.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05"))) + var resp *http.Response + client := &http.Client{} + resp, err = client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status) + return + } + b, err = io.ReadAll(resp.Body) + return +} + +func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) { + r := ctx.Req + + // 1. Figure out what key we need to verify + var v httpsig.Verifier + v, err = httpsig.NewVerifier(r) + if err != nil { + return + } + ID := v.KeyId() + var idIRI *url.URL + idIRI, err = url.Parse(ID) + if err != nil { + return + } + // 2. Fetch the public key of the other actor + var b []byte + b, err = fetch(idIRI) + if err != nil { + return + } + pKey, err := getPublicKeyFromResponse(*ctx, b, idIRI) + if err != nil { + return + } + // 3. Verify the other actor's key + algo := httpsig.Algorithm(setting.Federation.Algorithms[0]) + authenticated = nil == v.Verify(pKey, algo) + return +} + +// ReqSignature function +func ReqSignature() func(ctx *gitea_context.APIContext) { + return func(ctx *gitea_context.APIContext) { + if authenticated, err := verifyHTTPSignatures(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "verifyHttpSignatures", err) + } else if !authenticated { + ctx.Error(http.StatusForbidden, "reqSignature", "request signature verification failed") + } + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3fde7c34ee..4eeefface8 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -602,6 +602,7 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route { m.Group("/user/{username}", func() { m.Get("", activitypub.Person) }) + m.Post("/user/{username}/inbox", activitypub.ReqSignature(), activitypub.PersonInbox) }) } m.Get("/signing-key.gpg", misc.SigningKey) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index c4ecf0f2e9..390441cde7 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -28,8 +28,11 @@ "produces": [ "application/json" ], + "tags": [ + "activitypub" + ], "summary": "Returns the person", - "operationId": "information", + "operationId": "activitypubPerson", "parameters": [ { "type": "string", @@ -46,6 +49,32 @@ } } }, + "/activitypub/user/{username}/inbox": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Send to the inbox", + "operationId": "activitypubPersonInbox", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/admin/cron": { "get": { "produces": [