feat(activitiypub): enable HTTP signatures on all ActivityPub endpoints (#7035)

- Set the right keyID and use the right signing keys for outgoing requests.
- Verify the HTTP signature of all incoming requests, except for the server actor.
- Caches keys of incoming requests for users and servers actors.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7035
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: famfo <famfo@famfo.xyz>
Co-committed-by: famfo <famfo@famfo.xyz>
This commit is contained in:
famfo 2025-04-03 15:24:15 +00:00 committed by Gusted
parent ba5b157f7e
commit 77b0275572
22 changed files with 681 additions and 122 deletions

View file

@ -4,6 +4,7 @@
package forgefed
import (
"database/sql"
"fmt"
"strings"
"time"
@ -15,12 +16,14 @@ import (
// FederationHost data type
// swagger:model
type FederationHost struct {
ID int64 `xorm:"pk autoincr"`
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
LatestActivity time.Time `xorm:"NOT NULL"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
ID int64 `xorm:"pk autoincr"`
HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
LatestActivity time.Time `xorm:"NOT NULL"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
KeyID sql.NullString `xorm:"key_id UNIQUE"`
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
}
// Factory function for FederationHost. Created struct is asserted to be valid.

View file

@ -30,9 +30,9 @@ func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) {
return host, nil
}
func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) {
func findFederationHostFromDB(ctx context.Context, searchKey, searchValue string) (*FederationHost, error) {
host := new(FederationHost)
has, err := db.GetEngine(ctx).Where("host_fqdn=?", strings.ToLower(fqdn)).Get(host)
has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(host)
if err != nil {
return nil, err
} else if !has {
@ -44,6 +44,14 @@ func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost
return host, nil
}
func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) {
return findFederationHostFromDB(ctx, "host_fqdn=?", strings.ToLower(fqdn))
}
func FindFederationHostByKeyID(ctx context.Context, keyID string) (*FederationHost, error) {
return findFederationHostFromDB(ctx, "key_id=?", keyID)
}
func CreateFederationHost(ctx context.Context, host *FederationHost) error {
if res, err := validation.IsValid(host); !res {
return err

View file

@ -94,6 +94,8 @@ var migrations = []*Migration{
NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect),
// v27 -> v28
NewMigration("Add pronoun privacy settings to user", AddHidePronounsOptionToUser),
// v28 -> v29
NewMigration("Add public key information to `FederatedUser` and `FederationHost`", AddPublicKeyInformationForFederation),
}
// GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,29 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgejo_migrations //nolint:revive
import (
"database/sql"
"xorm.io/xorm"
)
func AddPublicKeyInformationForFederation(x *xorm.Engine) error {
type FederationHost struct {
KeyID sql.NullString `xorm:"key_id UNIQUE"`
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
}
err := x.Sync(&FederationHost{})
if err != nil {
return err
}
type FederatedUser struct {
KeyID sql.NullString `xorm:"key_id UNIQUE"`
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
}
return x.Sync(&FederatedUser{})
}

View file

@ -0,0 +1,44 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"fmt"
"net/url"
"forgejo.org/models/db"
"forgejo.org/modules/setting"
"forgejo.org/modules/validation"
)
// APActorID returns the IRI to the api endpoint of the user
func (u *User) APActorID() string {
if u.IsAPServerActor() {
return fmt.Sprintf("%sapi/v1/activitypub/actor", setting.AppURL)
}
return fmt.Sprintf("%sapi/v1/activitypub/user-id/%s", setting.AppURL, url.PathEscape(fmt.Sprintf("%d", u.ID)))
}
// APActorKeyID returns the ID of the user's public key
func (u *User) APActorKeyID() string {
return u.APActorID() + "#main-key"
}
func GetUserByFederatedURI(ctx context.Context, federatedURI string) (*User, error) {
user := new(User)
has, err := db.GetEngine(ctx).Where("normalized_federated_uri=?", federatedURI).Get(user)
if err != nil {
return nil, err
} else if !has {
return nil, nil
}
if res, err := validation.IsValid(*user); !res {
return nil, err
}
return user, nil
}

View file

@ -4,14 +4,20 @@
package user
import (
"context"
"database/sql"
"forgejo.org/models/db"
"forgejo.org/modules/validation"
)
type FederatedUser struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL"`
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL"`
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
KeyID sql.NullString `xorm:"key_id UNIQUE"`
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
}
func NewFederatedUser(userID int64, externalID string, federationHostID int64) (FederatedUser, error) {
@ -26,6 +32,30 @@ func NewFederatedUser(userID int64, externalID string, federationHostID int64) (
return result, nil
}
func getFederatedUserFromDB(ctx context.Context, searchKey, searchValue any) (*FederatedUser, error) {
federatedUser := new(FederatedUser)
has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(federatedUser)
if err != nil {
return nil, err
} else if !has {
return nil, nil
}
if res, err := validation.IsValid(*federatedUser); !res {
return nil, err
}
return federatedUser, nil
}
func GetFederatedUserByKeyID(ctx context.Context, keyID string) (*FederatedUser, error) {
return getFederatedUserFromDB(ctx, "key_id=?", keyID)
}
func GetFederatedUserByUserID(ctx context.Context, userID int64) (*FederatedUser, error) {
return getFederatedUserFromDB(ctx, "user_id=?", userID)
}
func (user FederatedUser) Validate() []string {
var result []string
result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...)

View file

@ -311,11 +311,6 @@ func (u *User) HTMLURL() string {
return setting.AppURL + url.PathEscape(u.Name)
}
// APActorID returns the IRI to the api endpoint of the user
func (u *User) APActorID() string {
return fmt.Sprintf("%vapi/v1/activitypub/user-id/%v", setting.AppURL, url.PathEscape(fmt.Sprintf("%v", u.ID)))
}
// OrganisationLink returns the organization sub page link.
func (u *User) OrganisationLink() string {
return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)

View file

@ -73,30 +73,30 @@ func (u *User) IsActions() bool {
}
const (
APActorUserID = -3
APActorUserName = "actor"
APActorEmail = "noreply@forgejo.org"
APServerActorUserID = -3
APServerActorUserName = "actor"
APServerActorEmail = "noreply@forgejo.org"
)
func NewAPActorUser() *User {
func NewAPServerActor() *User {
return &User{
ID: APActorUserID,
Name: APActorUserName,
LowerName: APActorUserName,
ID: APServerActorUserID,
Name: APServerActorUserName,
LowerName: APServerActorUserName,
IsActive: true,
Email: APActorEmail,
Email: APServerActorEmail,
KeepEmailPrivate: true,
LoginName: APActorUserName,
LoginName: APServerActorUserName,
Type: UserTypeIndividual,
Visibility: structs.VisibleTypePublic,
}
}
func APActorUserAPActorID() string {
func APServerActorID() string {
path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor")
return path
}
func (u *User) IsAPActor() bool {
return u != nil && u.ID == APActorUserID
func (u *User) IsAPServerActor() bool {
return u != nil && u.ID == APServerActorUserID
}

View file

@ -139,9 +139,21 @@ func TestAPActorID(t *testing.T) {
user := user_model.User{ID: 1}
url := user.APActorID()
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1"
if url != expected {
t.Errorf("unexpected APActorID, expected: %q, actual: %q", expected, url)
}
assert.Equal(t, expected, url)
}
func TestAPActorID_APActorID(t *testing.T) {
user := user_model.User{ID: user_model.APServerActorUserID}
url := user.APActorID()
expected := "https://try.gitea.io/api/v1/activitypub/actor"
assert.Equal(t, expected, url)
}
func TestAPActorKeyID(t *testing.T) {
user := user_model.User{ID: 1}
url := user.APActorKeyID()
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key"
assert.Equal(t, expected, url)
}
func TestSearchUsers(t *testing.T) {