diff options
author | 2023-09-12 11:43:12 +0200 | |
---|---|---|
committer | 2023-09-12 10:43:12 +0100 | |
commit | 4b594516ec5fe6d849663d877db5a0614de03089 (patch) | |
tree | d822d87aaba9d2836294198d43bc59fc210b6167 /internal | |
parent | [feature] Support Actor URIs for webfinger queries (#2187) (diff) | |
download | gotosocial-4b594516ec5fe6d849663d877db5a0614de03089.tar.xz |
[feature] Allow admins to expire remote public keys; refetch expired keys on demand (#2183)
Diffstat (limited to 'internal')
-rw-r--r-- | internal/api/client/admin/admin.go | 4 | ||||
-rw-r--r-- | internal/api/client/admin/domainkeysexpire.go | 149 | ||||
-rw-r--r-- | internal/api/model/admin.go | 11 | ||||
-rw-r--r-- | internal/api/model/domain.go | 8 | ||||
-rw-r--r-- | internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go | 45 | ||||
-rw-r--r-- | internal/federation/authenticate.go | 232 | ||||
-rw-r--r-- | internal/federation/federatingprotocol.go | 20 | ||||
-rw-r--r-- | internal/federation/federatingprotocol_test.go | 27 | ||||
-rw-r--r-- | internal/federation/federator.go | 3 | ||||
-rw-r--r-- | internal/federation/federator_test.go | 11 | ||||
-rw-r--r-- | internal/gtsmodel/account.go | 16 | ||||
-rw-r--r-- | internal/gtsmodel/adminaction.go | 5 | ||||
-rw-r--r-- | internal/processing/admin/domainkeysexpire.go | 87 | ||||
-rw-r--r-- | internal/processing/fedi/common.go | 6 | ||||
-rw-r--r-- | internal/processing/fedi/user.go | 9 |
15 files changed, 559 insertions, 74 deletions
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index ce6604c29..605c53731 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -31,6 +31,7 @@ const ( EmojiCategoriesPath = EmojiPath + "/categories" DomainBlocksPath = BasePath + "/domain_blocks" DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey + DomainKeysExpirePath = BasePath + "/domain_keys_expire" AccountsPath = BasePath + "/accounts" AccountsPathWithID = AccountsPath + "/:" + IDKey AccountsActionPath = AccountsPathWithID + "/action" @@ -83,6 +84,9 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) + // domain maintenance stuff + attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler) + // accounts stuff attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler) diff --git a/internal/api/client/admin/domainkeysexpire.go b/internal/api/client/admin/domainkeysexpire.go new file mode 100644 index 000000000..73a811dd4 --- /dev/null +++ b/internal/api/client/admin/domainkeysexpire.go @@ -0,0 +1,149 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 admin + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainKeysExpirePOSTHandler swagger:operation POST /api/v1/admin/domain_keys_expire domainKeysExpire +// +// Force expiry of cached public keys for all accounts on the given domain stored in your database. +// +// This is useful in cases where the remote domain has had to rotate their keys for whatever +// reason (security issue, data leak, routine safety procedure, etc), and your instance can no +// longer communicate with theirs properly using cached keys. A key marked as expired in this way +// will be lazily refetched next time a request is made to your instance signed by the owner of that +// key, so no further action should be required in order to reestablish communication with that domain. +// +// This endpoint is explicitly not for rotating your *own* keys, it only works for remote instances. +// +// Using this endpoint to expire keys for a domain that hasn't rotated all of their keys is not +// harmful and won't break federation, but it is pointless and will cause unnecessary requests to +// be performed. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// +// produces: +// - application/json +// +// parameters: +// - +// name: domain +// in: formData +// description: Domain to expire keys for. +// example: example.org +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '202': +// description: >- +// Request accepted and will be processed. +// Check the logs for progress / errors. +// schema: +// "$ref": "#/definitions/adminActionResponse" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '409': +// description: >- +// Conflict: There is already an admin action running that conflicts with this action. +// Check the error message in the response body for more information. This is a temporary +// error; it should be possible to process this action if you try again in a bit. +// '500': +// description: internal server error +func (m *Module) DomainKeysExpirePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + form := new(apimodel.DomainKeysExpireRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := validateDomainKeysExpire(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + actionID, errWithCode := m.processor.Admin().DomainKeysExpire( + c.Request.Context(), + authed.Account, + form.Domain, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusAccepted, &apimodel.AdminActionResponse{ActionID: actionID}) +} + +func validateDomainKeysExpire(form *apimodel.DomainKeysExpireRequest) error { + form.Domain = strings.TrimSpace(form.Domain) + if form.Domain == "" { + return errors.New("no domain given") + } + + if form.Domain == config.GetHost() || form.Domain == config.GetAccountDomain() { + return errors.New("provided domain was this domain, but must be a remote domain") + } + + return nil +} diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go index 6be3e9cbd..ca4aa32da 100644 --- a/internal/api/model/admin.go +++ b/internal/api/model/admin.go @@ -178,6 +178,17 @@ type AdminActionRequest struct { TargetID string `form:"-" json:"-" xml:"-"` } +// AdminActionResponse models the server +// response to an admin action. +// +// swagger:model adminActionResponse +type AdminActionResponse struct { + // Internal ID of the action. + // + // example: 01H9QG6TZ9W5P0402VFRVM17TH + ActionID string `json:"action_id"` +} + // MediaCleanupRequest models admin media cleanup parameters // // swagger:parameters mediaCleanup diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index 045dc2700..c5f77c82f 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -79,3 +79,11 @@ type DomainBlockCreateRequest struct { // public comment on the reason for the domain block PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"` } + +// DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys. +// +// swagger:model domainKeysExpireRequest +type DomainKeysExpireRequest struct { + // hostname/domain to expire keys for. + Domain string `form:"domain" json:"domain" xml:"domain"` +} diff --git a/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go b/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go new file mode 100644 index 000000000..814540505 --- /dev/null +++ b/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go @@ -0,0 +1,45 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 migrations + +import ( + "context" + "strings" + + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TIMESTAMPTZ", bun.Ident("accounts"), bun.Ident("public_key_expires_at")) + if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) { + return err + } + return nil + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/federation/authenticate.go b/internal/federation/authenticate.go index 80998680e..12d6f459a 100644 --- a/internal/federation/authenticate.go +++ b/internal/federation/authenticate.go @@ -25,14 +25,17 @@ import ( "fmt" "net/http" "net/url" + "time" "codeberg.org/gruf/go-kv" "github.com/go-fed/httpsig" "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -45,11 +48,47 @@ var ( } ) +// PubKeyAuth models authorization information for a remote +// Actor making a signed HTTP request to this GtS instance +// using a public key. +type PubKeyAuth struct { + // CachedPubKey is the public key found in the db + // for the Actor whose request we're now authenticating. + // Will be set only in cases where we had the Owner + // of the key stored in the database already. + CachedPubKey *rsa.PublicKey + + // FetchedPubKey is an up-to-date public key fetched + // from the remote instance. Will be set in cases + // where EITHER we hadn't seen the Actor before whose + // request we're now authenticating, OR a CachedPubKey + // was found in our database, but was expired. + FetchedPubKey *rsa.PublicKey + + // OwnerURI is the ActivityPub id of the owner of + // the public key used to sign the request we're + // now authenticating. This will always be set + // even if Owner isn't, so that callers can use + // this URI to go fetch the Owner from remote. + OwnerURI *url.URL + + // Owner is the account corresponding to OwnerURI. + // + // Owner will only be defined if the account who + // owns the public key was already cached in the + // database when we received the request we're now + // authenticating (ie., we've seen it before). + // + // If it's not defined, callers should use OwnerURI + // to go and dereference it. + Owner *gtsmodel.Account +} + // 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 requesting http signature. +// new Activities. The function returns details of the public key(s) used to +// authenticate the requesting http signature. // // 'Authenticate' in this case is defined as making sure that the http request // is actually signed by whoever claims to have signed it, by fetching the public @@ -70,7 +109,7 @@ var ( // 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(ctx context.Context, requestedUsername string) (*url.URL, gtserror.WithCode) { +func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedUsername string) (*PubKeyAuth, gtserror.WithCode) { // Thanks to the signature check middleware, // we should already have an http signature // verifier set on the context. If we don't, @@ -102,10 +141,10 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU // so now we need to validate the signature. var ( - pubKeyIDStr = pubKeyID.String() - requestingAccountURI *url.URL - pubKey interface{} - errWithCode gtserror.WithCode + pubKeyIDStr = pubKeyID.String() + local = (pubKeyID.Host == config.GetHost()) + pubKeyAuth *PubKeyAuth + errWithCode gtserror.WithCode ) l := log. @@ -115,37 +154,49 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU {"pubKeyID", pubKeyIDStr}, }...) - if pubKeyID.Host == config.GetHost() { - l.Trace("public key is ours, no dereference needed") - requestingAccountURI, pubKey, errWithCode = f.derefDBOnly(ctx, pubKeyIDStr) + if local { + l.Trace("public key is local, no dereference needed") + pubKeyAuth, errWithCode = f.derefPubKeyDBOnly(ctx, pubKeyIDStr) } else { - l.Trace("public key is not ours, checking if we need to dereference") - requestingAccountURI, pubKey, errWithCode = f.deref(ctx, requestedUsername, pubKeyIDStr, pubKeyID) + l.Trace("public key is remote, checking if we need to dereference") + pubKeyAuth, errWithCode = f.derefPubKey(ctx, requestedUsername, pubKeyIDStr, pubKeyID) } if errWithCode != nil { return nil, errWithCode } - // Ensure public key now defined. - if pubKey == nil { - err := gtserror.New("public key was nil") + if local && pubKeyAuth == nil { + // We signed this request, apparently, but + // local lookup didn't find anything. This + // is an almost impossible error condition! + err := gtserror.Newf("local public key %s could not be found; "+ + "has the account been manually removed from the db?", pubKeyIDStr) return nil, gtserror.NewErrorInternalError(err) } // Try to authenticate using permitted algorithms in - // order of most -> least common. Return OK as soon - // as one passes. - for _, algo := range signingAlgorithms { - l.Tracef("trying %s", algo) - - err := verifier.Verify(pubKey, algo) - if err == nil { - l.Tracef("authentication PASSED with %s", algo) - return requestingAccountURI, nil + // order of most -> least common, checking each defined + // pubKey for this Actor. Return OK as soon as one passes. + for _, pubKey := range [2]*rsa.PublicKey{ + pubKeyAuth.FetchedPubKey, + pubKeyAuth.CachedPubKey, + } { + if pubKey == nil { + continue } - l.Tracef("authentication NOT PASSED with %s: %q", algo, err) + for _, algo := range signingAlgorithms { + l.Tracef("trying %s", algo) + + err := verifier.Verify(pubKey, algo) + if err == nil { + l.Tracef("authentication PASSED with %s", algo) + return pubKeyAuth, nil + } + + l.Tracef("authentication NOT PASSED with %s: %q", algo, err) + } } // At this point no algorithms passed. @@ -157,36 +208,52 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU return nil, gtserror.NewErrorUnauthorized(err, err.Error()) } -// derefDBOnly tries to dereference the given public -// key using only entries already in the database. -func (f *federator) derefDBOnly( +// derefPubKeyDBOnly tries to dereference the given +// pubKey using only entries already in the database. +// +// In case of a db or URL error, will return the error. +// +// In case an entry for the pubKey owner just doesn't +// exist in the db (yet), will return nil, nil. +func (f *federator) derefPubKeyDBOnly( ctx context.Context, pubKeyIDStr string, -) (*url.URL, interface{}, gtserror.WithCode) { - reqAcct, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr) +) (*PubKeyAuth, gtserror.WithCode) { + owner, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr) if err != nil { + if errors.Is(err, db.ErrNoEntries) { + // We don't have this + // account stored (yet). + return nil, nil + } + err = gtserror.Newf("db error getting account with pubKeyID %s: %w", pubKeyIDStr, err) - return nil, nil, gtserror.NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } - reqAcctURI, err := url.Parse(reqAcct.URI) + ownerURI, err := url.Parse(owner.URI) if err != nil { err = gtserror.Newf("error parsing account uri with pubKeyID %s: %w", pubKeyIDStr, err) - return nil, nil, gtserror.NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } - return reqAcctURI, reqAcct.PublicKey, nil + return &PubKeyAuth{ + CachedPubKey: owner.PublicKey, + OwnerURI: ownerURI, + Owner: owner, + }, nil } -// deref tries to dereference the given public key by first -// checking in the database, and then (if no entries found) -// calling the remote pub key URI and extracting the key. -func (f *federator) deref( +// derefPubKey tries to dereference the given public key by first +// checking in the database, and then (if no entry found, or entry +// found but pubKey expired) calling the remote pub key URI and +// extracting the key. +func (f *federator) derefPubKey( ctx context.Context, requestedUsername string, pubKeyIDStr string, pubKeyID *url.URL, -) (*url.URL, interface{}, gtserror.WithCode) { +) (*PubKeyAuth, gtserror.WithCode) { l := log. WithContext(ctx). WithFields(kv.Fields{ @@ -196,42 +263,101 @@ func (f *federator) deref( // Try a database only deref first. We may already // have the requesting account cached locally. - reqAcctURI, pubKey, errWithCode := f.derefDBOnly(ctx, pubKeyIDStr) - if errWithCode == nil { - l.Trace("public key cached, no dereference needed") - return reqAcctURI, pubKey, nil + pubKeyAuth, errWithCode := f.derefPubKeyDBOnly(ctx, pubKeyIDStr) + if errWithCode != nil { + return nil, errWithCode } - l.Trace("public key not cached, trying dereference") + var ( + // Just haven't seen this + // Actor + their pubkey yet. + uncached = (pubKeyAuth == nil) + + // Have seen this Actor + their + // pubkey but latter is now expired. + expired = (!uncached && pubKeyAuth.Owner.PubKeyExpired()) + ) + + switch { + case uncached: + l.Trace("public key was not cached, trying dereference of public key") + case !expired: + l.Trace("public key cached and up to date, no dereference needed") + return pubKeyAuth, nil + case expired: + // This is fairly rare and it may be helpful for + // admins to see what's going on, so log at info. + l.Infof( + "public key was cached, but expired at %s, trying dereference of new public key", + pubKeyAuth.Owner.PublicKeyExpiresAt, + ) + } // If we've tried to get this account before and we // now have a tombstone for it (ie., it's been deleted // from remote), don't try to dereference it again. gone, err := f.CheckGone(ctx, pubKeyID) if err != nil { - err := gtserror.Newf("error checking for tombstone for %s: %w", pubKeyIDStr, err) - return nil, nil, gtserror.NewErrorInternalError(err) + err := gtserror.Newf("error checking for tombstone (%s): %w", pubKeyIDStr, err) + return nil, gtserror.NewErrorInternalError(err) } if gone { - err := gtserror.Newf("account with public key %s is gone", pubKeyIDStr) - return nil, nil, gtserror.NewErrorGone(err) + err := gtserror.Newf("account with public key is gone (%s)", pubKeyIDStr) + return nil, gtserror.NewErrorGone(err) } - // Make an http call to get the pubkey. + // Make an http call to get the (refreshed) pubkey. pubKeyBytes, errWithCode := f.callForPubKey(ctx, requestedUsername, pubKeyID) if errWithCode != nil { - return nil, nil, errWithCode + return nil, errWithCode } // Extract the key and the owner from the response. pubKey, pubKeyOwner, err := parsePubKeyBytes(ctx, pubKeyBytes, pubKeyID) if err != nil { - err := fmt.Errorf("error parsing public key %s: %w", pubKeyID, err) - return nil, nil, gtserror.NewErrorUnauthorized(err) + err := fmt.Errorf("error parsing public key (%s): %w", pubKeyID, err) + return nil, gtserror.NewErrorUnauthorized(err) + } + + if !expired { + // PubKeyResponse was nil before because + // we had nothing cached; return the key + // we just fetched, and nothing else. + return &PubKeyAuth{ + FetchedPubKey: pubKey, + OwnerURI: pubKeyOwner, + }, nil + } + + // Add newly-fetched key to response. + pubKeyAuth.FetchedPubKey = pubKey + + // If key was expired, that means we already + // had an owner stored for it locally. Since + // we now successfully refreshed the pub key, + // we should update the account to reflect that. + ownerAcct := pubKeyAuth.Owner + ownerAcct.PublicKey = pubKeyAuth.FetchedPubKey + ownerAcct.PublicKeyExpiresAt = time.Time{} + + l.Info("obtained a new public key to replace expired key, caching now; " + + "authorization for this request will be attempted with both old and new keys") + + if err := f.db.UpdateAccount( + ctx, + ownerAcct, + "public_key", + "public_key_expires_at", + ); err != nil { + err := gtserror.Newf("db error updating account with refreshed public key (%s): %w", pubKeyIDStr, err) + return nil, gtserror.NewErrorInternalError(err) } - return pubKeyOwner, pubKey, nil + // Return both new and cached (now + // expired) keys, authentication + // will be attempted with both. + return pubKeyAuth, nil } // callForPubKey handles the nitty gritty of actually diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index ef42639ed..fb4e5bfb9 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -209,7 +209,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } // Check who's trying to deliver to us by inspecting the http signature. - pubKeyOwner, errWithCode := f.AuthenticateFederatedRequest(ctx, receivingAccount.Username) + pubKeyAuth, errWithCode := f.AuthenticateFederatedRequest(ctx, receivingAccount.Username) if errWithCode != nil { switch errWithCode.Code() { case http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: @@ -232,12 +232,14 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } } + pubKeyOwnerURI := pubKeyAuth.OwnerURI + // Authentication has passed, check if we need to create a // new instance entry for the Host of the requesting account. - if _, err := f.db.GetInstance(ctx, pubKeyOwner.Host); err != nil { + if _, err := f.db.GetInstance(ctx, pubKeyOwnerURI.Host); err != nil { if !errors.Is(err, db.ErrNoEntries) { // There's been an actual error. - err = gtserror.Newf("error getting instance %s: %w", pubKeyOwner.Host, err) + err = gtserror.Newf("error getting instance %s: %w", pubKeyOwnerURI.Host, err) return ctx, false, err } @@ -247,17 +249,17 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr gtscontext.SetFastFail(ctx), username, &url.URL{ - Scheme: pubKeyOwner.Scheme, - Host: pubKeyOwner.Host, + Scheme: pubKeyOwnerURI.Scheme, + Host: pubKeyOwnerURI.Host, }, ) if err != nil { - err = gtserror.Newf("error dereferencing instance %s: %w", pubKeyOwner.Host, err) + err = gtserror.Newf("error dereferencing instance %s: %w", pubKeyOwnerURI.Host, err) return nil, false, err } if err := f.db.PutInstance(ctx, instance); err != nil && !errors.Is(err, db.ErrAlreadyExists) { - err = gtserror.Newf("error inserting instance entry for %s: %w", pubKeyOwner.Host, err) + err = gtserror.Newf("error inserting instance entry for %s: %w", pubKeyOwnerURI.Host, err) return nil, false, err } } @@ -268,7 +270,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr requestingAccount, _, err := f.GetAccountByURI( gtscontext.SetFastFail(ctx), username, - pubKeyOwner, + pubKeyOwnerURI, ) if err != nil { if gtserror.StatusCode(err) == http.StatusGone { @@ -282,7 +284,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr return ctx, false, nil } - err = gtserror.Newf("couldn't get requesting account %s: %w", pubKeyOwner, err) + err = gtserror.Newf("couldn't get requesting account %s: %w", pubKeyOwnerURI, err) return nil, false, err } diff --git a/internal/federation/federatingprotocol_test.go b/internal/federation/federatingprotocol_test.go index 8da6859dd..7a8343048 100644 --- a/internal/federation/federatingprotocol_test.go +++ b/internal/federation/federatingprotocol_test.go @@ -257,6 +257,33 @@ func (suite *FederatingProtocolTestSuite) TestAuthenticatePostInbox() { suite.Equal(http.StatusOK, code) } +func (suite *FederatingProtocolTestSuite) TestAuthenticatePostInboxKeyExpired() { + var ( + ctx = context.Background() + activity = suite.testActivities["dm_for_zork"] + receivingAccount = suite.testAccounts["local_account_1"] + ) + + // Update remote account to mark key as expired. + remoteAcct := >smodel.Account{} + *remoteAcct = *suite.testAccounts["remote_account_1"] + remoteAcct.PublicKeyExpiresAt = testrig.TimeMustParse("2022-06-10T15:22:08Z") + if err := suite.state.DB.UpdateAccount(ctx, remoteAcct, "public_key_expires_at"); err != nil { + suite.FailNow(err.Error()) + } + + ctx, authed, resp, code := suite.authenticatePostInbox( + ctx, + receivingAccount, + activity, + ) + + suite.NotNil(gtscontext.RequestingAccount(ctx)) + suite.True(authed) + suite.Equal([]byte{}, resp) + suite.Equal(http.StatusOK, code) +} + func (suite *FederatingProtocolTestSuite) TestAuthenticatePostGoneWithTombstone() { var ( activity = suite.testActivities["delete_https://somewhere.mysterious/users/rest_in_piss#main-key"] diff --git a/internal/federation/federator.go b/internal/federation/federator.go index 40af08d25..ad6db8ff7 100644 --- a/internal/federation/federator.go +++ b/internal/federation/federator.go @@ -19,7 +19,6 @@ package federation import ( "context" - "net/url" "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -49,7 +48,7 @@ type Federator interface { // If the request does not pass authentication, or there's a domain block, nil, false, nil will be returned. // // If something goes wrong during authentication, nil, false, and an error will be returned. - AuthenticateFederatedRequest(ctx context.Context, username string) (*url.URL, gtserror.WithCode) + AuthenticateFederatedRequest(ctx context.Context, username string) (*PubKeyAuth, gtserror.WithCode) pub.CommonBehavior pub.FederatingProtocol diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go index a80d590a4..3287cd11a 100644 --- a/internal/federation/federator_test.go +++ b/internal/federation/federator_test.go @@ -18,6 +18,8 @@ package federation_test import ( + "context" + "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/federation" @@ -71,7 +73,14 @@ func (suite *FederatorStandardTestSuite) SetupTest() { suite.typeconverter, ) - suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media") + // Ensure it's possible to deref + // main key of foss satan. + fossSatanPerson, err := suite.typeconverter.AccountToAS(context.Background(), suite.testAccounts["remote_account_1"]) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media", fossSatanPerson) suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople() suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses() diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 7b27f076a..578d4c811 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -72,9 +72,10 @@ type Account struct { FollowersURI string `bun:",nullzero,unique"` // URI for getting the followers list of this account FeaturedCollectionURI string `bun:",nullzero,unique"` // URL for getting the featured collection list of this account ActorType string `bun:",nullzero,notnull"` // What type of activitypub actor is this account? - PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for validating activitypub requests, will only be defined for local accounts - PublicKey *rsa.PublicKey `bun:",notnull"` // Publickey for encoding activitypub requests, will be defined for both local and remote accounts + PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for signing activitypub requests, will only be defined for local accounts + PublicKey *rsa.PublicKey `bun:",notnull"` // Publickey for authorizing signed activitypub requests, will be defined for both local and remote accounts PublicKeyURI string `bun:",nullzero,notnull,unique"` // Web-reachable location of this account's public key + PublicKeyExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // PublicKey will expire/has expired at given time, and should be fetched again as appropriate. Only ever set for remote accounts. SensitizedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account set to have all its media shown as sensitive? SilencedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account silenced (eg., statuses only visible to followers, not public)? SuspendedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) @@ -129,6 +130,17 @@ func (a *Account) EmojisPopulated() bool { return true } +// PubKeyExpired returns true if the account's public key +// has been marked as expired, and the expiry time has passed. +func (a *Account) PubKeyExpired() bool { + if a == nil { + return false + } + + return !a.PublicKeyExpiresAt.IsZero() && + a.PublicKeyExpiresAt.Before(time.Now()) +} + // AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis. type AccountToEmoji struct { AccountID string `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` diff --git a/internal/gtsmodel/adminaction.go b/internal/gtsmodel/adminaction.go index c6c598b32..1e55a33f9 100644 --- a/internal/gtsmodel/adminaction.go +++ b/internal/gtsmodel/adminaction.go @@ -72,6 +72,7 @@ const ( AdminActionUnsilence AdminActionSuspend AdminActionUnsuspend + AdminActionExpireKeys ) func (t AdminActionType) String() string { @@ -88,6 +89,8 @@ func (t AdminActionType) String() string { return "suspend" case AdminActionUnsuspend: return "unsuspend" + case AdminActionExpireKeys: + return "expire-keys" default: return "unknown" } @@ -107,6 +110,8 @@ func NewAdminActionType(in string) AdminActionType { return AdminActionSuspend case "unsuspend": return AdminActionUnsuspend + case "expire-keys": + return AdminActionExpireKeys default: return AdminActionUnknown } diff --git a/internal/processing/admin/domainkeysexpire.go b/internal/processing/admin/domainkeysexpire.go new file mode 100644 index 000000000..886da8b2f --- /dev/null +++ b/internal/processing/admin/domainkeysexpire.go @@ -0,0 +1,87 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 admin + +import ( + "context" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +// DomainKeysExpire iterates through all +// accounts belonging to the given domain, +// and expires the public key of each +// account found this way. +// +// The PublicKey for each account will be +// re-fetched next time a signed request +// from that account is received. +func (p *Processor) DomainKeysExpire( + ctx context.Context, + adminAcct *gtsmodel.Account, + domain string, +) (string, gtserror.WithCode) { + actionID := id.NewULID() + + // Process key expiration asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domain, + Type: gtsmodel.AdminActionExpireKeys, + AccountID: adminAcct.ID, + }, + func(ctx context.Context) gtserror.MultiError { + return p.domainKeysExpireSideEffects(ctx, domain) + }, + ); errWithCode != nil { + return actionID, errWithCode + } + + return actionID, nil +} + +func (p *Processor) domainKeysExpireSideEffects(ctx context.Context, domain string) gtserror.MultiError { + var ( + expiresAt = time.Now() + errs gtserror.MultiError + ) + + // For each account on this domain, expire + // the public key and update the account. + if err := p.rangeDomainAccounts(ctx, domain, func(account *gtsmodel.Account) { + account.PublicKeyExpiresAt = expiresAt + + if err := p.state.DB.UpdateAccount( + ctx, + account, + "public_key_expires_at", + ); err != nil { + errs.Appendf("db error updating account: %w", err) + } + }); err != nil { + errs.Appendf("db error ranging through accounts: %w", err) + } + + return errs +} diff --git a/internal/processing/fedi/common.go b/internal/processing/fedi/common.go index 1331a20e0..38c31ffd2 100644 --- a/internal/processing/fedi/common.go +++ b/internal/processing/fedi/common.go @@ -48,7 +48,7 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string) // Ensure request signed, and use signature URI to // get requesting account, dereferencing if necessary. - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) if errWithCode != nil { return nil, nil, errWithCode } @@ -56,10 +56,10 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string) requestingAccount, _, err := p.federator.GetAccountByURI( gtscontext.SetFastFail(ctx), requestedUsername, - requestingAccountURI, + pubKeyAuth.OwnerURI, ) if err != nil { - err = gtserror.Newf("error getting account %s: %w", requestingAccountURI, err) + err = gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err) return nil, nil, gtserror.NewErrorUnauthorized(err) } diff --git a/internal/processing/fedi/user.go b/internal/processing/fedi/user.go index 4a55df01f..f3305c103 100644 --- a/internal/processing/fedi/user.go +++ b/internal/processing/fedi/user.go @@ -66,7 +66,7 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque // If the request is not on a public key path, we want to // try to authenticate it before we serve any data, so that // we can serve a more complete profile. - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) if errWithCode != nil { return nil, errWithCode // likely 401 } @@ -89,7 +89,7 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque // Instead, we end up in an 'I'll show you mine if you show me // yours' situation, where we sort of agree to reveal each // other's profiles at the same time. - if p.federator.Handshaking(requestedUsername, requestingAccountURI) { + if p.federator.Handshaking(requestedUsername, pubKeyAuth.OwnerURI) { return data(person) } @@ -98,10 +98,11 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque requestingAccount, _, err := p.federator.GetAccountByURI( // On a hot path so fail quickly. gtscontext.SetFastFail(ctx), - requestedUsername, requestingAccountURI, + requestedUsername, + pubKeyAuth.OwnerURI, ) if err != nil { - err := gtserror.Newf("error getting account %s: %w", requestingAccountURI, err) + err := gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err) return nil, gtserror.NewErrorUnauthorized(err) } |