aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLoïc Dachary2021-11-10 13:35:02 +0100
committerAnthony Wang2022-03-18 17:34:09 -0500
commit97fedf26169bca28cb310014e73be494d495a650 (patch)
tree90f8ed632e3b6c3d69aa5e9d41516f47bce54c02
parent15c1f6218c407b236a52eef71ca8ec286d702958 (diff)
activitypub: implement the ReqSignature middleware
Signed-off-by: Loïc Dachary <loic@dachary.org>
-rw-r--r--integrations/api_activitypub_person_test.go55
-rw-r--r--modules/activitypub/client.go33
-rw-r--r--modules/activitypub/client_test.go11
-rw-r--r--modules/structs/activitypub.go1
-rw-r--r--routers/api/v1/activitypub/person.go65
-rw-r--r--routers/api/v1/activitypub/reqsignature.go158
-rw-r--r--routers/api/v1/api.go1
-rw-r--r--templates/swagger/v1_json.tmpl31
8 files changed, 293 insertions, 62 deletions
diff --git a/integrations/api_activitypub_person_test.go b/integrations/api_activitypub_person_test.go
index e031e886d..4f131fe4e 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 c3c1d9e95..83d5ab72a 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 e29117ea1..29a286d48 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 e1e2ec46a..65db69ee2 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 326629f8b..60d0143ea 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 000000000..d422d26fa
--- /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 3fde7c34e..4eeefface 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 c4ecf0f2e..390441cde 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": [