diff --git a/internal/federation/protocol.go b/internal/federation/protocol.go index 686c4d67c..158afe184 100644 --- a/internal/federation/protocol.go +++ b/internal/federation/protocol.go @@ -105,6 +105,8 @@ func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques } l.Tracef("parsed username %s from %s", username, r.URL.String()) + l.Tracef("signature: %s", r.Header.Get("Signature")) + ctxWithUsername := context.WithValue(ctx, util.APUsernameKey, username) ctxWithActivity := context.WithValue(ctxWithUsername, util.APActivityKey, activity) return ctxWithActivity, nil @@ -148,7 +150,15 @@ func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } l.Tracef("parsed username %s from %s", username, r.URL.String()) - return validateInboundFederationRequest(ctx, r, f.db, username, f.transportController) + newContext, authed, err := validateInboundFederationRequest(ctx, r, f.db, username, f.transportController) + + if err != nil { + l.Debug(err) + } + + return newContext, authed, err + + } // 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 10ed417e2..b30c4c7c1 100644 --- a/internal/federation/protocol_test.go +++ b/internal/federation/protocol_test.go @@ -19,11 +19,15 @@ package federation_test import ( + "bytes" "context" - "encoding/json" + "crypto/x509" + "encoding/pem" "fmt" + "io/ioutil" "net/http" "net/http/httptest" + "strings" "testing" "github.com/go-fed/activity/pub" @@ -33,8 +37,8 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -44,9 +48,8 @@ type ProtocolTestSuite struct { config *config.Config db db.DB log *logrus.Logger - federator *federation.Federator - tc transport.Controller - activities map[string]pub.Activity + 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 @@ -55,17 +58,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, suite.tc).(*federation.Federator) + 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 @@ -76,16 +75,27 @@ func (suite *ProtocolTestSuite) TearDownTest() { // 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 + // 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, suite.log, suite.config, tc).(*federation.Federator) + + // 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) - newContext, err := suite.federator.PostInboxRequestBodyHook(ctx, request, activity) + // 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) + // username should be set on context now usernameI := newContext.Value(util.APUsernameKey) assert.NotNil(suite.T(), usernameI) username, ok := usernameI.(string) @@ -93,21 +103,87 @@ func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() { assert.NotEmpty(suite.T(), username) assert.Equal(suite.T(), "the_mighty_zork", username) + // activity should be set on context now activityI := newContext.Value(util.APActivityKey) 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, returnedActivity) + assert.EqualValues(suite.T(), activity.Activity, returnedActivity) +} - r, err := returnedActivity.Serialize() +func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() { + + // the activity we're gonna use + activity := suite.activities["dm_for_zork"] + sendingAccount := suite.accounts["remote_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") - b, err := json.Marshal(r) + // 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.InboxURL, 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, suite.log, suite.config, tc) + + // setup request + ctx := context.Background() + 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) + + // trigger the function being tested, and return the new context it creates + newContext, authed, err := federator.AuthenticatePostInbox(ctx, nil, request) assert.NoError(suite.T(), err) + assert.True(suite.T(), authed) - fmt.Println(string(b)) + // since we know this account already it should be set on the context + requestingAccountI := newContext.Value(util.APRequestingAccountKey) + assert.NotNil(suite.T(), requestingAccountI) + requestingAccount, ok := requestingAccountI.(*gtsmodel.Account) + assert.True(suite.T(), ok) + assert.Equal(suite.T(), sendingAccount.Username, requestingAccount.Username) + // the host making the request should also be set on the context + requestingHostI := newContext.Value(util.APRequestingHostKey) + assert.NotNil(suite.T(), requestingHostI) + requestingHost, ok := requestingHostI.(string) + assert.True(suite.T(), ok) + assert.Equal(suite.T(), sendingAccount.Domain, requestingHost) } func TestProtocolTestSuite(t *testing.T) { diff --git a/internal/federation/util.go b/internal/federation/util.go index f9cdce1bd..dff73cae7 100644 --- a/internal/federation/util.go +++ b/internal/federation/util.go @@ -35,6 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/transport" + "github.com/superseriousbusiness/gotosocial/internal/util" ) /* @@ -110,41 +111,62 @@ func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (p cr // 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) { +// +// A *side effect* of calling this function is that the name of the host making the request will be set +// onto the returned context, using APRequestingHostKey. If known to us already, the remote account making +// the request will also be set on the context, using APRequestingAccountKey. If not known to us already, +// the value of this key will be set to nil and the account will have to be fetched further down the line. +func validateInboundFederationRequest(ctx context.Context, request *http.Request, dbConn 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()) + requestingPublicKeyID, 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 { + requestedAccount := >smodel.Account{} + if err := dbConn.GetWhere("username", inboxUsername, requestedAccount); 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) + transport, err := transportController.NewTransport(requestedAccount.PublicKeyURI, requestedAccount.PrivateKey) if err != nil { return ctx, false, fmt.Errorf("error creating new transport: %s", err) } - b, err := transport.Dereference(ctx, requesterPublicKeyID) + b, err := transport.Dereference(ctx, requestingPublicKeyID) if err != nil { - return ctx, false, fmt.Errorf("error deferencing key %s: %s", requesterPublicKeyID.String(), err) + return ctx, false, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) } - requesterPublicKey, err := getPublicKeyFromResponse(ctx, b, requesterPublicKeyID) + requestingPublicKey, err := getPublicKeyFromResponse(ctx, b, requestingPublicKeyID) if err != nil { - return ctx, false, fmt.Errorf("error getting key %s from response %s: %s", requesterPublicKeyID.String(), string(b), err) + return ctx, false, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.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) + if err := v.Verify(requestingPublicKey, algo); err != nil { + return ctx, false, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err) } - return ctx, true, nil + var requestingAccount *gtsmodel.Account + a := >smodel.Account{} + if err := dbConn.GetWhere("public_key_uri", requestingPublicKeyID.String(), a); err == nil { + // we know about this account already so we can set it on the context + requestingAccount = a + } else { + if _, ok := err.(db.ErrNoEntries); !ok { + return ctx, false, fmt.Errorf("database error finding account with public key uri %s: %s", requestingPublicKeyID.String(), err) + } + // do nothing here, requestingAccount will stay nil and we'll have to figure it out further down the line + } + + // all good at this point, so just set some stuff on the context + contextWithHost := context.WithValue(ctx, util.APRequestingHostKey, requestingPublicKeyID.Host) + contextWithRequestingAccount := context.WithValue(contextWithHost, util.APRequestingAccountKey, requestingAccount) + + return contextWithRequestingAccount, true, nil } diff --git a/internal/transport/controller.go b/internal/transport/controller.go index 5bfc123fd..a4654dfe8 100644 --- a/internal/transport/controller.go +++ b/internal/transport/controller.go @@ -26,7 +26,6 @@ import ( "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. @@ -36,17 +35,15 @@ type Controller interface { 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 { +func NewController(config *config.Config,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), @@ -54,10 +51,10 @@ func NewController(config *config.Config, db db.DB, clock pub.Clock, client pub. } 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"} + prefs := []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512} + digestAlgo := httpsig.DigestSha256 + getHeaders := []string{"(request-target)", "date"} + postHeaders := []string{"(request-target)", "date", "digest"} getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature) if err != nil { diff --git a/internal/util/uri.go b/internal/util/uri.go index 1b748ace0..217352435 100644 --- a/internal/util/uri.go +++ b/internal/util/uri.go @@ -56,7 +56,11 @@ const ( // APActivityKey can be used to set and retrieve the actual go-fed pub.Activity within a context. APActivityKey APContextKey = "activity" // APUsernameKey can be used to set and retrieve the username of the user being interacted with. - APUsernameKey APContextKey = "username" + APUsernameKey APContextKey = "username" + // APRequestingHostKey can be used to set and retrieve the host of an incoming federation request. + APRequestingHostKey APContextKey = "requestingHost" + // APRequestingAccountKey can be used to set and retrieve the account of an incoming federation request. + APRequestingAccountKey APContextKey = "requestingAccount" ) // UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc. diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 56d3f87dd..8a9843b46 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -19,9 +19,15 @@ package testrig import ( + "bytes" + "context" + "crypto" "crypto/rand" "crypto/rsa" + "encoding/json" + "io/ioutil" "net" + "net/http" "net/url" "time" @@ -287,6 +293,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { AlsoKnownAs: "", PrivateKey: &rsa.PrivateKey{}, PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://localhost:8080/users/weed_lord420/publickey", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -314,6 +321,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { Language: "en", URI: "http://localhost:8080/users/admin", URL: "http://localhost:8080/@admin", + PublicKeyURI: "http://localhost:8080/users/admin/publickey", LastWebfingeredAt: time.Time{}, InboxURL: "http://localhost:8080/users/admin/inbox", OutboxURL: "http://localhost:8080/users/admin/outbox", @@ -361,6 +369,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { AlsoKnownAs: "", PrivateKey: &rsa.PrivateKey{}, PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://localhost:8080/users/the_mighty_zork/publickey", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -398,6 +407,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { AlsoKnownAs: "", PrivateKey: &rsa.PrivateKey{}, PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://localhost:8080/users/1happyturtle/publickey", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -440,8 +450,9 @@ func NewTestAccounts() map[string]*gtsmodel.Account { FeaturedCollectionURL: "https://fossbros-anonymous.io/users/foss_satan/collections/featured", ActorType: gtsmodel.ActivityStreamsPerson, AlsoKnownAs: "", - PrivateKey: &rsa.PrivateKey{}, - PublicKey: nil, + PrivateKey: nil, + PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://fossbros-anonymous.io/users/foss_satan#publickey", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -472,10 +483,10 @@ func NewTestAccounts() map[string]*gtsmodel.Account { } pub := &priv.PublicKey - // only local accounts get a private key - if v.Domain == "" { - v.PrivateKey = priv - } + // normally only local accounts get a private key (obviously) + // but for testing purposes and signing requests, we'll give + // remote accounts a private key as well + v.PrivateKey = priv v.PublicKey = pub } return accounts @@ -998,8 +1009,17 @@ func NewTestFaves() map[string]*gtsmodel.StatusFave { } } +type ActivityWithSignature struct { + Activity pub.Activity + SignatureHeader string + DigestHeader string + DateHeader string +} + // NewTestActivities returns a bunch of pub.Activity types for use in testing the federation protocols. -func NewTestActivities() map[string]pub.Activity { +// A struct of accounts needs to be passed in because the activities will also be bundled along with +// their requesting signatures. +func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { dmForZork := newNote( URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6"), URLMustParse("https://fossbros-anonymous.io/@foss_satan/5424b153-4553-4f30-9358-7b92f7cd42f6"), @@ -1014,12 +1034,63 @@ func NewTestActivities() map[string]pub.Activity { URLMustParse("https://fossbros-anonymous.io/users/foss_satan"), time.Now(), dmForZork) + sig, digest, date := getSignatureForActivity(createDmForZork, accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].InboxURL)) - return map[string]pub.Activity{ - "dm_for_zork": createDmForZork, + return map[string]ActivityWithSignature{ + "dm_for_zork": { + Activity: createDmForZork, + SignatureHeader: sig, + DigestHeader: digest, + DateHeader: date, + }, } } +// getSignatureForActivity does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive +// the HTTP Signature for the given activity, public key ID, private key, and destination. +func getSignatureForActivity(activity pub.Activity, pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { + + // create a client that basically just pulls the signature out of the request and sets it + client := &mockHTTPClient{ + do: func(req *http.Request) (*http.Response, error) { + signatureHeader = req.Header.Get("Signature") + digestHeader = req.Header.Get("Digest") + dateHeader = req.Header.Get("Date") + r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + }, + } + + // use the client to create a new transport + c := NewTestTransportController(client) + tp, err := c.NewTransport(pubKeyID, privkey) + if err != nil { + panic(err) + } + + // convert the activity into json bytes + m, err := activity.Serialize() + if err != nil { + panic(err) + } + bytes, err := json.Marshal(m) + if err != nil { + panic(err) + } + + // trigger the delivery function, which will trigger the 'do' function of the recorder above + if err := tp.Deliver(context.Background(), bytes, destination); err != nil { + panic(err) + } + + // headers should now be populated + return +} + +// newNote returns a new activity streams note for the given parameters func newNote( noteID *url.URL, noteURL *url.URL, @@ -1071,6 +1142,7 @@ func newNote( return note } +// wrapNoteInCreate wraps the given activity streams note in a Create activity streams action func wrapNoteInCreate(createID *url.URL, createActor *url.URL, createPublished time.Time, createNote vocab.ActivityStreamsNote) vocab.ActivityStreamsCreate { // create the.... create create := streams.NewActivityStreamsCreate() diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go index 00b6485ae..942be7f3a 100644 --- a/testrig/transportcontroller.go +++ b/testrig/transportcontroller.go @@ -22,7 +22,6 @@ 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" ) @@ -36,8 +35,8 @@ import ( // 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()) +func NewTestTransportController(client pub.HttpClient) transport.Controller { + return transport.NewController(NewTestConfig(), &federation.Clock{}, client, NewTestLog()) } // NewMockHTTPClient returns a client that conforms to the pub.HttpClient interface,