diff options
Diffstat (limited to 'internal/federation')
| -rw-r--r-- | internal/federation/clock.go | 42 | ||||
| -rw-r--r-- | internal/federation/commonbehavior.go | 152 | ||||
| -rw-r--r-- | internal/federation/federatingactor.go | 136 | ||||
| -rw-r--r-- | internal/federation/federatingprotocol.go (renamed from internal/federation/federation.go) | 218 | ||||
| -rw-r--r-- | internal/federation/federator.go | 79 | ||||
| -rw-r--r-- | internal/federation/federator_test.go | 190 | ||||
| -rw-r--r-- | internal/federation/util.go | 237 | 
7 files changed, 917 insertions, 137 deletions
| diff --git a/internal/federation/clock.go b/internal/federation/clock.go new file mode 100644 index 000000000..f0d6f5e84 --- /dev/null +++ b/internal/federation/clock.go @@ -0,0 +1,42 @@ +/* +   GoToSocial +   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"time" + +	"github.com/go-fed/activity/pub" +) + +/* +	GOFED CLOCK INTERFACE +	Determines the time. +*/ + +// Clock implements the Clock interface of go-fed +type Clock struct{} + +// Now just returns the time now +func (c *Clock) Now() time.Time { +	return time.Now() +} + +func NewClock() pub.Clock { +	return &Clock{} +} diff --git a/internal/federation/commonbehavior.go b/internal/federation/commonbehavior.go new file mode 100644 index 000000000..9274e78b4 --- /dev/null +++ b/internal/federation/commonbehavior.go @@ -0,0 +1,152 @@ +/* +   GoToSocial +   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"context" +	"fmt" +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/activity/streams/vocab" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/util" +) + +/* +	GOFED COMMON BEHAVIOR INTERFACE +	Contains functions required for both the Social API and Federating Protocol. +	It is passed to the library as a dependency injection from the client +	application. +*/ + +// AuthenticateGetInbox delegates the authentication of a GET to an +// inbox. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +// +// If an error is returned, it is passed back to the caller of +// GetInbox. In this case, the implementation must not write a +// response to the ResponseWriter as is expected that the client will +// do so when handling the error. The 'authenticated' is ignored. +// +// If no error is returned, but authentication or authorization fails, +// then authenticated must be false and error nil. It is expected that +// the implementation handles writing to the ResponseWriter in this +// case. +// +// Finally, if the authentication and authorization succeeds, then +// authenticated must be true and error nil. The request will continue +// to be processed. +func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { +	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through +	// the CLIENT API, not through the federation API, so we just do nothing here. +	return nil, false, nil +} + +// AuthenticateGetOutbox delegates the authentication of a GET to an +// outbox. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +// +// If an error is returned, it is passed back to the caller of +// GetOutbox. In this case, the implementation must not write a +// response to the ResponseWriter as is expected that the client will +// do so when handling the error. The 'authenticated' is ignored. +// +// If no error is returned, but authentication or authorization fails, +// then authenticated must be false and error nil. It is expected that +// the implementation handles writing to the ResponseWriter in this +// case. +// +// Finally, if the authentication and authorization succeeds, then +// authenticated must be true and error nil. The request will continue +// to be processed. +func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { +	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through +	// the CLIENT API, not through the federation API, so we just do nothing here. +	return nil, false, nil +} + +// GetOutbox returns the OrderedCollection inbox of the actor for this +// context. It is up to the implementation to provide the correct +// collection for the kind of authorization given in the request. +// +// AuthenticateGetOutbox will be called prior to this. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +func (f *federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { +	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through +	// the CLIENT API, not through the federation API, so we just do nothing here. +	return nil, nil +} + +// NewTransport returns a new Transport on behalf of a specific actor. +// +// The actorBoxIRI will be either the inbox or outbox of an actor who is +// attempting to do the dereferencing or delivery. Any authentication +// scheme applied on the request must be based on this actor. The +// request must contain some sort of credential of the user, such as a +// HTTP Signature. +// +// The gofedAgent passed in should be used by the Transport +// implementation in the User-Agent, as well as the application-specific +// user agent string. The gofedAgent will indicate this library's use as +// well as the library's version number. +// +// Any server-wide rate-limiting that needs to occur should happen in a +// Transport implementation. This factory function allows this to be +// created, so peer servers are not DOS'd. +// +// Any retry logic should also be handled by the Transport +// implementation. +// +// Note that the library will not maintain a long-lived pointer to the +// returned Transport so that any private credentials are able to be +// garbage collected. +func (f *federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) { + +	var username string +	var err error + +	if util.IsInboxPath(actorBoxIRI) { +		username, err = util.ParseInboxPath(actorBoxIRI) +		if err != nil { +			return nil, fmt.Errorf("couldn't parse path %s as an inbox: %s", actorBoxIRI.String(), err) +		} +	} else if util.IsOutboxPath(actorBoxIRI) { +		username, err = util.ParseOutboxPath(actorBoxIRI) +		if err != nil { +			return nil, fmt.Errorf("couldn't parse path %s as an outbox: %s", actorBoxIRI.String(), err) +		} +	} else { +		return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String()) +	} + +	account := >smodel.Account{} +	if err := f.db.GetLocalAccountByUsername(username, account); err != nil { +		return nil, fmt.Errorf("error getting account with username %s from the db: %s", username, err) +	} + +	return f.transportController.NewTransport(account.PublicKeyURI, account.PrivateKey) +} diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go new file mode 100644 index 000000000..f105d9125 --- /dev/null +++ b/internal/federation/federatingactor.go @@ -0,0 +1,136 @@ +/* +   GoToSocial +   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"context" +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/activity/streams/vocab" +) + +// federatingActor implements the go-fed federating protocol interface +type federatingActor struct { +	actor pub.FederatingActor +} + +// newFederatingProtocol returns the gotosocial implementation of the GTSFederatingProtocol interface +func newFederatingActor(c pub.CommonBehavior, s2s pub.FederatingProtocol, db pub.Database, clock pub.Clock) pub.FederatingActor { +	actor := pub.NewFederatingActor(c, s2s, db, clock) + +	return &federatingActor{ +		actor: actor, +	} +} + +// Send a federated activity. +// +// The provided url must be the outbox of the sender. All processing of +// the activity occurs similarly to the C2S flow: +//   - If t is not an Activity, it is wrapped in a Create activity. +//   - A new ID is generated for the activity. +//   - The activity is added to the specified outbox. +//   - The activity is prepared and delivered to recipients. +// +// Note that this function will only behave as expected if the +// implementation has been constructed to support federation. This +// method will guaranteed work for non-custom Actors. For custom actors, +// care should be used to not call this method if only C2S is supported. +func (f *federatingActor) Send(c context.Context, outbox *url.URL, t vocab.Type) (pub.Activity, error) { +	return f.actor.Send(c, outbox, t) +} + +// PostInbox returns true if the request was handled as an ActivityPub +// POST to an actor's inbox. If false, the request was not an +// ActivityPub request and may still be handled by the caller in +// another way, such as serving a web page. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the Actor was constructed with the Federated Protocol enabled, +// side effects will occur. +// +// If the Federated Protocol is not enabled, writes the +// http.StatusMethodNotAllowed status code in the response. No side +// effects occur. +func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.PostInbox(c, w, r) +} + +// GetInbox returns true if the request was handled as an ActivityPub +// GET to an actor's inbox. If false, the request was not an ActivityPub +// request and may still be handled by the caller in another way, such +// as serving a web page. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the request is an ActivityPub request, the Actor will defer to the +// application to determine the correct authorization of the request and +// the resulting OrderedCollection to respond with. The Actor handles +// serializing this OrderedCollection and responding with the correct +// headers and http.StatusOK. +func (f *federatingActor) GetInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.GetInbox(c, w, r) +} + +// PostOutbox returns true if the request was handled as an ActivityPub +// POST to an actor's outbox. If false, the request was not an +// ActivityPub request and may still be handled by the caller in another +// way, such as serving a web page. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the Actor was constructed with the Social Protocol enabled, side +// effects will occur. +// +// If the Social Protocol is not enabled, writes the +// http.StatusMethodNotAllowed status code in the response. No side +// effects occur. +// +// If the Social and Federated Protocol are both enabled, it will handle +// the side effects of receiving an ActivityStream Activity, and then +// federate the Activity to peers. +func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.PostOutbox(c, w, r) +} + +// GetOutbox returns true if the request was handled as an ActivityPub +// GET to an actor's outbox. If false, the request was not an +// ActivityPub request. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the request is an ActivityPub request, the Actor will defer to the +// application to determine the correct authorization of the request and +// the resulting OrderedCollection to respond with. The Actor handles +// serializing this OrderedCollection and responding with the correct +// headers and http.StatusOK. +func (f *federatingActor) GetOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { +	return f.actor.GetOutbox(c, w, r) +} diff --git a/internal/federation/federation.go b/internal/federation/federatingprotocol.go index a2aba3fcf..1764eb791 100644 --- a/internal/federation/federation.go +++ b/internal/federation/federatingprotocol.go @@ -16,34 +16,23 @@     along with this program.  If not, see <http://www.gnu.org/licenses/>.  */ -// Package federation provides ActivityPub/federation functionality for GoToSocial  package federation  import (  	"context" +	"errors" +	"fmt"  	"net/http"  	"net/url" -	"time"  	"github.com/go-fed/activity/pub"  	"github.com/go-fed/activity/streams/vocab"  	"github.com/sirupsen/logrus"  	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/util"  ) -// New returns a go-fed compatible federating actor -func New(db db.DB, log *logrus.Logger) pub.FederatingActor { -	f := &Federator{ -		db: db, -	} -	return pub.NewFederatingActor(f, f, db.Federation(), f) -} - -// Federator implements several go-fed interfaces in one convenient location -type Federator struct { -	db db.DB -} -  /*  	GO FED FEDERATING PROTOCOL INTERFACE  	FederatingProtocol contains behaviors an application needs to satisfy for the @@ -70,9 +59,21 @@ type Federator struct {  // PostInbox. In this case, the DelegateActor implementation must not  // write a response to the ResponseWriter as is expected that the caller  // to PostInbox will do so when handling the error. -func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) { -	// TODO -	return nil, nil +func (f *federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) { +	l := f.log.WithFields(logrus.Fields{ +		"func":      "PostInboxRequestBodyHook", +		"useragent": r.UserAgent(), +		"url":       r.URL.String(), +	}) + +	if activity == nil { +		err := errors.New("nil activity in PostInboxRequestBodyHook") +		l.Debug(err) +		return nil, err +	} + +	ctxWithActivity := context.WithValue(ctx, util.APActivity, activity) +	return ctxWithActivity, nil  }  // AuthenticatePostInbox delegates the authentication of a POST to an @@ -91,9 +92,54 @@ func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques  // Finally, if the authentication and authorization succeeds, then  // authenticated must be true and error nil. The request will continue  // to be processed. -func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { -	// TODO -	return nil, false, nil +func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { +	l := f.log.WithFields(logrus.Fields{ +		"func":      "AuthenticatePostInbox", +		"useragent": r.UserAgent(), +		"url":       r.URL.String(), +	}) +	l.Trace("received request to authenticate") + +	requestedAccountI := ctx.Value(util.APAccount) +	if requestedAccountI == nil { +		return ctx, false, errors.New("requested account not set in context") +	} + +	requestedAccount, ok := requestedAccountI.(*gtsmodel.Account) +	if !ok || requestedAccount == nil { +		return ctx, false, errors.New("requested account not parsebale from context") +	} + +	publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r) +	if err != nil { +		l.Debugf("request not authenticated: %s", err) +		return ctx, false, fmt.Errorf("not authenticated: %s", err) +	} + +	requestingAccount := >smodel.Account{} +	if err := f.db.GetWhere("uri", publicKeyOwnerURI.String(), requestingAccount); err != nil { +		// there's been a proper error so return it +		if _, ok := err.(db.ErrNoEntries); !ok { +			return ctx, false, fmt.Errorf("error getting requesting account with public key id %s: %s", publicKeyOwnerURI.String(), err) +		} + +		// we don't know this account (yet) so let's dereference it right now +		// TODO: slow-fed +		person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI) +		if err != nil { +			return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err) +		} + +		a, err := f.typeConverter.ASRepresentationToAccount(person) +		if err != nil { +			return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err) +		} +		requestingAccount = a +	} + +	contextWithRequestingAccount := context.WithValue(ctx, util.APRequestingAccount, requestingAccount) + +	return contextWithRequestingAccount, true, nil  }  // Blocked should determine whether to permit a set of actors given by @@ -110,7 +156,7 @@ func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr  // Finally, if the authentication and authorization succeeds, then  // blocked must be false and error nil. The request will continue  // to be processed. -func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) { +func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) {  	// TODO  	return false, nil  } @@ -134,7 +180,7 @@ func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er  //  // Applications are not expected to handle every single ActivityStreams  // type and extension. The unhandled ones are passed to DefaultCallback. -func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) { +func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) {  	// TODO  	return pub.FederatingWrappedCallbacks{}, nil, nil  } @@ -146,8 +192,12 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrap  // Applications are not expected to handle every single ActivityStreams  // type and extension, so the unhandled ones are passed to  // DefaultCallback. -func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity) error { -	// TODO +func (f *federator) DefaultCallback(ctx context.Context, activity pub.Activity) error { +	l := f.log.WithFields(logrus.Fields{ +		"func":   "DefaultCallback", +		"aptype": activity.GetTypeName(), +	}) +	l.Debugf("received unhandle-able activity type so ignoring it")  	return nil  } @@ -155,7 +205,7 @@ func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity)  // an activity to determine if inbox forwarding needs to occur.  //  // Zero or negative numbers indicate infinite recursion. -func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int { +func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {  	// TODO  	return 0  } @@ -165,7 +215,7 @@ func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int {  // delivery.  //  // Zero or negative numbers indicate infinite recursion. -func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int { +func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int {  	// TODO  	return 0  } @@ -177,7 +227,7 @@ func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int {  //  // The activity is provided as a reference for more intelligent  // logic to be used, but the implementation must not modify it. -func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) { +func (f *federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) {  	// TODO  	return nil, nil  } @@ -190,114 +240,8 @@ func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients []  //  // Always called, regardless whether the Federated Protocol or Social  // API is enabled. -func (f *Federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { -	// TODO +func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { +	// IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through +	// the CLIENT API, not through the federation API, so we just do nothing here.  	return nil, nil  } - -/* -	GOFED COMMON BEHAVIOR INTERFACE -	Contains functions required for both the Social API and Federating Protocol. -	It is passed to the library as a dependency injection from the client -	application. -*/ - -// AuthenticateGetInbox delegates the authentication of a GET to an -// inbox. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -// -// If an error is returned, it is passed back to the caller of -// GetInbox. In this case, the implementation must not write a -// response to the ResponseWriter as is expected that the client will -// do so when handling the error. The 'authenticated' is ignored. -// -// If no error is returned, but authentication or authorization fails, -// then authenticated must be false and error nil. It is expected that -// the implementation handles writing to the ResponseWriter in this -// case. -// -// Finally, if the authentication and authorization succeeds, then -// authenticated must be true and error nil. The request will continue -// to be processed. -func (f *Federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { -	// TODO -	// use context.WithValue() and context.Value() to set and get values through here -	return nil, false, nil -} - -// AuthenticateGetOutbox delegates the authentication of a GET to an -// outbox. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -// -// If an error is returned, it is passed back to the caller of -// GetOutbox. In this case, the implementation must not write a -// response to the ResponseWriter as is expected that the client will -// do so when handling the error. The 'authenticated' is ignored. -// -// If no error is returned, but authentication or authorization fails, -// then authenticated must be false and error nil. It is expected that -// the implementation handles writing to the ResponseWriter in this -// case. -// -// Finally, if the authentication and authorization succeeds, then -// authenticated must be true and error nil. The request will continue -// to be processed. -func (f *Federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { -	// TODO -	return nil, false, nil -} - -// GetOutbox returns the OrderedCollection inbox of the actor for this -// context. It is up to the implementation to provide the correct -// collection for the kind of authorization given in the request. -// -// AuthenticateGetOutbox will be called prior to this. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -func (f *Federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { -	// TODO -	return nil, nil -} - -// NewTransport returns a new Transport on behalf of a specific actor. -// -// The actorBoxIRI will be either the inbox or outbox of an actor who is -// attempting to do the dereferencing or delivery. Any authentication -// scheme applied on the request must be based on this actor. The -// request must contain some sort of credential of the user, such as a -// HTTP Signature. -// -// The gofedAgent passed in should be used by the Transport -// implementation in the User-Agent, as well as the application-specific -// user agent string. The gofedAgent will indicate this library's use as -// well as the library's version number. -// -// Any server-wide rate-limiting that needs to occur should happen in a -// Transport implementation. This factory function allows this to be -// created, so peer servers are not DOS'd. -// -// Any retry logic should also be handled by the Transport -// implementation. -// -// Note that the library will not maintain a long-lived pointer to the -// returned Transport so that any private credentials are able to be -// garbage collected. -func (f *Federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) { -	// TODO -	return nil, nil -} - -/* -	GOFED CLOCK INTERFACE -	Determines the time. -*/ - -// Now returns the current time. -func (f *Federator) Now() time.Time { -	return time.Now() -} diff --git a/internal/federation/federator.go b/internal/federation/federator.go new file mode 100644 index 000000000..4fe0369b9 --- /dev/null +++ b/internal/federation/federator.go @@ -0,0 +1,79 @@ +/* +   GoToSocial +   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/transport" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Federator wraps various interfaces and functions to manage activitypub federation from gotosocial +type Federator interface { +	// FederatingActor returns the underlying pub.FederatingActor, which can be used to send activities, and serve actors at inboxes/outboxes. +	FederatingActor() pub.FederatingActor +	// AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources. +	// The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. +	AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) +	// DereferenceRemoteAccount can be used to get the representation of a remote account, based on the account ID (which is a URI). +	// The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. +	DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) +	// GetTransportForUser returns a new transport initialized with the key credentials belonging to the given username. +	// This can be used for making signed http requests. +	GetTransportForUser(username string) (pub.Transport, error) +	pub.CommonBehavior +	pub.FederatingProtocol +} + +type federator struct { +	config              *config.Config +	db                  db.DB +	clock               pub.Clock +	typeConverter       typeutils.TypeConverter +	transportController transport.Controller +	actor               pub.FederatingActor +	log                 *logrus.Logger +} + +// NewFederator returns a new federator +func NewFederator(db db.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { + +	clock := &Clock{} +	f := &federator{ +		config:              config, +		db:                  db, +		clock:               &Clock{}, +		typeConverter:       typeConverter, +		transportController: transportController, +		log:                 log, +	} +	actor := newFederatingActor(f, f, db.Federation(), clock) +	f.actor = actor +	return f +} + +func (f *federator) FederatingActor() pub.FederatingActor { +	return f.actor +} diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go new file mode 100644 index 000000000..2eab09507 --- /dev/null +++ b/internal/federation/federator_test.go @@ -0,0 +1,190 @@ +/* +   GoToSocial +   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation_test + +import ( +	"bytes" +	"context" +	"crypto/x509" +	"encoding/pem" +	"fmt" +	"io/ioutil" +	"net/http" +	"net/http/httptest" +	"strings" +	"testing" + +	"github.com/go-fed/activity/pub" +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/suite" + +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/federation" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/testrig" +) + +type ProtocolTestSuite struct { +	suite.Suite +	config        *config.Config +	db            db.DB +	log           *logrus.Logger +	storage       storage.Storage +	typeConverter typeutils.TypeConverter +	accounts      map[string]*gtsmodel.Account +	activities    map[string]testrig.ActivityWithSignature +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *ProtocolTestSuite) SetupSuite() { +	// setup standard items +	suite.config = testrig.NewTestConfig() +	suite.db = testrig.NewTestDB() +	suite.log = testrig.NewTestLog() +	suite.storage = testrig.NewTestStorage() +	suite.typeConverter = testrig.NewTestTypeConverter(suite.db) +	suite.accounts = testrig.NewTestAccounts() +	suite.activities = testrig.NewTestActivities(suite.accounts) +} + +func (suite *ProtocolTestSuite) SetupTest() { +	testrig.StandardDBSetup(suite.db) + +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *ProtocolTestSuite) TearDownTest() { +	testrig.StandardDBTeardown(suite.db) +} + +// make sure PostInboxRequestBodyHook properly sets the inbox username and activity on the context +func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() { + +	// the activity we're gonna use +	activity := suite.activities["dm_for_zork"] + +	// setup transport controller with a no-op client so we don't make external calls +	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { +		return nil, nil +	})) +	// setup module being tested +	federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + +	// setup request +	ctx := context.Background() +	request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting +	request.Header.Set("Signature", activity.SignatureHeader) + +	// trigger the function being tested, and return the new context it creates +	newContext, err := federator.PostInboxRequestBodyHook(ctx, request, activity.Activity) +	assert.NoError(suite.T(), err) +	assert.NotNil(suite.T(), newContext) + +	// activity should be set on context now +	activityI := newContext.Value(util.APActivity) +	assert.NotNil(suite.T(), activityI) +	returnedActivity, ok := activityI.(pub.Activity) +	assert.True(suite.T(), ok) +	assert.NotNil(suite.T(), returnedActivity) +	assert.EqualValues(suite.T(), activity.Activity, returnedActivity) +} + +func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() { + +	// the activity we're gonna use +	activity := suite.activities["dm_for_zork"] +	sendingAccount := suite.accounts["remote_account_1"] +	inboxAccount := suite.accounts["local_account_1"] + +	encodedPublicKey, err := x509.MarshalPKIXPublicKey(sendingAccount.PublicKey) +	assert.NoError(suite.T(), err) +	publicKeyBytes := pem.EncodeToMemory(&pem.Block{ +		Type:  "PUBLIC KEY", +		Bytes: encodedPublicKey, +	}) +	publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n") + +	// for this test we need the client to return the public key of the activity creator on the 'remote' instance +	responseBodyString := fmt.Sprintf(` +	{ +		"@context": [ +			"https://www.w3.org/ns/activitystreams", +			"https://w3id.org/security/v1" +		], + +		"id": "%s", +		"type": "Person", +		"preferredUsername": "%s", +		"inbox": "%s", + +		"publicKey": { +			"id": "%s", +			"owner": "%s", +			"publicKeyPem": "%s" +		} +	}`, sendingAccount.URI, sendingAccount.Username, sendingAccount.InboxURI, sendingAccount.PublicKeyURI, sendingAccount.URI, publicKeyString) + +	// create a transport controller whose client will just return the response body string we specified above +	tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { +		r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString))) +		return &http.Response{ +			StatusCode: 200, +			Body:       r, +		}, nil +	})) + +	// now setup module being tested, with the mock transport controller +	federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + +	// setup request +	ctx := context.Background() +	// by the time AuthenticatePostInbox is called, PostInboxRequestBodyHook should have already been called, +	// which should have set the account and username onto the request. We can replicate that behavior here: +	ctxWithAccount := context.WithValue(ctx, util.APAccount, inboxAccount) +	ctxWithActivity := context.WithValue(ctxWithAccount, util.APActivity, activity) + +	request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting +	// we need these headers for the request to be validated +	request.Header.Set("Signature", activity.SignatureHeader) +	request.Header.Set("Date", activity.DateHeader) +	request.Header.Set("Digest", activity.DigestHeader) +	// we can pass this recorder as a writer and read it back after +	recorder := httptest.NewRecorder() + +	// trigger the function being tested, and return the new context it creates +	newContext, authed, err := federator.AuthenticatePostInbox(ctxWithActivity, recorder, request) +	assert.NoError(suite.T(), err) +	assert.True(suite.T(), authed) + +	// since we know this account already it should be set on the context +	requestingAccountI := newContext.Value(util.APRequestingAccount) +	assert.NotNil(suite.T(), requestingAccountI) +	requestingAccount, ok := requestingAccountI.(*gtsmodel.Account) +	assert.True(suite.T(), ok) +	assert.Equal(suite.T(), sendingAccount.Username, requestingAccount.Username) +} + +func TestProtocolTestSuite(t *testing.T) { +	suite.Run(t, new(ProtocolTestSuite)) +} diff --git a/internal/federation/util.go b/internal/federation/util.go new file mode 100644 index 000000000..ab854db7c --- /dev/null +++ b/internal/federation/util.go @@ -0,0 +1,237 @@ +/* +   GoToSocial +   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + +   This program is free software: you can redistribute it and/or modify +   it under the terms of the GNU Affero General Public License as published by +   the Free Software Foundation, either version 3 of the License, or +   (at your option) any later version. + +   This program is distributed in the hope that it will be useful, +   but WITHOUT ANY WARRANTY; without even the implied warranty of +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +   GNU Affero General Public License for more details. + +   You should have received a copy of the GNU Affero General Public License +   along with this program.  If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( +	"context" +	"crypto/x509" +	"encoding/json" +	"encoding/pem" +	"errors" +	"fmt" +	"net/http" +	"net/url" + +	"github.com/go-fed/activity/pub" +	"github.com/go-fed/activity/streams" +	"github.com/go-fed/activity/streams/vocab" +	"github.com/go-fed/httpsig" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +/* +	publicKeyer is BORROWED DIRECTLY FROM https://github.com/go-fed/apcore/blob/master/ap/util.go +	Thank you @cj@mastodon.technology ! <3 +*/ +type publicKeyer interface { +	GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty +} + +/* +	getPublicKeyFromResponse is adapted from https://github.com/go-fed/apcore/blob/master/ap/util.go +	Thank you @cj@mastodon.technology ! <3 +*/ +func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (vocab.W3IDSecurityV1PublicKey, error) { +	m := make(map[string]interface{}) +	if err := json.Unmarshal(b, &m); err != nil { +		return nil, err +	} + +	t, err := streams.ToType(c, m) +	if err != nil { +		return nil, err +	} + +	pker, ok := t.(publicKeyer) +	if !ok { +		return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %T", t) +	} + +	pkp := pker.GetW3IDSecurityV1PublicKey() +	if pkp == nil { +		return nil, errors.New("publicKey property is not provided") +	} + +	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 nil, err +		} +		if pkID.String() != keyID.String() { +			continue +		} +		pkpFound = pkValue +		break +	} + +	if pkpFound == nil { +		return nil, fmt.Errorf("cannot find publicKey with id: %s", keyID) +	} + +	return pkpFound, nil +} + +// AuthenticateFederatedRequest authenticates any kind of incoming federated request from a remote server. This includes things like +// GET requests for dereferencing our users or statuses etc, and POST requests for delivering new Activities. The function returns +// the URL of the owner of the public key used in the http signature. +// +// Authenticate in this case is defined as just making sure that the http request is actually signed by whoever claims +// to have signed it, by fetching the public key from the signature and checking it against the remote public key. This function +// *does not* check whether the request is authorized, only whether it's authentic. +// +// The provided username will be used to generate a transport for making remote requests/derefencing the public key ID of the request signature. +// Ideally you should pass in the username of the user *being requested*, so that the remote server can decide how to handle the request based on who's making it. +// Ie., if the request on this server is for https://example.org/users/some_username then you should pass in the username 'some_username'. +// The remote server will then know that this is the user making the dereferencing request, and they can decide to allow or deny the request depending on their settings. +// +// Note that it is also valid to pass in an empty string here, in which case the keys of the instance account will be used. +// +// Also note that this function *does not* dereference the remote account that the signature key is associated with. +// Other functions should use the returned URL to dereference the remote account, if required. +func (f *federator) AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) { +	verifier, err := httpsig.NewVerifier(r) +	if err != nil { +		return nil, fmt.Errorf("could not create http sig verifier: %s", err) +	} + +	// The key ID should be given in the signature so that we know where to fetch it from the remote server. +	// This will be something like https://example.org/users/whatever_requesting_user#main-key +	requestingPublicKeyID, err := url.Parse(verifier.KeyId()) +	if err != nil { +		return nil, fmt.Errorf("could not parse key id into a url: %s", err) +	} + +	transport, err := f.GetTransportForUser(username) +	if err != nil { +		return nil, fmt.Errorf("transport err: %s", err) +	} + +	// The actual http call to the remote server is made right here in the Dereference function. +	b, err := transport.Dereference(context.Background(), requestingPublicKeyID) +	if err != nil { +		return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) +	} + +	// if the key isn't in the response, we can't authenticate the request +	requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID) +	if err != nil { +		return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err) +	} + +	// we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey +	pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem() +	if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { +		return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value") +	} + +	// and decode the PEM so that we can parse it as a golang public key +	pubKeyPem := pkPemProp.Get() +	block, _ := pem.Decode([]byte(pubKeyPem)) +	if block == nil || block.Type != "PUBLIC KEY" { +		return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") +	} + +	p, err := x509.ParsePKIXPublicKey(block.Bytes) +	if err != nil { +		return nil, fmt.Errorf("could not parse public key from block bytes: %s", err) +	} +	if p == nil { +		return nil, errors.New("returned public key was empty") +	} + +	// do the actual authentication here! +	algo := httpsig.RSA_SHA256 // TODO: make this more robust +	if err := verifier.Verify(p, algo); err != nil { +		return nil, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err) +	} + +	// all good! we just need the URI of the key owner to return +	pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner() +	if pkOwnerProp == nil || !pkOwnerProp.IsIRI() { +		return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value") +	} +	pkOwnerURI := pkOwnerProp.GetIRI() + +	return pkOwnerURI, nil +} + +func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) { + +	transport, err := f.GetTransportForUser(username) +	if err != nil { +		return nil, fmt.Errorf("transport err: %s", err) +	} + +	b, err := transport.Dereference(context.Background(), remoteAccountID) +	if err != nil { +		return nil, fmt.Errorf("error deferencing %s: %s", remoteAccountID.String(), err) +	} + +	m := make(map[string]interface{}) +	if err := json.Unmarshal(b, &m); err != nil { +		return nil, fmt.Errorf("error unmarshalling bytes into json: %s", err) +	} + +	t, err := streams.ToType(context.Background(), m) +	if err != nil { +		return nil, fmt.Errorf("error resolving json into ap vocab type: %s", err) +	} + +	switch t.GetTypeName() { +	case string(gtsmodel.ActivityStreamsPerson): +		p, ok := t.(vocab.ActivityStreamsPerson) +		if !ok { +			return nil, errors.New("error resolving type as activitystreams person") +		} +		return p, nil +	case string(gtsmodel.ActivityStreamsApplication): +		// TODO: convert application into person +	} + +	return nil, fmt.Errorf("type name %s not supported", t.GetTypeName()) +} + +func (f *federator) GetTransportForUser(username string) (pub.Transport, error) { +	// We need an account to use to create a transport for dereferecing the signature. +	// If a username has been given, we can fetch the account with that username and use it. +	// Otherwise, we can take the instance account and use those credentials to make the request. +	ourAccount := >smodel.Account{} +	var u string +	if username == "" { +		u = f.config.Host +	} else { +		u = username +	} +	if err := f.db.GetLocalAccountByUsername(u, ourAccount); err != nil { +		return nil, fmt.Errorf("error getting account %s from db: %s", username, err) +	} + +	transport, err := f.transportController.NewTransport(ourAccount.PublicKeyURI, ourAccount.PrivateKey) +	if err != nil { +		return nil, fmt.Errorf("error creating transport for user %s: %s", username, err) +	} +	return transport, nil +} | 
