diff --git a/models/forgefed/federationhost.go b/models/forgefed/federationhost.go index 00f13ea399..7db49e58e8 100644 --- a/models/forgefed/federationhost.go +++ b/models/forgefed/federationhost.go @@ -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. diff --git a/models/forgefed/federationhost_repository.go b/models/forgefed/federationhost_repository.go index b04a5cd882..fa1f906824 100644 --- a/models/forgefed/federationhost_repository.go +++ b/models/forgefed/federationhost_repository.go @@ -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 diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index a4cbca70c1..ef446add4e 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -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. diff --git a/models/forgejo_migrations/v29.go b/models/forgejo_migrations/v29.go new file mode 100644 index 0000000000..d0c2f723ae --- /dev/null +++ b/models/forgejo_migrations/v29.go @@ -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{}) +} diff --git a/models/user/activitypub.go b/models/user/activitypub.go new file mode 100644 index 0000000000..490615239c --- /dev/null +++ b/models/user/activitypub.go @@ -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 +} diff --git a/models/user/federated_user.go b/models/user/federated_user.go index fc07836408..d32f42867d 100644 --- a/models/user/federated_user.go +++ b/models/user/federated_user.go @@ -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")...) diff --git a/models/user/user.go b/models/user/user.go index c77e8aeac2..900878768e 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -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) diff --git a/models/user/user_system.go b/models/user/user_system.go index f1585b512a..82805cc8ee 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -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 } diff --git a/models/user/user_test.go b/models/user/user_test.go index 695d3dc5c6..52ebe2e204 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -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) { diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go index d43e9c2bb0..91c767c589 100644 --- a/modules/activitypub/client.go +++ b/modules/activitypub/client.go @@ -191,10 +191,17 @@ func (c *Client) GetBody(uri string) ([]byte, error) { return nil, err } defer response.Body.Close() - body, err := io.ReadAll(response.Body) + if response.ContentLength > setting.Federation.MaxSize { + return nil, fmt.Errorf("Request returned %d bytes (max allowed incomming size: %d bytes)", response.ContentLength, setting.Federation.MaxSize) + } else if response.ContentLength == -1 { + log.Warn("Request to %v returned an unknown content length, response may be truncated to %d bytes", uri, setting.Federation.MaxSize) + } + + body, err := io.ReadAll(io.LimitReader(response.Body, setting.Federation.MaxSize)) if err != nil { return nil, err } + log.Debug("Client: got body: %v", charLimiter(string(body), 120)) return body, nil } diff --git a/modules/setting/federation.go b/modules/setting/federation.go index a0fdec228e..510ac128ee 100644 --- a/modules/setting/federation.go +++ b/modules/setting/federation.go @@ -15,18 +15,20 @@ var ( Enabled bool ShareUserStatistics bool MaxSize int64 - Algorithms []string + SignatureAlgorithms []string DigestAlgorithm string GetHeaders []string PostHeaders []string + SignatureEnforced bool }{ Enabled: false, ShareUserStatistics: true, MaxSize: 4, - Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, + SignatureAlgorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, DigestAlgorithm: "SHA-256", GetHeaders: []string{"(request-target)", "Date", "Host"}, PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"}, + SignatureEnforced: true, } ) @@ -44,8 +46,8 @@ func loadFederationFrom(rootCfg ConfigProvider) { // Get MaxSize in bytes instead of MiB Federation.MaxSize = 1 << 20 * Federation.MaxSize - HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms)) - for i, alg := range Federation.Algorithms { + HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.SignatureAlgorithms)) + for i, alg := range Federation.SignatureAlgorithms { HttpsigAlgs[i] = httpsig.Algorithm(alg) } } diff --git a/modules/test/distant_federation_server_mock.go b/modules/test/distant_federation_server_mock.go index fd68c88a40..9bd908e2b9 100644 --- a/modules/test/distant_federation_server_mock.go +++ b/modules/test/distant_federation_server_mock.go @@ -95,7 +95,7 @@ func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server { }) } for _, repository := range mock.Repositories { - federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox/", repository.ID), + federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repository.ID), func(res http.ResponseWriter, req *http.Request) { if req.Method != "POST" { t.Errorf("POST expected at: %q", req.URL.EscapedPath()) diff --git a/routers/api/v1/activitypub/actor.go b/routers/api/v1/activitypub/actor.go index 7568a2a7c8..e49f277842 100644 --- a/routers/api/v1/activitypub/actor.go +++ b/routers/api/v1/activitypub/actor.go @@ -28,7 +28,7 @@ func Actor(ctx *context.APIContext) { // "200": // "$ref": "#/responses/ActivityPub" - link := user_model.APActorUserAPActorID() + link := user_model.APServerActorID() actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) actor.PreferredUsername = ap.NaturalLanguageValuesNew() @@ -46,7 +46,7 @@ func Actor(ctx *context.APIContext) { actor.PublicKey.ID = ap.IRI(link + "#main-key") actor.PublicKey.Owner = ap.IRI(link) - publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPActorUser()) + publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPServerActor()) if err != nil { ctx.ServerError("GetPublicKey", err) return diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go index a9bb4bd868..5872d951cf 100644 --- a/routers/api/v1/activitypub/reqsignature.go +++ b/routers/api/v1/activitypub/reqsignature.go @@ -6,59 +6,141 @@ package activitypub import ( "crypto" "crypto/x509" + "database/sql" "encoding/pem" "fmt" - "io" "net/http" "net/url" + "forgejo.org/models/db" + "forgejo.org/models/forgefed" + "forgejo.org/models/user" "forgejo.org/modules/activitypub" - "forgejo.org/modules/httplib" + fm "forgejo.org/modules/forgefed" "forgejo.org/modules/log" "forgejo.org/modules/setting" gitea_context "forgejo.org/services/context" + "forgejo.org/services/federation" "github.com/42wim/httpsig" ap "github.com/go-ap/activitypub" ) -func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) { - person := ap.PersonNew(ap.IRI(keyID.String())) - err = person.UnmarshalJSON(b) - if err != nil { - return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err) - } - pubKey := person.PublicKey - if pubKey.ID.String() != keyID.String() { - return nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b)) - } - pubKeyPem := pubKey.PublicKeyPem +func decodePublicKeyPem(pubKeyPem string) ([]byte, error) { block, _ := pem.Decode([]byte(pubKeyPem)) if block == nil || block.Type != "PUBLIC KEY" { return nil, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type") } - p, err = x509.ParsePKIXPublicKey(block.Bytes) - return p, err + + return block.Bytes, nil } -func fetch(iri *url.URL) (b []byte, err error) { - req := httplib.NewRequest(iri.String(), http.MethodGet) - req.Header("Accept", activitypub.ActivityStreamsContentType) - req.Header("User-Agent", "Gitea/"+setting.AppVer) - resp, err := req.Response() +func getFederatedUser(ctx *gitea_context.APIContext, person *ap.Person, federationHost *forgefed.FederationHost) (*user.FederatedUser, error) { + dbUser, err := user.GetUserByFederatedURI(ctx, person.ID.String()) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status) + if dbUser != nil { + federatedUser, err := user.GetFederatedUserByUserID(ctx, dbUser.ID) + if err != nil { + return nil, err + } + + if federatedUser != nil { + return federatedUser, nil + } } - b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize)) - return b, err + + personID, err := fm.NewPersonID(person.ID.String(), string(federationHost.NodeInfo.SoftwareName)) + if err != nil { + return nil, err + } + + _, federatedUser, err := federation.CreateUserFromAP(ctx, personID, federationHost.ID) + if err != nil { + return nil, err + } + + return federatedUser, nil +} + +func storePublicKey(ctx *gitea_context.APIContext, person *ap.Person, pubKeyBytes []byte) error { + federationHost, err := federation.GetFederationHostForURI(ctx, person.ID.String()) + if err != nil { + return err + } + + if person.Type == ap.ActivityVocabularyType("Application") { + federationHost.KeyID = sql.NullString{ + String: person.PublicKey.ID.String(), + Valid: true, + } + + federationHost.PublicKey = sql.Null[sql.RawBytes]{ + V: pubKeyBytes, + Valid: true, + } + + _, err = db.GetEngine(ctx).ID(federationHost.ID).Update(federationHost) + if err != nil { + return err + } + } else if person.Type == ap.ActivityVocabularyType("Person") { + federatedUser, err := getFederatedUser(ctx, person, federationHost) + if err != nil { + return err + } + + federatedUser.KeyID = sql.NullString{ + String: person.PublicKey.ID.String(), + Valid: true, + } + + federatedUser.PublicKey = sql.Null[sql.RawBytes]{ + V: pubKeyBytes, + Valid: true, + } + + _, err = db.GetEngine(ctx).ID(federatedUser.ID).Update(federatedUser) + if err != nil { + return err + } + } + + return nil +} + +func getPublicKeyFromResponse(b []byte, keyID *url.URL) (person *ap.Person, pubKeyBytes []byte, p crypto.PublicKey, err error) { + person = ap.PersonNew(ap.IRI(keyID.String())) + err = person.UnmarshalJSON(b) + if err != nil { + return nil, nil, nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err) + } + + pubKey := person.PublicKey + if pubKey.ID.String() != keyID.String() { + return nil, nil, nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b)) + } + + pubKeyBytes, err = decodePublicKeyPem(pubKey.PublicKeyPem) + if err != nil { + return nil, nil, nil, err + } + + p, err = x509.ParsePKIXPublicKey(pubKeyBytes) + if err != nil { + return nil, nil, nil, err + } + + return person, pubKeyBytes, p, err } func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) { + if !setting.Federation.SignatureEnforced { + return true, nil + } + r := ctx.Req // 1. Figure out what key we need to verify @@ -66,23 +148,78 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er if err != nil { return false, err } + ID := v.KeyId() idIRI, err := url.Parse(ID) if err != nil { return false, err } + + signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0]) + // 2. Fetch the public key of the other actor - b, err := fetch(idIRI) + // Try if the signing actor is an already known federated user + federationUser, err := user.GetFederatedUserByKeyID(ctx, idIRI.String()) if err != nil { return false, err } - pubKey, err := getPublicKeyFromResponse(b, idIRI) + + if federationUser != nil && federationUser.PublicKey.Valid { + pubKey, err := x509.ParsePKIXPublicKey(federationUser.PublicKey.V) + if err != nil { + return false, err + } + + authenticated = v.Verify(pubKey, signatureAlgorithm) == nil + return authenticated, err + } + + // Try if the signing actor is an already known federation host + federationHost, err := forgefed.FindFederationHostByKeyID(ctx, idIRI.String()) if err != nil { return false, err } - // 3. Verify the other actor's key - algo := httpsig.Algorithm(setting.Federation.Algorithms[0]) - authenticated = v.Verify(pubKey, algo) == nil + + if federationHost != nil && federationHost.PublicKey.Valid { + pubKey, err := x509.ParsePKIXPublicKey(federationHost.PublicKey.V) + if err != nil { + return false, err + } + + authenticated = v.Verify(pubKey, signatureAlgorithm) == nil + return authenticated, err + } + + // Fetch missing public key + actionsUser := user.NewAPServerActor() + clientFactory, err := activitypub.GetClientFactory(ctx) + if err != nil { + return false, err + } + + apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) + if err != nil { + return false, err + } + + b, err := apClient.GetBody(idIRI.String()) + if err != nil { + return false, err + } + + person, pubKeyBytes, pubKey, err := getPublicKeyFromResponse(b, idIRI) + if err != nil { + return false, err + } + + authenticated = v.Verify(pubKey, signatureAlgorithm) == nil + if authenticated { + err = storePublicKey(ctx, person, pubKeyBytes) + if err != nil { + return false, err + } + } + return authenticated, err } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 36288b3d73..551fcf7a43 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -840,22 +840,22 @@ func Routes() *web.Route { m.Group("/activitypub", func() { // deprecated, remove in 1.20, use /user-id/{user-id} instead m.Group("/user/{username}", func() { - m.Get("", activitypub.Person) + m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) }, context.UserAssignmentAPI(), checkTokenPublicOnly()) m.Group("/user-id/{user-id}", func() { - m.Get("", activitypub.Person) + m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) }, context.UserIDAssignmentAPI(), checkTokenPublicOnly()) m.Group("/actor", func() { m.Get("", activitypub.Actor) - m.Post("/inbox", activitypub.ActorInbox) + m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.ActorInbox) }) m.Group("/repository-id/{repository-id}", func() { - m.Get("", activitypub.Repository) + m.Get("", activitypub.ReqHTTPSignature(), activitypub.Repository) m.Post("/inbox", bind(forgefed.ForgeLike{}), - // TODO: activitypub.ReqHTTPSignature(), + activitypub.ReqHTTPSignature(), activitypub.RepositoryInbox) }, context.RepositoryIDAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index dc662a654e..2a8f267422 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1313,7 +1313,7 @@ func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *use } // Special user that can't have associated contributions and permissions in the repo. - if poster.IsGhost() || poster.IsActions() || poster.IsAPActor() { + if poster.IsGhost() || poster.IsActions() || poster.IsAPServerActor() { return roleDescriptor, nil } diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index 21c7be855b..b8c471bfbb 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -98,39 +98,47 @@ func ProcessLikeActivity(ctx context.Context, form any, repositoryID int64) (int } func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) { - actionsUser := user.NewActionsUser() + actionsUser := user.NewAPServerActor() clientFactory, err := activitypub.GetClientFactory(ctx) if err != nil { return nil, err } - client, err := clientFactory.WithKeys(ctx, actionsUser, "no idea where to get key material.") + + client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) if err != nil { return nil, err } + body, err := client.GetBody(actorID.AsWellKnownNodeInfoURI()) if err != nil { return nil, err } + nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body) if err != nil { return nil, err } + body, err = client.GetBody(nodeInfoWellKnown.Href) if err != nil { return nil, err } + nodeInfo, err := forgefed.NewNodeInfo(body) if err != nil { return nil, err } + result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host) if err != nil { return nil, err } + err = forgefed.CreateFederationHost(ctx, &result) if err != nil { return nil, err } + return &result, nil } @@ -155,18 +163,18 @@ func GetFederationHostForURI(ctx context.Context, actorURI string) (*forgefed.Fe } func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) { - // ToDo: Do we get a publicKeyId from server, repo or owner or repo? - actionsUser := user.NewActionsUser() + actionsUser := user.NewAPServerActor() clientFactory, err := activitypub.GetClientFactory(ctx) if err != nil { return nil, nil, err } - client, err := clientFactory.WithKeys(ctx, actionsUser, "no idea where to get key material.") + + apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) if err != nil { return nil, nil, err } - body, err := client.GetBody(personID.AsURI()) + body, err := apClient.GetBody(personID.AsURI()) if err != nil { return nil, nil, err } @@ -176,26 +184,32 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI if err != nil { return nil, nil, err } + if res, err := validation.IsValid(person); !res { return nil, nil, err } + log.Info("Fetched valid person:%q", person) localFqdn, err := url.ParseRequestURI(setting.AppURL) if err != nil { return nil, nil, err } + email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname()) loginName := personID.AsLoginName() name := fmt.Sprintf("%v%v", person.PreferredUsername.String(), personID.HostSuffix()) fullName := person.Name.String() + if len(person.Name) == 0 { fullName = name } + password, err := password.Generate(32) if err != nil { return nil, nil, err } + newUser := user.User{ LowerName: strings.ToLower(name), Name: name, @@ -209,16 +223,18 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI IsAdmin: false, NormalizedFederatedURI: personID.AsURI(), } + federatedUser := user.FederatedUser{ ExternalID: personID.ID, FederationHostID: federationHostID, } + err = user.CreateFederatedUser(ctx, &newUser, &federatedUser) if err != nil { return nil, nil, err } - log.Info("Created federatedUser:%q", federatedUser) + log.Info("Created federatedUser:%q", federatedUser) return &newUser, &federatedUser, nil } @@ -274,7 +290,8 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error if err != nil { return err } - apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorID()) + + apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorKeyID()) if err != nil { return err } @@ -285,7 +302,7 @@ func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error return err } - _, err = apclient.Post(json, fmt.Sprintf("%v/inbox/", activity.Object)) + _, err = apclient.Post(json, fmt.Sprintf("%s/inbox", activity.Object)) if err != nil { log.Error("error %v while sending activity: %q", err, activity) } diff --git a/tests/integration/activitypub_client_test.go b/tests/integration/activitypub_client_test.go new file mode 100644 index 0000000000..afafca52ae --- /dev/null +++ b/tests/integration/activitypub_client_test.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/url" + "testing" + + "forgejo.org/models/db" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/routers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubClientBodySize(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) + require.NoError(t, err) + + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) + require.NoError(t, err) + + url := u.JoinPath("/api/v1/nodeinfo").String() + + // Request with normal MaxSize + t.Run("NormalMaxSize", func(t *testing.T) { + resp, err := apClient.GetBody(url) + require.NoError(t, err) + assert.Contains(t, string(resp), "forgejo") + }) + + // Set MaxSize to something very low to always fail + // Request with low MaxSize + t.Run("LowMaxSize", func(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.MaxSize, 100)() + + _, err = apClient.GetBody(url) + require.Error(t, err) + assert.ErrorContains(t, err, "Request returned") + }) + }) +} diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go index d8492942e5..fdbf3fac46 100644 --- a/tests/integration/api_activitypub_person_test.go +++ b/tests/integration/api_activitypub_person_test.go @@ -26,33 +26,47 @@ import ( func TestActivityPubPerson(t *testing.T) { defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() - defer tests.PrepareTestEnv(t)() + onGiteaRun(t, func(t *testing.T, u *url.URL) { + userID := 2 + username := "user2" + userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID) - userID := 2 - username := "user2" - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/user-id/%v", userID)) - resp := MakeRequest(t, req, http.StatusOK) - assert.Contains(t, resp.Body.String(), "@context") + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - var person ap.Person - err := person.UnmarshalJSON(resp.Body.Bytes()) - require.NoError(t, err) + clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) + require.NoError(t, err) - assert.Equal(t, ap.PersonType, person.Type) - assert.Equal(t, username, person.PreferredUsername.String()) - keyID := person.GetID().String() - assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v$", userID), keyID) - assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/outbox$", userID), person.Outbox.GetID().String()) - assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/inbox$", userID), person.Inbox.GetID().String()) + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) + require.NoError(t, err) - pubKey := person.PublicKey - assert.NotNil(t, pubKey) - publicKeyID := keyID + "#main-key" - assert.Equal(t, pubKey.ID.String(), publicKeyID) + // Unsigned request + t.Run("UnsignedRequest", func(t *testing.T) { + req := NewRequest(t, "GET", userURL) + MakeRequest(t, req, http.StatusBadRequest) + }) - pubKeyPem := pubKey.PublicKeyPem - assert.NotNil(t, pubKeyPem) - assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) + t.Run("SignedRequestValidation", func(t *testing.T) { + // Signed requset + resp, err := apClient.GetBody(userURL) + require.NoError(t, err) + + var person ap.Person + err = person.UnmarshalJSON(resp) + require.NoError(t, err) + + assert.Equal(t, ap.PersonType, person.Type) + assert.Equal(t, username, person.PreferredUsername.String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", userID), person.GetID()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/outbox$", userID), person.Outbox.GetID().String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/inbox$", userID), person.Inbox.GetID().String()) + + assert.NotNil(t, person.PublicKey) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d#main-key$", userID), person.PublicKey.ID) + + assert.NotNil(t, person.PublicKey.PublicKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", person.PublicKey.PublicKeyPem) + }) + }) } func TestActivityPubMissingPerson(t *testing.T) { diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go index 29fbe6d781..fd19b4ce33 100644 --- a/tests/integration/api_activitypub_repository_test.go +++ b/tests/integration/api_activitypub_repository_test.go @@ -28,18 +28,28 @@ import ( func TestActivityPubRepository(t *testing.T) { defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() - defer tests.PrepareTestEnv(t)() - repositoryID := 2 - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) - resp := MakeRequest(t, req, http.StatusOK) - assert.Contains(t, resp.Body.String(), "@context") + onGiteaRun(t, func(t *testing.T, u *url.URL) { + repositoryID := 2 - var repository forgefed_modules.Repository - err := repository.UnmarshalJSON(resp.Body.Bytes()) - require.NoError(t, err) + apServerActor := user.NewAPServerActor() - assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%v$", repositoryID), repository.GetID().String()) + cf, err := activitypub.GetClientFactory(db.DefaultContext) + require.NoError(t, err) + + c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) + require.NoError(t, err) + + resp, err := c.GetBody(fmt.Sprintf("%sapi/v1/activitypub/repository-id/%d", u, repositoryID)) + require.NoError(t, err) + assert.Contains(t, string(resp), "@context") + + var repository forgefed_modules.Repository + err = repository.UnmarshalJSON(resp) + require.NoError(t, err) + + assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%d$", repositoryID), repository.GetID().String()) + }) } func TestActivityPubMissingRepository(t *testing.T) { @@ -48,7 +58,7 @@ func TestActivityPubMissingRepository(t *testing.T) { defer tests.PrepareTestEnv(t)() repositoryID := 9999999 - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID)) resp := MakeRequest(t, req, http.StatusNotFound) assert.Contains(t, resp.Body.String(), "repository does not exist") } @@ -62,14 +72,16 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) { defer federatedSrv.Close() onGiteaRun(t, func(t *testing.T, u *url.URL) { - actionsUser := user.NewActionsUser() + apServerActor := user.NewAPServerActor() repositoryID := 2 timeNow := time.Now().UTC() cf, err := activitypub.GetClientFactory(db.DefaultContext) require.NoError(t, err) - c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used") + + c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) require.NoError(t, err) + repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() activity1 := []byte(fmt.Sprintf( @@ -139,14 +151,16 @@ func TestActivityPubRepositoryInboxInvalid(t *testing.T) { defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() onGiteaRun(t, func(t *testing.T, u *url.URL) { - actionsUser := user.NewActionsUser() + apServerActor := user.NewAPServerActor() repositoryID := 2 + cf, err := activitypub.GetClientFactory(db.DefaultContext) require.NoError(t, err) - c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used") + + c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) require.NoError(t, err) - repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repositoryID)).String() + repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() activity := []byte(`{"type":"Wrong"}`) resp, err := c.Post(activity, repoInboxURL) require.NoError(t, err) diff --git a/tests/integration/api_federation_httpsig_test.go b/tests/integration/api_federation_httpsig_test.go new file mode 100644 index 0000000000..9d66f25102 --- /dev/null +++ b/tests/integration/api_federation_httpsig_test.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "forgejo.org/models/db" + "forgejo.org/models/forgefed" + "forgejo.org/models/unittest" + "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/routers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFederationHttpSigValidation(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + userID := 2 + userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID) + + user1 := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 1}) + + clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) + require.NoError(t, err) + + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) + require.NoError(t, err) + + // Unsigned request + t.Run("UnsignedRequest", func(t *testing.T) { + req := NewRequest(t, "GET", userURL) + MakeRequest(t, req, http.StatusBadRequest) + }) + + // Signed request + t.Run("SignedRequest", func(t *testing.T) { + resp, err := apClient.Get(userURL) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + // HACK HACK HACK: the host part of the URL gets set to which IP forgejo is + // listening on, NOT localhost, which is the Domain given to forgejo which + // is then used for eg. the keyID all requests + applicationKeyID := fmt.Sprintf("%sapi/v1/activitypub/actor#main-key", setting.AppURL) + actorKeyID := fmt.Sprintf("%sapi/v1/activitypub/user-id/1#main-key", setting.AppURL) + + // Check for cached public keys + t.Run("ValidateCaches", func(t *testing.T) { + host, err := forgefed.FindFederationHostByKeyID(db.DefaultContext, applicationKeyID) + require.NoError(t, err) + assert.NotNil(t, host) + assert.True(t, host.PublicKey.Valid) + + user, err := user.GetFederatedUserByKeyID(db.DefaultContext, actorKeyID) + require.NoError(t, err) + assert.NotNil(t, user) + assert.True(t, user.PublicKey.Valid) + }) + + // Disable signature validation + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() + + // Unsigned request + t.Run("SignatureValidationDisabled", func(t *testing.T) { + req := NewRequest(t, "GET", userURL) + MakeRequest(t, req, http.StatusOK) + }) + }) +} diff --git a/tests/integration/user_federationhost_xorm_test.go b/tests/integration/user_federationhost_xorm_test.go new file mode 100644 index 0000000000..ed29a23ab7 --- /dev/null +++ b/tests/integration/user_federationhost_xorm_test.go @@ -0,0 +1,109 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "database/sql" + "testing" + + "forgejo.org/models/db" + "forgejo.org/models/forgefed" + "forgejo.org/models/user" + "forgejo.org/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStoreFederationHost(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("ExplicitNull", func(t *testing.T) { + federationHost := forgefed.FederationHost{ + HostFqdn: "ExplicitNull", + // Explicit null on KeyID and PublicKey + KeyID: sql.NullString{Valid: false}, + PublicKey: sql.Null[sql.RawBytes]{Valid: false}, + } + + _, err := db.GetEngine(db.DefaultContext).Insert(&federationHost) + require.NoError(t, err) + + dbFederationHost := new(forgefed.FederationHost) + has, err := db.GetEngine(db.DefaultContext).Where("host_fqdn=?", "ExplicitNull").Get(dbFederationHost) + require.NoError(t, err) + assert.True(t, has) + + assert.False(t, dbFederationHost.KeyID.Valid) + assert.False(t, dbFederationHost.PublicKey.Valid) + }) + + t.Run("NotNull", func(t *testing.T) { + federationHost := forgefed.FederationHost{ + HostFqdn: "ImplicitNull", + KeyID: sql.NullString{Valid: true, String: "meow"}, + PublicKey: sql.Null[sql.RawBytes]{Valid: true, V: sql.RawBytes{0x23, 0x42}}, + } + + _, err := db.GetEngine(db.DefaultContext).Insert(&federationHost) + require.NoError(t, err) + + dbFederationHost := new(forgefed.FederationHost) + has, err := db.GetEngine(db.DefaultContext).Where("host_fqdn=?", "ImplicitNull").Get(dbFederationHost) + require.NoError(t, err) + assert.True(t, has) + + assert.True(t, dbFederationHost.KeyID.Valid) + assert.Equal(t, "meow", dbFederationHost.KeyID.String) + + assert.True(t, dbFederationHost.PublicKey.Valid) + assert.Equal(t, sql.RawBytes{0x23, 0x42}, dbFederationHost.PublicKey.V) + }) +} + +func TestStoreFederatedUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("ExplicitNull", func(t *testing.T) { + federatedUser := user.FederatedUser{ + UserID: 0, + ExternalID: "ExplicitNull", + FederationHostID: 0, + KeyID: sql.NullString{Valid: false}, + PublicKey: sql.Null[sql.RawBytes]{Valid: false}, + } + + _, err := db.GetEngine(db.DefaultContext).Insert(&federatedUser) + require.NoError(t, err) + + dbFederatedUser := new(user.FederatedUser) + has, err := db.GetEngine(db.DefaultContext).Where("user_id=?", 0).Get(dbFederatedUser) + require.NoError(t, err) + assert.True(t, has) + + assert.False(t, dbFederatedUser.KeyID.Valid) + assert.False(t, dbFederatedUser.PublicKey.Valid) + }) + + t.Run("NotNull", func(t *testing.T) { + federatedUser := user.FederatedUser{ + UserID: 1, + ExternalID: "ImplicitNull", + FederationHostID: 1, + KeyID: sql.NullString{Valid: true, String: "woem"}, + PublicKey: sql.Null[sql.RawBytes]{Valid: true, V: sql.RawBytes{0x42, 0x23}}, + } + + _, err := db.GetEngine(db.DefaultContext).Insert(&federatedUser) + require.NoError(t, err) + + dbFederatedUser := new(user.FederatedUser) + has, err := db.GetEngine(db.DefaultContext).Where("user_id=?", 1).Get(dbFederatedUser) + require.NoError(t, err) + assert.True(t, has) + + assert.True(t, dbFederatedUser.KeyID.Valid) + assert.Equal(t, "woem", dbFederatedUser.KeyID.String) + assert.True(t, dbFederatedUser.PublicKey.Valid) + assert.Equal(t, sql.RawBytes{0x42, 0x23}, dbFederatedUser.PublicKey.V) + }) +}