diff --git a/go.mod b/go.mod index 07edd0a..d1cefcf 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gin-contrib/sessions v0.0.3 github.com/gin-gonic/gin v1.6.3 github.com/go-fed/activity v1.0.0 + github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5 github.com/go-pg/pg/extra/pgdebug v0.2.0 github.com/go-pg/pg/v10 v10.8.0 github.com/golang/mock v1.4.4 // indirect diff --git a/internal/db/gtsmodel/account.go b/internal/db/gtsmodel/account.go index 4bf5a9d..ed06eba 100644 --- a/internal/db/gtsmodel/account.go +++ b/internal/db/gtsmodel/account.go @@ -115,6 +115,8 @@ type Account struct { PrivateKey *rsa.PrivateKey // Publickey for encoding activitypub requests, will be defined for both local and remote accounts PublicKey *rsa.PublicKey + // Web-reachable location of this account's public key + PublicKeyURI string /* ADMIN FIELDS diff --git a/internal/db/pg.go b/internal/db/pg.go index 4353be2..a6ae8ce 100644 --- a/internal/db/pg.go +++ b/internal/db/pg.go @@ -465,6 +465,7 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr URL: newAccountURIs.UserURL, PrivateKey: key, PublicKey: &key.PublicKey, + PublicKeyURI: newAccountURIs.PublicKeyURI, ActorType: gtsmodel.ActivityStreamsPerson, URI: newAccountURIs.UserURI, InboxURL: newAccountURIs.InboxURI, diff --git a/internal/federation/protocol.go b/internal/federation/protocol.go index 721466f..686c4d6 100644 --- a/internal/federation/protocol.go +++ b/internal/federation/protocol.go @@ -30,22 +30,25 @@ import ( "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/util" ) // Federator implements the go-fed federating protocol interface type Federator struct { - db db.DB - log *logrus.Logger - config *config.Config + db db.DB + log *logrus.Logger + config *config.Config + transportController transport.Controller } // NewFederator returns the gotosocial implementation of the go-fed FederatingProtocol interface -func NewFederator(db db.DB, log *logrus.Logger, config *config.Config) pub.FederatingProtocol { +func NewFederator(db db.DB, log *logrus.Logger, config *config.Config, transportController transport.Controller) pub.FederatingProtocol { return &Federator{ - db: db, - log: log, - config: config, + db: db, + log: log, + config: config, + transportController: transportController, } } @@ -76,17 +79,18 @@ func NewFederator(db db.DB, log *logrus.Logger, config *config.Config) pub.Feder // 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) { - if activity == nil { - return nil, errors.New("nil activity in PostInboxRequestBodyHook") - } - l := f.log.WithFields(logrus.Fields{ "func": "PostInboxRequestBodyHook", "useragent": r.UserAgent(), "url": r.URL.String(), - "aptype": activity.GetTypeName(), }) + if activity == nil { + err := errors.New("nil activity in PostInboxRequestBodyHook") + l.Debug(err) + return nil, err + } + if !util.IsInboxPath(r.URL) { err := fmt.Errorf("url %s did not corresponding to inbox path", r.URL.String()) l.Debug(err) @@ -123,8 +127,28 @@ func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques // 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 + l := f.log.WithFields(logrus.Fields{ + "func": "AuthenticatePostInbox", + "useragent": r.UserAgent(), + "url": r.URL.String(), + }) + l.Trace("received request to authenticate") + + if !util.IsInboxPath(r.URL) { + err := fmt.Errorf("url %s did not corresponding to inbox path", r.URL.String()) + l.Debug(err) + return nil, false, err + } + + username, err := util.ParseInboxPath(r.URL) + if err != nil { + err := fmt.Errorf("could not parse username from url: %s", r.URL.String()) + l.Debug(err) + return nil, false, err + } + l.Tracef("parsed username %s from %s", username, r.URL.String()) + + return validateInboundFederationRequest(ctx, r, f.db, username, f.transportController) } // Blocked should determine whether to permit a set of actors given by diff --git a/internal/federation/protocol_test.go b/internal/federation/protocol_test.go index cbdf27a..8147808 100644 --- a/internal/federation/protocol_test.go +++ b/internal/federation/protocol_test.go @@ -34,6 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -44,6 +45,7 @@ type ProtocolTestSuite struct { db db.DB log *logrus.Logger federator *federation.Federator + tc transport.Controller activities map[string]pub.Activity } @@ -53,10 +55,13 @@ func (suite *ProtocolTestSuite) SetupSuite() { suite.config = testrig.NewTestConfig() suite.db = testrig.NewTestDB() suite.log = testrig.NewTestLog() + suite.tc = testrig.NewTestTransportController(suite.db, testrig.NewMockHTTPClient(func(req *http.Request)(*http.Response, error) { + return nil, nil + })) suite.activities = testrig.NewTestActivities() // setup module being tested - suite.federator = federation.NewFederator(suite.db, suite.log, suite.config).(*federation.Federator) + suite.federator = federation.NewFederator(suite.db, suite.log, suite.config, suite.tc).(*federation.Federator) } func (suite *ProtocolTestSuite) SetupTest() { diff --git a/internal/federation/util.go b/internal/federation/util.go new file mode 100644 index 0000000..e1367b9 --- /dev/null +++ b/internal/federation/util.go @@ -0,0 +1,151 @@ +/* + 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 . +*/ + +package federation + +import ( + "context" + "crypto" + "crypto/x509" + "encoding/json" + "encoding/pem" + "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/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/transport" +) + + +/* + 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 BORROWED DIRECTLY 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) (p crypto.PublicKey, err error) { + m := make(map[string]interface{}, 0) + err = json.Unmarshal(b, &m) + if err != nil { + return + } + var t vocab.Type + t, err = streams.ToType(c, 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", keyId) + 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 +} + +// validateInboundFederationRequest validates an incoming federation request (!!) by deriving the public key +// of the requester from the request, checking the owner of the inbox that's being requested, and doing +// some fiddling around with http signatures. +func validateInboundFederationRequest(ctx context.Context, request *http.Request, db db.DB, inboxUsername string, transportController transport.Controller) (context.Context, bool, error) { + v, err := httpsig.NewVerifier(request) + if err != nil { + return ctx, false, fmt.Errorf("could not create http sig verifier: %s", err) + } + + requesterPublicKeyID, err := url.Parse(v.KeyId()) + if err != nil { + return ctx, false, fmt.Errorf("could not create parse key id into a url: %s", err) + } + + acct := >smodel.Account{} + if err := db.GetWhere("username", inboxUsername, acct); err != nil { + return ctx, false, fmt.Errorf("could not fetch username %s from the database: %s", inboxUsername, err) + } + + transport, err := transportController.NewTransport(acct.PublicKeyURI, acct.PrivateKey) + if err != nil { + return ctx, false, fmt.Errorf("error creating new transport: %s", err) + } + + b, err := transport.Dereference(ctx, requesterPublicKeyID) + if err != nil { + return ctx, false, fmt.Errorf("error deferencing key %s: %s", requesterPublicKeyID.String(), err) + } + + requesterPublicKey, err := getPublicKeyFromResponse(ctx, b, requesterPublicKeyID) + if err != nil { + return ctx, false, fmt.Errorf("error getting key %s from response %s: %s", requesterPublicKeyID.String(), string(b), err) + } + + algo := httpsig.RSA_SHA256 + if err := v.Verify(requesterPublicKey, algo); err != nil { + return ctx, false, fmt.Errorf("error verifying key %s: %s", requesterPublicKeyID.String(), err) + } + + return ctx, true, nil +} diff --git a/internal/transport/controller.go b/internal/transport/controller.go new file mode 100644 index 0000000..5bfc123 --- /dev/null +++ b/internal/transport/controller.go @@ -0,0 +1,73 @@ +/* + 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 . +*/ + +package transport + +import ( + "crypto" + "fmt" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/httpsig" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" +) + +// Controller generates transports for use in making federation requests to other servers. +type Controller interface { + NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) +} + +type controller struct { + config *config.Config + db db.DB + clock pub.Clock + client pub.HttpClient + appAgent string +} + +// NewController returns an implementation of the Controller interface for creating new transports +func NewController(config *config.Config, db db.DB, clock pub.Clock, client pub.HttpClient, log *logrus.Logger) Controller { + return &controller{ + config: config, + db: db, + clock: clock, + client: client, + appAgent: fmt.Sprintf("%s %s", config.ApplicationName, config.Host), + } +} + +func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) { + prefs := []httpsig.Algorithm{httpsig.Algorithm("rsa-sha256"), httpsig.Algorithm("rsa-sha512")} + digestAlgo := httpsig.DigestAlgorithm("SHA-256") + getHeaders := []string{"(request-target)", "Date"} + postHeaders := []string{"(request-target)", "Date", "Digest"} + + getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature) + if err != nil { + return nil, fmt.Errorf("error creating get signer: %s", err) + } + + postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature) + if err != nil { + return nil, fmt.Errorf("error creating post signer: %s", err) + } + + return pub.NewHttpSigTransport(c.client, c.appAgent, c.clock, getSigner, postSigner, pubKeyID, privkey), nil +} diff --git a/internal/util/uri.go b/internal/util/uri.go index 9d16fee..1b748ac 100644 --- a/internal/util/uri.go +++ b/internal/util/uri.go @@ -45,6 +45,8 @@ const ( CollectionsPath = "collections" // FeaturedPath represents the webfinger featured location FeaturedPath = "featured" + // PublicKeyPath is for serving an account's public key + PublicKeyPath = "publickey" ) // APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains @@ -82,6 +84,8 @@ type UserURIs struct { LikedURI string // The webfinger URI for this user's featured collections, eg., https://example.org/users/example_user/collections/featured CollectionURI string + // The URI for this user's public key, eg., https://example.org/users/example_user/publickey + PublicKeyURI string } // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. @@ -100,6 +104,8 @@ func GenerateURIsForAccount(username string, protocol string, host string) *User followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath) likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath) collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath) + publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath) + return &UserURIs{ HostURL: hostURL, UserURL: userURL, @@ -113,6 +119,7 @@ func GenerateURIsForAccount(username string, protocol string, host string) *User FollowingURI: followingURI, LikedURI: likedURI, CollectionURI: collectionURI, + PublicKeyURI: publicKeyURI, } } diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go new file mode 100644 index 0000000..c46ec5d --- /dev/null +++ b/testrig/transportcontroller.go @@ -0,0 +1,57 @@ +/* + 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 . +*/ + +package testrig + +import ( + "net/http" + + "github.com/go-fed/activity/pub" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/transport" +) + +// NewTestTransportController returns a test transport controller with the given http client. +// +// Obviously for testing purposes you should not be making actual http calls to other servers. +// To obviate this, use the function NewMockHTTPClient in this package to return a mock http +// client that doesn't make any remote calls but just returns whatever you tell it to. +// +// Unlike the other test interfaces provided in this package, you'll probably want to call this function +// PER TEST rather than per suite, so that the do function can be set on a test by test (or even more granular) +// basis. +func NewTestTransportController(db db.DB, client pub.HttpClient) transport.Controller { + return transport.NewController(NewTestConfig(), db, &federation.Clock{}, client, NewTestLog()) +} + +// NewMockHTTPClient returns a client that conforms to the pub.HttpClient interface, +// but will always just execute the given `do` function, allowing responses to be mocked. +func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error)) pub.HttpClient { + return &mockHttpClient{ + do: do, + } +} + +type mockHttpClient struct { + do func(req *http.Request) (*http.Response, error) +} + +func (m *mockHttpClient) Do(req *http.Request) (*http.Response, error) { + return m.do(req) +}