activitypub: implement the ReqSignature middleware

Signed-off-by: Loïc Dachary <loic@dachary.org>
This commit is contained in:
Loïc Dachary 2021-11-10 13:35:02 +01:00 committed by Anthony Wang
parent 15c1f6218c
commit 97fedf2616
No known key found for this signature in database
GPG Key ID: BC96B00AEC5F2D76
8 changed files with 293 additions and 62 deletions

View File

@ -9,9 +9,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"testing" "testing"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"github.com/go-fed/activity/pub" "github.com/go-fed/activity/pub"
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
@ -32,7 +35,7 @@ func TestActivityPubPerson(t *testing.T) {
username := "user2" username := "user2"
req := NewRequestf(t, "GET", fmt.Sprintf("/api/v1/activitypub/user/%s", username)) req := NewRequestf(t, "GET", fmt.Sprintf("/api/v1/activitypub/user/%s", username))
resp := MakeRequest(t, req, http.StatusOK) 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{} var m map[string]interface{}
_ = json.Unmarshal(resp.Body.Bytes(), &m) _ = json.Unmarshal(resp.Body.Bytes(), &m)
@ -46,26 +49,26 @@ func TestActivityPubPerson(t *testing.T) {
assert.Equal(t, err, nil) assert.Equal(t, err, nil)
assert.Equal(t, "Person", person.GetTypeName()) assert.Equal(t, "Person", person.GetTypeName())
assert.Equal(t, username, person.GetActivityStreamsName().Begin().GetXMLSchemaString()) assert.Equal(t, username, person.GetActivityStreamsName().Begin().GetXMLSchemaString())
keyId := person.GetJSONLDId().GetIRI().String() keyID := person.GetJSONLDId().GetIRI().String()
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyId) 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/outbox$", username), person.GetActivityStreamsOutbox().GetIRI().String())
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.GetActivityStreamsInbox().GetIRI().String()) assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.GetActivityStreamsInbox().GetIRI().String())
pkp := person.GetW3IDSecurityV1PublicKey() pkp := person.GetW3IDSecurityV1PublicKey()
publicKeyId := keyId + "/#main-key" publicKeyID := keyID + "/#main-key"
var pkpFound vocab.W3IDSecurityV1PublicKey var pkpFound vocab.W3IDSecurityV1PublicKey
for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() { for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() {
if !pkpIter.IsW3IDSecurityV1PublicKey() { if !pkpIter.IsW3IDSecurityV1PublicKey() {
continue continue
} }
pkValue := pkpIter.Get() pkValue := pkpIter.Get()
var pkId *url.URL var pkID *url.URL
pkId, err = pub.GetId(pkValue) pkID, err = pub.GetId(pkValue)
if err != nil { if err != nil {
return return
} }
assert.Equal(t, pkId.String(), publicKeyId) assert.Equal(t, pkID.String(), publicKeyID)
if pkId.String() != publicKeyId { if pkID.String() != publicKeyID {
continue continue
} }
pkpFound = pkValue pkpFound = pkValue
@ -91,6 +94,40 @@ func TestActivityPubMissingPerson(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/activitypub/user/nonexistentuser") req := NewRequestf(t, "GET", "/api/v1/activitypub/user/nonexistentuser")
resp := MakeRequest(t, req, http.StatusNotFound) 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)
}) })
} }

View File

@ -19,10 +19,11 @@ import (
) )
const ( 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 var hasRequestTarget, hasDate, hasDigest bool
for _, header := range headers { for _, header := range headers {
hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget
@ -39,6 +40,7 @@ func containsRequiredHttpHeaders(method string, headers []string) error {
return nil return nil
} }
// Client struct
type Client struct { type Client struct {
clock pub.Clock clock pub.Clock
client *http.Client client *http.Client
@ -47,13 +49,14 @@ type Client struct {
getHeaders []string getHeaders []string
postHeaders []string postHeaders []string
priv *rsa.PrivateKey priv *rsa.PrivateKey
pubId string pubID string
} }
func NewClient(user *user_model.User, pubId string) (c *Client, err error) { // NewClient function
if err = containsRequiredHttpHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil { func NewClient(user *user_model.User, pubID string) (c *Client, err error) {
if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil {
return return
} else if err = containsRequiredHttpHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil { } else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil {
return return
} else if !httpsig.IsSupportedDigestAlgorithm(setting.Federation.DigestAlgorithm) { } else if !httpsig.IsSupportedDigestAlgorithm(setting.Federation.DigestAlgorithm) {
err = fmt.Errorf("unsupported digest algorithm: %s", 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, getHeaders: setting.Federation.GetHeaders,
postHeaders: setting.Federation.PostHeaders, postHeaders: setting.Federation.PostHeaders,
priv: privParsed, priv: privParsed,
pubId: pubId, pubID: pubID,
} }
return 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)) byteCopy := make([]byte, len(b))
copy(byteCopy, b) copy(byteCopy, b)
buf := bytes.NewBuffer(byteCopy) buf := bytes.NewBuffer(byteCopy)
var req *http.Request
req, err = http.NewRequest(http.MethodPost, to, buf) req, err = http.NewRequest(http.MethodPost, to, buf)
if err != nil { if err != nil {
return return
} }
req.Header.Add("Content-Type", activityStreamsContentType) req.Header.Add("Content-Type", ActivityStreamsContentType)
req.Header.Add("Accept-Charset", "utf-8") 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"))) 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 { if err != nil {
return return
} }
err = signer.SignRequest(c.priv, c.pubId, req, b) err = signer.SignRequest(c.priv, c.pubID, req, b)
if err != nil { 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 return
} }
resp, err = c.client.Do(req) resp, err = c.client.Do(req)

View File

@ -7,7 +7,6 @@ package activitypub
import ( import (
"fmt" "fmt"
"io" "io"
"io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"regexp" "regexp"
@ -24,16 +23,16 @@ import (
func TestActivityPubSignedPost(t *testing.T) { func TestActivityPubSignedPost(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User)
pubId := "https://example.com/pubId" pubID := "https://example.com/pubID"
c, err := NewClient(user, pubId) c, err := NewClient(user, pubID)
assert.NoError(t, err) assert.NoError(t, err)
expected := "BODY" expected := "BODY"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest"))
assert.Contains(t, r.Header.Get("Signature"), pubId) assert.Contains(t, r.Header.Get("Signature"), pubID)
assert.Equal(t, r.Header.Get("Content-Type"), activityStreamsContentType) assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType)
body, err := ioutil.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, expected, string(body)) assert.Equal(t, expected, string(body))
fmt.Fprintf(w, expected) fmt.Fprintf(w, expected)

View File

@ -4,6 +4,7 @@
package structs package structs
// ActivityPub type
type ActivityPub struct { type ActivityPub struct {
Context string `json:"@context"` Context string `json:"@context"`
} }

View File

@ -9,7 +9,6 @@ import (
"net/url" "net/url"
"strings" "strings"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/activitypub" "code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@ -17,32 +16,9 @@ import (
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
) )
// hack waiting on https://github.com/go-gitea/gitea/pull/16834 // Person function
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
func Person(ctx *context.APIContext) { func Person(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username} information // swagger:operation GET /activitypub/user/{username} activitypub activitypubPerson
// --- // ---
// summary: Returns the person // summary: Returns the person
// produces: // produces:
@ -73,30 +49,30 @@ func Person(ctx *context.APIContext) {
person.SetActivityStreamsName(name) person.SetActivityStreamsName(name)
ibox := streams.NewActivityStreamsInboxProperty() ibox := streams.NewActivityStreamsInboxProperty()
url_object, _ := url.Parse(link + "/inbox") urlObject, _ := url.Parse(link + "/inbox")
ibox.SetIRI(url_object) ibox.SetIRI(urlObject)
person.SetActivityStreamsInbox(ibox) person.SetActivityStreamsInbox(ibox)
obox := streams.NewActivityStreamsOutboxProperty() obox := streams.NewActivityStreamsOutboxProperty()
url_object, _ = url.Parse(link + "/outbox") urlObject, _ = url.Parse(link + "/outbox")
obox.SetIRI(url_object) obox.SetIRI(urlObject)
person.SetActivityStreamsOutbox(obox) person.SetActivityStreamsOutbox(obox)
publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
publicKeyType := streams.NewW3IDSecurityV1PublicKey() publicKeyType := streams.NewW3IDSecurityV1PublicKey()
pubKeyIdProp := streams.NewJSONLDIdProperty() pubKeyIDProp := streams.NewJSONLDIdProperty()
pubKeyIRI, _ := url.Parse(link + "/#main-key") pubKeyIRI, _ := url.Parse(link + "/#main-key")
pubKeyIdProp.SetIRI(pubKeyIRI) pubKeyIDProp.SetIRI(pubKeyIRI)
publicKeyType.SetJSONLDId(pubKeyIdProp) publicKeyType.SetJSONLDId(pubKeyIDProp)
ownerProp := streams.NewW3IDSecurityV1OwnerProperty() ownerProp := streams.NewW3IDSecurityV1OwnerProperty()
ownerProp.SetIRI(idIRI) ownerProp.SetIRI(idIRI)
publicKeyType.SetW3IDSecurityV1Owner(ownerProp) publicKeyType.SetW3IDSecurityV1Owner(ownerProp)
publicKeyPemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() publicKeyPemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
if publicKeyPem, err := GetPublicKey(user); err != nil { if publicKeyPem, err := activitypub.GetPublicKey(user); err != nil {
ctx.Error(http.StatusInternalServerError, "GetPublicKey", err) ctx.Error(http.StatusInternalServerError, "GetPublicKey", err)
} else { } else {
publicKeyPemProp.Set(publicKeyPem) publicKeyPemProp.Set(publicKeyPem)
@ -110,3 +86,24 @@ func Person(ctx *context.APIContext) {
jsonmap, _ = streams.Serialize(person) jsonmap, _ = streams.Serialize(person)
ctx.JSON(http.StatusOK, jsonmap) 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)
}

View File

@ -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")
}
}
}

View File

@ -602,6 +602,7 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route {
m.Group("/user/{username}", func() { m.Group("/user/{username}", func() {
m.Get("", activitypub.Person) m.Get("", activitypub.Person)
}) })
m.Post("/user/{username}/inbox", activitypub.ReqSignature(), activitypub.PersonInbox)
}) })
} }
m.Get("/signing-key.gpg", misc.SigningKey) m.Get("/signing-key.gpg", misc.SigningKey)

View File

@ -28,8 +28,11 @@
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [
"activitypub"
],
"summary": "Returns the person", "summary": "Returns the person",
"operationId": "information", "operationId": "activitypubPerson",
"parameters": [ "parameters": [
{ {
"type": "string", "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": { "/admin/cron": {
"get": { "get": {
"produces": [ "produces": [