[feature] Allow admins to expire remote public keys; refetch expired keys on demand (#2183)

This commit is contained in:
tobi 2023-09-12 11:43:12 +02:00 committed by GitHub
commit 4b594516ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 841 additions and 117 deletions

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

@ -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)
}
return pubKeyOwner, pubKey, nil
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 both new and cached (now
// expired) keys, authentication
// will be attempted with both.
return pubKeyAuth, nil
}
// callForPubKey handles the nitty gritty of actually

View file

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

View file

@ -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 := &gtsmodel.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"]

View file

@ -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

View file

@ -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()

View file

@ -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"`

View file

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

View file

@ -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,
&gtsmodel.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
}

View file

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

View file

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