From 15c1f6218c407b236a52eef71ca8ec286d702958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Dachary?= Date: Tue, 9 Nov 2021 08:36:23 +0100 Subject: [PATCH] activitypub: signing http client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Loïc Dachary --- modules/activitypub/client.go | 117 +++++++++++++++++++++++++++++ modules/activitypub/client_test.go | 49 ++++++++++++ modules/activitypub/main_test.go | 16 ++++ modules/setting/federation.go | 12 ++- 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 modules/activitypub/client.go create mode 100644 modules/activitypub/client_test.go create mode 100644 modules/activitypub/main_test.go diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go new file mode 100644 index 0000000000..c3c1d9e950 --- /dev/null +++ b/modules/activitypub/client.go @@ -0,0 +1,117 @@ +// 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 ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "github.com/go-fed/activity/pub" + "github.com/go-fed/httpsig" +) + +const ( + activityStreamsContentType = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" +) + +func containsRequiredHttpHeaders(method string, headers []string) error { + var hasRequestTarget, hasDate, hasDigest bool + for _, header := range headers { + hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget + hasDate = hasDate || header == "Date" + hasDigest = method == "GET" || hasDigest || header == "Digest" + } + 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 !hasDigest { + return fmt.Errorf("missing http header for %s: Digest", method) + } + return nil +} + +type Client struct { + clock pub.Clock + client *http.Client + algs []httpsig.Algorithm + digestAlg httpsig.DigestAlgorithm + getHeaders []string + postHeaders []string + priv *rsa.PrivateKey + pubId string +} + +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 { + return + } else if !httpsig.IsSupportedDigestAlgorithm(setting.Federation.DigestAlgorithm) { + err = fmt.Errorf("unsupported digest algorithm: %s", setting.Federation.DigestAlgorithm) + return + } + algos := make([]httpsig.Algorithm, len(setting.Federation.Algorithms)) + for i, algo := range setting.Federation.Algorithms { + algos[i] = httpsig.Algorithm(algo) + } + clock, err := NewClock() + if err != nil { + return + } + + priv, err := GetPrivateKey(user) + if err != nil { + return + } + privPem, _ := pem.Decode([]byte(priv)) + privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) + if err != nil { + return + } + + c = &Client{ + clock: clock, + client: &http.Client{}, + algs: algos, + digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm), + getHeaders: setting.Federation.GetHeaders, + postHeaders: setting.Federation.PostHeaders, + priv: privParsed, + pubId: pubId, + } + return +} + +func (c *Client) Post(b []byte, to string) (resp *http.Response, 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("Accept-Charset", "utf-8") + req.Header.Add("Date", fmt.Sprintf("%s GMT", c.clock.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05"))) + + signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, 60) + if err != nil { + return + } + err = signer.SignRequest(c.priv, c.pubId, req, b) + if err != nil { + return + } + resp, err = c.client.Do(req) + return +} diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go new file mode 100644 index 0000000000..e29117ea13 --- /dev/null +++ b/modules/activitypub/client_test.go @@ -0,0 +1,49 @@ +// 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 ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + _ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4 + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +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) + 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.NoError(t, err) + assert.Equal(t, expected, string(body)) + fmt.Fprintf(w, expected) + })) + defer srv.Close() + + r, err := c.Post([]byte(expected), srv.URL) + assert.NoError(t, err) + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Equal(t, expected, string(body)) +} diff --git a/modules/activitypub/main_test.go b/modules/activitypub/main_test.go new file mode 100644 index 0000000000..c2be6f661e --- /dev/null +++ b/modules/activitypub/main_test.go @@ -0,0 +1,16 @@ +// 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 ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, filepath.Join("..", "..")) +} diff --git a/modules/setting/federation.go b/modules/setting/federation.go index c300060789..12330c6cf3 100644 --- a/modules/setting/federation.go +++ b/modules/setting/federation.go @@ -9,9 +9,17 @@ import "code.gitea.io/gitea/modules/log" // Federation settings var ( Federation = struct { - Enabled bool + Enabled bool + Algorithms []string + DigestAlgorithm string + GetHeaders []string + PostHeaders []string }{ - Enabled: true, + Enabled: true, + Algorithms: []string{"rsa-sha256", "rsa-sha512"}, + DigestAlgorithm: "SHA-256", + GetHeaders: []string{"(request-target)", "Date"}, + PostHeaders: []string{"(request-target)", "Date", "Digest"}, } )