From 6f501b1fdf96d59c876a41e158c2273adbc8c571 Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Tue, 1 Jul 2025 19:49:00 +0200 Subject: [PATCH] Improved signature handling & instance actor (#8275) This PR is part of https://codeberg.org/forgejo/forgejo/pulls/4767 It improves the signature handling: 1. move logic to a service (might be used from other services as well) 2. make a clear difference between ` ReqHTTPUserSignature` and `ReqHTTPUserOrInstanceSignature` 3. improve test ability (activitypub/client & distant_federation_server_mock Adjust instance actor 1. name & 2. webfinger ## Strategy for next PRs is Integration tests are in the driving seat. I will step by step add integration tests form original PR and add code required by the integration test changes. ## Meta Proposal howto process large PRs can be discussed here: https://codeberg.org/forgejo-contrib/federation/pulls/37 Current state with rendered diagrams can be found here: https://codeberg.org/meissa/federation/src/branch/merge-large-pr/doc/merge-large-pr.md Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8275 Reviewed-by: Earl Warren Co-authored-by: Michael Jerger Co-committed-by: Michael Jerger --- .deadcode-out | 7 +- models/user/activitypub.go | 4 +- models/user/user_repository.go | 9 - models/user/user_test.go | 2 +- modules/activitypub/client.go | 46 ++-- modules/structs/activitypub.go | 13 + .../test/distant_federation_server_mock.go | 89 ++++--- routers/api/v1/activitypub/actor.go | 4 +- routers/api/v1/activitypub/reqsignature.go | 221 +++------------- routers/api/v1/api.go | 19 +- routers/web/webfinger.go | 26 ++ services/federation/federation_service.go | 246 ++++++------------ services/federation/repo_like.go | 146 +++++++++++ services/federation/signature_service.go | 234 +++++++++++++++++ tests/integration/activitypub_client_test.go | 2 +- .../integration/api_activitypub_actor_test.go | 33 ++- .../api_activitypub_person_test.go | 36 ++- .../api_activitypub_repository_test.go | 21 +- .../api_federation_httpsig_test.go | 2 +- tests/integration/webfinger_test.go | 9 + 20 files changed, 726 insertions(+), 443 deletions(-) create mode 100644 services/federation/repo_like.go create mode 100644 services/federation/signature_service.go diff --git a/.deadcode-out b/.deadcode-out index 0e3a97ffa8..f9bee43043 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -34,9 +34,6 @@ forgejo.org/models/dbfs Create Rename -forgejo.org/models/forgefed - GetFederationHost - forgejo.org/models/forgejo/semver GetVersion SetVersionString @@ -68,7 +65,6 @@ forgejo.org/models/user DeleteUserSetting GetFederatedUser GetFederatedUserByUserID - UpdateFederatedUser GetFollowersForUser AddFollower RemoveFollower @@ -249,6 +245,9 @@ forgejo.org/routers/web/org forgejo.org/services/context GetPrivateContext +forgejo.org/services/federation + Init + forgejo.org/services/repository IsErrForkAlreadyExist diff --git a/models/user/activitypub.go b/models/user/activitypub.go index 816fd8a098..aabf2336fc 100644 --- a/models/user/activitypub.go +++ b/models/user/activitypub.go @@ -19,7 +19,7 @@ func (u *User) APActorID() string { 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 { +// KeyID returns the ID of the user's public key +func (u *User) KeyID() string { return u.APActorID() + "#main-key" } diff --git a/models/user/user_repository.go b/models/user/user_repository.go index 3f24efb1fb..85f44f1598 100644 --- a/models/user/user_repository.go +++ b/models/user/user_repository.go @@ -57,14 +57,6 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat return committer.Commit() } -func (federatedUser *FederatedUser) UpdateFederatedUser(ctx context.Context) error { - if _, err := validation.IsValid(federatedUser); err != nil { - return err - } - _, err := db.GetEngine(ctx).ID(federatedUser.ID).Cols("inbox_path").Update(federatedUser) - return err -} - func FindFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) { federatedUser := new(FederatedUser) user := new(User) @@ -219,7 +211,6 @@ func RemoveFollower(ctx context.Context, followedUser *User, followingUser *Fede return err } -// TODO: We should unify Activity-pub-following and classical following (see models/user/follow.go) func IsFollowingAp(ctx context.Context, followedUser *User, followingUser *FederatedUser) (bool, error) { if res, err := validation.IsValid(followedUser); !res { return false, err diff --git a/models/user/user_test.go b/models/user/user_test.go index fd9d05653f..f9a3aa6075 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -150,7 +150,7 @@ func TestAPActorID_APActorID(t *testing.T) { func TestKeyID(t *testing.T) { user := user_model.User{ID: 1} - url := user.APActorKeyID() + url := user.KeyID() expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key" assert.Equal(t, expected, url) } diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go index d015fb7bec..fb6fa8b543 100644 --- a/modules/activitypub/client.go +++ b/modules/activitypub/client.go @@ -89,6 +89,7 @@ func NewClientFactory() (c *ClientFactory, err error) { type APClientFactory interface { WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) + WithKeysDirect(ctx context.Context, privateKey, pubID string) (APClient, error) } // Client struct @@ -103,12 +104,8 @@ type Client struct { } // NewRequest function -func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) { - priv, err := GetPrivateKey(ctx, user) - if err != nil { - return nil, err - } - privPem, _ := pem.Decode([]byte(priv)) +func (cf *ClientFactory) WithKeysDirect(ctx context.Context, privateKey, pubID string) (APClient, error) { + privPem, _ := pem.Decode([]byte(privateKey)) privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) if err != nil { return nil, err @@ -126,6 +123,14 @@ func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pu return &c, nil } +func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) { + priv, err := GetPrivateKey(ctx, user) + if err != nil { + return nil, err + } + return cf.WithKeysDirect(ctx, priv, pubID) +} + // NewRequest function func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) { buf := bytes.NewBuffer(b) @@ -149,12 +154,14 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { return nil, err } - signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) - if err != nil { - return nil, err - } - if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil { - return nil, err + if c.pubID != "" { + signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) + if err != nil { + return nil, err + } + if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil { + return nil, err + } } resp, err = c.client.Do(req) @@ -167,12 +174,15 @@ func (c *Client) Get(to string) (resp *http.Response, err error) { if req, err = c.newRequest(http.MethodGet, nil, to); err != nil { return nil, err } - signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime) - if err != nil { - return nil, err - } - if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil { - return nil, err + + if c.pubID != "" { + signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime) + if err != nil { + return nil, err + } + if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil { + return nil, err + } } resp, err = c.client.Do(req) diff --git a/modules/structs/activitypub.go b/modules/structs/activitypub.go index 117eb0bed2..0cc257ff95 100644 --- a/modules/structs/activitypub.go +++ b/modules/structs/activitypub.go @@ -1,4 +1,5 @@ // Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package structs @@ -7,3 +8,15 @@ package structs type ActivityPub struct { Context string `json:"@context"` } + +type APRemoteFollowOption struct { + Target string `json:"target"` +} + +type APPersonFollowItem struct { + ActorID string `json:"actor_id"` + Note string `json:"note"` + + OriginalURL string `json:"original_url"` + OriginalItem string `json:"original_item"` +} diff --git a/modules/test/distant_federation_server_mock.go b/modules/test/distant_federation_server_mock.go index 9bd908e2b9..ea8a69e9b4 100644 --- a/modules/test/distant_federation_server_mock.go +++ b/modules/test/distant_federation_server_mock.go @@ -10,56 +10,79 @@ import ( "net/http/httptest" "strings" "testing" + + "forgejo.org/modules/util" ) type FederationServerMockPerson struct { - ID int64 - Name string - PubKey string + ID int64 + Name string + PubKey string + PrivKey string } type FederationServerMockRepository struct { ID int64 } +type ApActorMock struct { + PrivKey string + PubKey string +} type FederationServerMock struct { + ApActor ApActorMock Persons []FederationServerMockPerson Repositories []FederationServerMockRepository LastPost string } func NewFederationServerMockPerson(id int64, name string) FederationServerMockPerson { + priv, pub, _ := util.GenerateKeyPair(3072) return FederationServerMockPerson{ - ID: id, - Name: name, - PubKey: `"-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA18H5s7N6ItZUAh9tneII\nIuZdTTa3cZlLa/9ejWAHTkcp3WLW+/zbsumlMrWYfBy2/yTm56qasWt38iY4D6ul\n` + - `CPiwhAqX3REvVq8tM79a2CEqZn9ka6vuXoDgBg/sBf/BUWqf7orkjUXwk/U0Egjf\nk5jcurF4vqf1u+rlAHH37dvSBaDjNj6Qnj4OP12bjfaY/yvs7+jue/eNXFHjzN4E\n` + - `T2H4B/yeKTJ4UuAwTlLaNbZJul2baLlHelJPAsxiYaziVuV5P+IGWckY6RSerRaZ\nAkc4mmGGtjAyfN9aewe+lNVfwS7ElFx546PlLgdQgjmeSwLX8FWxbPE5A/PmaXCs\n` + - `nx+nou+3dD7NluULLtdd7K+2x02trObKXCAzmi5/Dc+yKTzpFqEz+hLNCz7TImP/\ncK//NV9Q+X67J9O27baH9R9ZF4zMw8rv2Pg0WLSw1z7lLXwlgIsDapeMCsrxkVO4\n` + - `LXX5AQ1xQNtlssnVoUBqBrvZsX2jUUKUocvZqMGuE4hfAgMBAAE=\n-----END PUBLIC KEY-----\n"`, + ID: id, + Name: name, + PubKey: pub, + PrivKey: priv, } } +func (p *FederationServerMockPerson) KeyID(host string) string { + return fmt.Sprintf("%[1]v/api/v1/activitypub/user-id/%[2]v#main-key", host, p.ID) +} + func NewFederationServerMockRepository(id int64) FederationServerMockRepository { return FederationServerMockRepository{ ID: id, } } +func NewApActorMock() ApActorMock { + priv, pub, _ := util.GenerateKeyPair(1024) + return ApActorMock{ + PrivKey: priv, + PubKey: pub, + } +} + +func (u *ApActorMock) KeyID(host string) string { + return fmt.Sprintf("%[1]v/api/v1/activitypub/actor#main-key", host) +} + func (p FederationServerMockPerson) marshal(host string) string { return fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],`+ - `"id":"http://%[1]v/api/activitypub/user-id/%[2]v",`+ + `"id":"http://%[1]v/api/v1/activitypub/user-id/%[2]v",`+ `"type":"Person",`+ `"icon":{"type":"Image","mediaType":"image/png","url":"http://%[1]v/avatars/1bb05d9a5f6675ed0272af9ea193063c"},`+ `"url":"http://%[1]v/%[2]v",`+ - `"inbox":"http://%[1]v/api/activitypub/user-id/%[2]v/inbox",`+ - `"outbox":"http://%[1]v/api/activitypub/user-id/%[2]v/outbox",`+ + `"inbox":"http://%[1]v/api/v1/activitypub/user-id/%[2]v/inbox",`+ + `"outbox":"http://%[1]v/api/v1/activitypub/user-id/%[2]v/outbox",`+ `"preferredUsername":"%[3]v",`+ - `"publicKey":{"id":"http://%[1]v/api/activitypub/user-id/%[2]v#main-key",`+ - `"owner":"http://%[1]v/api/activitypub/user-id/%[2]v",`+ - `"publicKeyPem":%[4]v}}`, host, p.ID, p.Name, p.PubKey) + `"publicKey":{"id":"http://%[1]v/api/v1/activitypub/user-id/%[2]v#main-key",`+ + `"owner":"http://%[1]v/api/v1/activitypub/user-id/%[2]v",`+ + `"publicKeyPem":%[4]q}}`, host, p.ID, p.Name, p.PubKey) } func NewFederationServerMock() *FederationServerMock { return &FederationServerMock{ + ApActor: NewApActorMock(), Persons: []FederationServerMockPerson{ NewFederationServerMockPerson(15, "stargoose1"), NewFederationServerMockPerson(30, "stargoose2"), @@ -71,8 +94,18 @@ func NewFederationServerMock() *FederationServerMock { } } +func (mock *FederationServerMock) recordLastPost(t *testing.T, req *http.Request) { + buf := new(strings.Builder) + _, err := io.Copy(buf, req.Body) + if err != nil { + t.Errorf("Error reading body: %q", err) + } + mock.LastPost = strings.ReplaceAll(buf.String(), req.Host, "DISTANT_FEDERATION_HOST") +} + func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server { federatedRoutes := http.NewServeMux() + federatedRoutes.HandleFunc("/.well-known/nodeinfo", func(res http.ResponseWriter, req *http.Request) { // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo @@ -87,30 +120,28 @@ func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server { `"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},`+ `"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`) }) + for _, person := range mock.Persons { federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/%v", person.ID), func(res http.ResponseWriter, req *http.Request) { // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2 fmt.Fprint(res, person.marshal(req.Host)) }) - } - for _, repository := range mock.Repositories { - federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repository.ID), + federatedRoutes.HandleFunc(fmt.Sprintf("POST /api/v1/activitypub/user-id/%v/inbox", person.ID), func(res http.ResponseWriter, req *http.Request) { - if req.Method != "POST" { - t.Errorf("POST expected at: %q", req.URL.EscapedPath()) - } - buf := new(strings.Builder) - _, err := io.Copy(buf, req.Body) - if err != nil { - t.Errorf("Error reading body: %q", err) - } - mock.LastPost = buf.String() + mock.recordLastPost(t, req) + }) + } + + for _, repository := range mock.Repositories { + federatedRoutes.HandleFunc(fmt.Sprintf("POST /api/v1/activitypub/repository-id/%v/inbox", repository.ID), + func(res http.ResponseWriter, req *http.Request) { + mock.recordLastPost(t, req) }) } federatedRoutes.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) { - t.Errorf("Unhandled request: %q", req.URL.EscapedPath()) + t.Errorf("Unhandled %v request: %q", req.Method, req.URL.EscapedPath()) }) federatedSrv := httptest.NewServer(federatedRoutes) return federatedSrv diff --git a/routers/api/v1/activitypub/actor.go b/routers/api/v1/activitypub/actor.go index e49f277842..0ff822c7f4 100644 --- a/routers/api/v1/activitypub/actor.go +++ b/routers/api/v1/activitypub/actor.go @@ -32,7 +32,7 @@ func Actor(ctx *context.APIContext) { actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) actor.PreferredUsername = ap.NaturalLanguageValuesNew() - err := actor.PreferredUsername.Set("en", ap.Content(setting.Domain)) + err := actor.PreferredUsername.Set("en", ap.Content("ghost")) if err != nil { ctx.ServerError("PreferredUsername.Set", err) return @@ -41,8 +41,6 @@ func Actor(ctx *context.APIContext) { actor.URL = ap.IRI(setting.AppURL) actor.Inbox = ap.IRI(link + "/inbox") - actor.Outbox = ap.IRI(link + "/outbox") - actor.PublicKey.ID = ap.IRI(link + "#main-key") actor.PublicKey.Owner = ap.IRI(link) diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go index b84fbe05fa..91274249ec 100644 --- a/routers/api/v1/activitypub/reqsignature.go +++ b/routers/api/v1/activitypub/reqsignature.go @@ -4,132 +4,17 @@ package activitypub import ( - "crypto" - "crypto/x509" - "database/sql" - "encoding/pem" - "errors" - "fmt" "net/http" - "net/url" - "forgejo.org/models/db" - "forgejo.org/models/forgefed" - "forgejo.org/models/user" - "forgejo.org/modules/activitypub" - 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 decodePublicKeyPem(pubKeyPem string) ([]byte, error) { - block, _ := pem.Decode([]byte(pubKeyPem)) - if block == nil || block.Type != "PUBLIC KEY" { - return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") - } - - return block.Bytes, nil -} - -func getFederatedUser(ctx *gitea_context.APIContext, person *ap.Person, federationHost *forgefed.FederationHost) (*user.FederatedUser, error) { - personID, err := fm.NewPersonID(person.ID.String(), string(federationHost.NodeInfo.SoftwareName)) - if err != nil { - return nil, err - } - _, federatedUser, err := user.FindFederatedUser(ctx, personID.ID, federationHost.ID) - if err != nil { - return nil, err - } - - if federatedUser != nil { - return federatedUser, nil - } - - _, newFederatedUser, err := federation.CreateUserFromAP(ctx, personID, federationHost.ID) - if err != nil { - return nil, err - } - - return newFederatedUser, 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) { +func verifyHTTPUserOrInstanceSignature(ctx *gitea_context.APIContext) (authenticated bool, err error) { if !setting.Federation.SignatureEnforced { return true, nil } @@ -142,84 +27,64 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er return false, err } - ID := v.KeyId() - idIRI, err := url.Parse(ID) + signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0]) + pubKey, err := federation.FindOrCreateFederatedUserKey(ctx.Base, v.KeyId()) + if err != nil || pubKey == nil { + pubKey, err = federation.FindOrCreateFederationHostKey(ctx.Base, v.KeyId()) + if err != nil { + return false, err + } + } + + err = v.Verify(pubKey, signatureAlgorithm) + if err != nil { + return false, err + } + return true, nil +} + +func verifyHTTPUserSignature(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 + v, err := httpsig.NewVerifier(r) if err != nil { return false, err } signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0]) - - // 2. Fetch the public key of the other actor - // Try if the signing actor is an already known federated user - _, federationUser, err := user.FindFederatedUserByKeyID(ctx, idIRI.String()) + pubKey, err := federation.FindOrCreateFederatedUserKey(ctx.Base, v.KeyId()) if err != nil { return false, err } - 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()) + err = v.Verify(pubKey, signatureAlgorithm) if err != nil { return false, err } - - 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 + return true, nil } // ReqHTTPSignature function -func ReqHTTPSignature() func(ctx *gitea_context.APIContext) { +func ReqHTTPUserOrInstanceSignature() func(ctx *gitea_context.APIContext) { return func(ctx *gitea_context.APIContext) { - if authenticated, err := verifyHTTPSignatures(ctx); err != nil { + if authenticated, err := verifyHTTPUserOrInstanceSignature(ctx); err != nil { + log.Warn("verifyHttpSignatures failed: %v", err) + ctx.Error(http.StatusBadRequest, "reqSignature", "request signature verification failed") + } else if !authenticated { + ctx.Error(http.StatusForbidden, "reqSignature", "request signature verification failed") + } + } +} + +// ReqHTTPSignature function +func ReqHTTPUserSignature() func(ctx *gitea_context.APIContext) { + return func(ctx *gitea_context.APIContext) { + if authenticated, err := verifyHTTPUserSignature(ctx); err != nil { log.Warn("verifyHttpSignatures failed: %v", err) ctx.Error(http.StatusBadRequest, "reqSignature", "request signature verification failed") } else if !authenticated { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index bf08bdd249..fe13f85df4 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -92,6 +92,7 @@ import ( _ "forgejo.org/routers/api/v1/swagger" // for swagger generation "code.forgejo.org/go-chi/binding" + ap "github.com/go-ap/activitypub" ) func sudo() func(ctx *context.APIContext) { @@ -826,24 +827,22 @@ func Routes() *web.Route { if setting.Federation.Enabled { m.Get("/nodeinfo", misc.NodeInfo) m.Group("/activitypub", func() { - // deprecated, remove in 1.20, use /user-id/{user-id} instead - m.Group("/user/{username}", func() { - 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.ReqHTTPSignature(), activitypub.Person) - m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) + m.Get("", activitypub.ReqHTTPUserOrInstanceSignature(), activitypub.Person) + m.Post("/inbox", + activitypub.ReqHTTPUserSignature(), + bind(ap.Activity{}), + activitypub.PersonInbox) }, context.UserIDAssignmentAPI(), checkTokenPublicOnly()) m.Group("/actor", func() { m.Get("", activitypub.Actor) - m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.ActorInbox) + m.Post("/inbox", activitypub.ReqHTTPUserOrInstanceSignature(), activitypub.ActorInbox) }) m.Group("/repository-id/{repository-id}", func() { - m.Get("", activitypub.ReqHTTPSignature(), activitypub.Repository) + m.Get("", activitypub.ReqHTTPUserSignature(), activitypub.Repository) m.Post("/inbox", bind(forgefed.ForgeLike{}), - activitypub.ReqHTTPSignature(), + activitypub.ReqHTTPUserSignature(), activitypub.RepositoryInbox) }, context.RepositoryIDAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index be3c2925fe..372c08f7d8 100644 --- a/routers/web/webfinger.go +++ b/routers/web/webfinger.go @@ -58,7 +58,33 @@ func WebfingerQuery(ctx *context.Context) { return } + // Instance actor + if parts[0] == "ghost" { + aliases := []string{ + appURL.String() + "api/v1/activitypub/actor", + } + + links := []*webfingerLink{ + { + Rel: "self", + Type: "application/activity+json", + Href: appURL.String() + "api/v1/activitypub/actor", + }, + } + + ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*") + ctx.JSON(http.StatusOK, &webfingerJRD{ + Subject: fmt.Sprintf("acct:%s@%s", "ghost", appURL.Host), + Aliases: aliases, + Links: links, + }) + ctx.Resp.Header().Set("Content-Type", "application/jrd+json") + + return + } + u, err = user_model.GetUserByName(ctx, parts[0]) + case "mailto": u, err = user_model.GetUserByEmail(ctx, resource.Opaque) if u != nil && u.KeepEmailPrivate { diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index a3b719d1a7..b71d8d2575 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -5,15 +5,12 @@ package federation import ( "context" - "errors" + "database/sql" "fmt" - "net/http" "net/url" "strings" - "time" "forgejo.org/models/forgefed" - "forgejo.org/models/repo" "forgejo.org/models/user" "forgejo.org/modules/activitypub" "forgejo.org/modules/auth/password" @@ -21,91 +18,84 @@ import ( "forgejo.org/modules/log" "forgejo.org/modules/setting" "forgejo.org/modules/validation" + context_service "forgejo.org/services/context" "github.com/google/uuid" ) -// ProcessLikeActivity receives a ForgeLike activity and does the following: -// Validation of the activity -// Creation of a (remote) federationHost if not existing -// Creation of a forgefed Person if not existing -// Validation of incoming RepositoryID against Local RepositoryID -// Star the repo if it wasn't already stared -// Do some mitigation against out of order attacks -func ProcessLikeActivity(ctx context.Context, form any, repositoryID int64) (int, string, error) { - activity := form.(*fm.ForgeLike) - if res, err := validation.IsValid(activity); !res { - return http.StatusNotAcceptable, "Invalid activity", err - } - log.Info("Activity validated:%v", activity) +func Init() error { + return nil +} - // parse actorID (person) - actorURI := activity.Actor.GetID().String() - log.Info("actorURI was: %v", actorURI) - federationHost, err := GetFederationHostForURI(ctx, actorURI) +func FindOrCreateFederationHost(ctx *context_service.Base, actorURI string) (*forgefed.FederationHost, error) { + rawActorID, err := fm.NewActorID(actorURI) if err != nil { - return http.StatusInternalServerError, "Wrong FederationHost", err + return nil, err } - if !activity.IsNewer(federationHost.LatestActivity) { - return http.StatusNotAcceptable, "Activity out of order.", errors.New("Activity already processed") + federationHost, err := forgefed.FindFederationHostByFqdnAndPort(ctx, rawActorID.Host, rawActorID.HostPort) + if err != nil { + return nil, err + } + if federationHost == nil { + result, err := createFederationHostFromAP(ctx, rawActorID) + if err != nil { + return nil, err + } + federationHost = result + } + return federationHost, nil +} + +func FindOrCreateFederatedUser(ctx *context_service.Base, actorURI string) (*user.User, *user.FederatedUser, *forgefed.FederationHost, error) { + user, federatedUser, federationHost, err := findFederatedUser(ctx, actorURI) + if err != nil { + return nil, nil, nil, err + } + personID, err := fm.NewPersonID(actorURI, string(federationHost.NodeInfo.SoftwareName)) + if err != nil { + return nil, nil, nil, err + } + + if user != nil { + log.Trace("Found local federatedUser: %#v", user) + } else { + user, federatedUser, err = createUserFromAP(ctx, personID, federationHost.ID) + if err != nil { + return nil, nil, nil, err + } + log.Trace("Created federatedUser from ap: %#v", user) + } + log.Trace("Got user: %v", user.Name) + + return user, federatedUser, federationHost, nil +} + +func findFederatedUser(ctx *context_service.Base, actorURI string) (*user.User, *user.FederatedUser, *forgefed.FederationHost, error) { + federationHost, err := FindOrCreateFederationHost(ctx, actorURI) + if err != nil { + return nil, nil, nil, err } actorID, err := fm.NewPersonID(actorURI, string(federationHost.NodeInfo.SoftwareName)) if err != nil { - return http.StatusNotAcceptable, "Invalid PersonID", err + return nil, nil, nil, err } - log.Info("Actor accepted:%v", actorID) - // parse objectID (repository) - objectID, err := fm.NewRepositoryID(activity.Object.GetID().String(), string(forgefed.ForgejoSourceType)) + user, federatedUser, err := user.FindFederatedUser(ctx, actorID.ID, federationHost.ID) if err != nil { - return http.StatusNotAcceptable, "Invalid objectId", err - } - if objectID.ID != fmt.Sprint(repositoryID) { - return http.StatusNotAcceptable, "Invalid objectId", err - } - log.Info("Object accepted:%v", objectID) - - // Check if user already exists - user, _, err := user.FindFederatedUser(ctx, actorID.ID, federationHost.ID) - if err != nil { - return http.StatusInternalServerError, "Searching for user failed", err - } - if user != nil { - log.Info("Found local federatedUser: %v", user) - } else { - user, _, err = CreateUserFromAP(ctx, actorID, federationHost.ID) - if err != nil { - return http.StatusInternalServerError, "Error creating federatedUser", err - } - log.Info("Created federatedUser from ap: %v", user) - } - log.Info("Got user:%v", user.Name) - - // execute the activity if the repo was not stared already - alreadyStared := repo.IsStaring(ctx, user.ID, repositoryID) - if !alreadyStared { - err = repo.StarRepo(ctx, user.ID, repositoryID, true) - if err != nil { - return http.StatusNotAcceptable, "Error staring", err - } - } - federationHost.LatestActivity = activity.StartTime - err = forgefed.UpdateFederationHost(ctx, federationHost) - if err != nil { - return http.StatusNotAcceptable, "Error updating federatedHost", err + return nil, nil, nil, err } - return 0, "", nil + return user, federatedUser, federationHost, nil } -func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) { +func createFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) { actionsUser := user.NewAPServerActor() clientFactory, err := activitypub.GetClientFactory(ctx) if err != nil { return nil, err } - client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) + client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID()) if err != nil { return nil, err } @@ -130,6 +120,7 @@ func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge return nil, err } + // TODO: we should get key material here also to have it immediately result, err := forgefed.NewFederationHost(actorID.Host, nodeInfo, actorID.HostPort, actorID.HostSchema) if err != nil { return nil, err @@ -143,34 +134,14 @@ func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge return &result, nil } -func GetFederationHostForURI(ctx context.Context, actorURI string) (*forgefed.FederationHost, error) { - log.Info("Input was: %v", actorURI) - rawActorID, err := fm.NewActorID(actorURI) - if err != nil { - return nil, err - } - federationHost, err := forgefed.FindFederationHostByFqdnAndPort(ctx, rawActorID.Host, rawActorID.HostPort) - if err != nil { - return nil, err - } - if federationHost == nil { - result, err := CreateFederationHostFromAP(ctx, rawActorID) - if err != nil { - return nil, err - } - federationHost = result - } - return federationHost, nil -} - -func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) { +func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) { actionsUser := user.NewAPServerActor() clientFactory, err := activitypub.GetClientFactory(ctx) if err != nil { return nil, nil, err } - apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) + apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID()) if err != nil { return nil, nil, err } @@ -216,6 +187,11 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI return nil, nil, err } + pubKeyBytes, err := decodePublicKeyPem(person.PublicKey.PublicKeyPem) + if err != nil { + return nil, nil, err + } + newUser := user.User{ LowerName: strings.ToLower(name), Name: name, @@ -234,86 +210,30 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI FederationHostID: federationHostID, InboxPath: inbox.Path, NormalizedOriginalURL: personID.AsURI(), + KeyID: sql.NullString{ + String: person.PublicKey.ID.String(), + Valid: true, + }, + PublicKey: sql.Null[sql.RawBytes]{ + V: pubKeyBytes, + Valid: true, + }, } - err = user.CreateFederatedUser(ctx, &newUser, &federatedUser) + log.Info("Fetch federatedUser:%q", federatedUser) + return &newUser, &federatedUser, nil +} + +func createUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) { + newUser, federatedUser, err := fetchUserFromAP(ctx, personID, federationHostID) + if err != nil { + return nil, nil, err + } + err = user.CreateFederatedUser(ctx, newUser, federatedUser) if err != nil { return nil, nil, err } log.Info("Created federatedUser:%q", federatedUser) - return &newUser, &federatedUser, nil -} - -// Create or update a list of FollowingRepo structs -func StoreFollowingRepoList(ctx context.Context, localRepoID int64, followingRepoList []string) (int, string, error) { - followingRepos := make([]*repo.FollowingRepo, 0, len(followingRepoList)) - for _, uri := range followingRepoList { - federationHost, err := GetFederationHostForURI(ctx, uri) - if err != nil { - return http.StatusInternalServerError, "Wrong FederationHost", err - } - followingRepoID, err := fm.NewRepositoryID(uri, string(federationHost.NodeInfo.SoftwareName)) - if err != nil { - return http.StatusNotAcceptable, "Invalid federated repo", err - } - followingRepo, err := repo.NewFollowingRepo(localRepoID, followingRepoID.ID, federationHost.ID, uri) - if err != nil { - return http.StatusNotAcceptable, "Invalid federated repo", err - } - followingRepos = append(followingRepos, &followingRepo) - } - - if err := repo.StoreFollowingRepos(ctx, localRepoID, followingRepos); err != nil { - return 0, "", err - } - - return 0, "", nil -} - -func DeleteFollowingRepos(ctx context.Context, localRepoID int64) error { - return repo.StoreFollowingRepos(ctx, localRepoID, []*repo.FollowingRepo{}) -} - -func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error { - followingRepos, err := repo.FindFollowingReposByRepoID(ctx, repoID) - log.Info("Federated Repos is: %v", followingRepos) - if err != nil { - return err - } - - likeActivityList := make([]fm.ForgeLike, 0) - for _, followingRepo := range followingRepos { - log.Info("Found following repo: %v", followingRepo) - target := followingRepo.URI - likeActivity, err := fm.NewForgeLike(doer.APActorID(), target, time.Now()) - if err != nil { - return err - } - likeActivityList = append(likeActivityList, likeActivity) - } - - apclientFactory, err := activitypub.GetClientFactory(ctx) - if err != nil { - return err - } - - apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorKeyID()) - if err != nil { - return err - } - for i, activity := range likeActivityList { - activity.StartTime = activity.StartTime.Add(time.Duration(i) * time.Second) - json, err := activity.MarshalJSON() - if err != nil { - return err - } - - _, err = apclient.Post(json, fmt.Sprintf("%s/inbox", activity.Object)) - if err != nil { - log.Error("error %v while sending activity: %q", err, activity) - } - } - - return nil + return newUser, federatedUser, nil } diff --git a/services/federation/repo_like.go b/services/federation/repo_like.go new file mode 100644 index 0000000000..c1e6500c61 --- /dev/null +++ b/services/federation/repo_like.go @@ -0,0 +1,146 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package federation + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "forgejo.org/models/forgefed" + "forgejo.org/models/repo" + "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + fm "forgejo.org/modules/forgefed" + "forgejo.org/modules/log" + "forgejo.org/modules/validation" + context_service "forgejo.org/services/context" +) + +// ProcessLikeActivity receives a ForgeLike activity and does the following: +// Validation of the activity +// Creation of a (remote) federationHost if not existing +// Creation of a forgefed Person if not existing +// Validation of incoming RepositoryID against Local RepositoryID +// Star the repo if it wasn't already stared +// Do some mitigation against out of order attacks +func ProcessLikeActivity(ctx *context_service.APIContext, form any, repositoryID int64) (int, string, error) { + activity := form.(*fm.ForgeLike) + if res, err := validation.IsValid(activity); !res { + return http.StatusNotAcceptable, "Invalid activity", err + } + log.Trace("Activity validated: %#v", activity) + + // parse actorID (person) + actorURI := activity.Actor.GetID().String() + user, _, federationHost, err := FindOrCreateFederatedUser(ctx.Base, actorURI) + if err != nil { + ctx.Error(http.StatusNotAcceptable, "Federated user not found", err) + return http.StatusInternalServerError, "FindOrCreateFederatedUser", err + } + + if !activity.IsNewer(federationHost.LatestActivity) { + return http.StatusNotAcceptable, "Activity out of order.", errors.New("Activity already processed") + } + + // parse objectID (repository) + objectID, err := fm.NewRepositoryID(activity.Object.GetID().String(), string(forgefed.ForgejoSourceType)) + if err != nil { + return http.StatusNotAcceptable, "Invalid objectId", err + } + if objectID.ID != fmt.Sprint(repositoryID) { + return http.StatusNotAcceptable, "Invalid objectId", err + } + log.Trace("Object accepted: %#v", objectID) + + // execute the activity if the repo was not stared already + alreadyStared := repo.IsStaring(ctx, user.ID, repositoryID) + if !alreadyStared { + err = repo.StarRepo(ctx, user.ID, repositoryID, true) + if err != nil { + return http.StatusNotAcceptable, "Error staring", err + } + } + federationHost.LatestActivity = activity.StartTime + err = forgefed.UpdateFederationHost(ctx, federationHost) + if err != nil { + return http.StatusNotAcceptable, "Error updating federatedHost", err + } + + return 0, "", nil +} + +// Create or update a list of FollowingRepo structs +func StoreFollowingRepoList(ctx *context_service.Context, localRepoID int64, followingRepoList []string) (int, string, error) { + followingRepos := make([]*repo.FollowingRepo, 0, len(followingRepoList)) + for _, uri := range followingRepoList { + federationHost, err := FindOrCreateFederationHost(ctx.Base, uri) + if err != nil { + return http.StatusInternalServerError, "Wrong FederationHost", err + } + followingRepoID, err := fm.NewRepositoryID(uri, string(federationHost.NodeInfo.SoftwareName)) + if err != nil { + return http.StatusNotAcceptable, "Invalid federated repo", err + } + followingRepo, err := repo.NewFollowingRepo(localRepoID, followingRepoID.ID, federationHost.ID, uri) + if err != nil { + return http.StatusNotAcceptable, "Invalid federated repo", err + } + followingRepos = append(followingRepos, &followingRepo) + } + + if err := repo.StoreFollowingRepos(ctx, localRepoID, followingRepos); err != nil { + return 0, "", err + } + + return 0, "", nil +} + +func DeleteFollowingRepos(ctx context.Context, localRepoID int64) error { + return repo.StoreFollowingRepos(ctx, localRepoID, []*repo.FollowingRepo{}) +} + +func SendLikeActivities(ctx context.Context, doer user.User, repoID int64) error { + followingRepos, err := repo.FindFollowingReposByRepoID(ctx, repoID) + log.Trace("Federated Repos is: %#v", followingRepos) + if err != nil { + return err + } + + likeActivityList := make([]fm.ForgeLike, 0) + for _, followingRepo := range followingRepos { + log.Trace("Found following repo: %#v", followingRepo) + target := followingRepo.URI + likeActivity, err := fm.NewForgeLike(doer.APActorID(), target, time.Now()) + if err != nil { + return err + } + likeActivityList = append(likeActivityList, likeActivity) + } + + apclientFactory, err := activitypub.GetClientFactory(ctx) + if err != nil { + return err + } + apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorID()+"#main-key") + if err != nil { + return err + } + for i, activity := range likeActivityList { + activity.StartTime = activity.StartTime.Add(time.Duration(i) * time.Second) + json, err := activity.MarshalJSON() + if err != nil { + return err + } + + _, err = apclient.Post(json, fmt.Sprintf("%v/inbox", activity.Object)) + if err != nil { + log.Error("error %v while sending activity: %#v", err, activity) + } + } + + return nil +} diff --git a/services/federation/signature_service.go b/services/federation/signature_service.go new file mode 100644 index 0000000000..e5102b89d8 --- /dev/null +++ b/services/federation/signature_service.go @@ -0,0 +1,234 @@ +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package federation + +import ( + "crypto/x509" + "database/sql" + "encoding/pem" + "errors" + "fmt" + "net/url" + + "forgejo.org/models/forgefed" + "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + fm "forgejo.org/modules/forgefed" + context_service "forgejo.org/services/context" + + ap "github.com/go-ap/activitypub" +) + +// Factory function for ActorID. Created struct is asserted to be valid +func NewActorIDFromKeyID(ctx *context_service.Base, uri string) (fm.ActorID, error) { + parsedURI, err := url.Parse(uri) + parsedURI.Fragment = "" + if err != nil { + return fm.ActorID{}, err + } + + actionsUser := user.NewAPServerActor() + clientFactory, err := activitypub.GetClientFactory(ctx) + if err != nil { + return fm.ActorID{}, err + } + + apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID()) + if err != nil { + return fm.ActorID{}, err + } + + userResponse, err := apClient.GetBody(parsedURI.String()) + if err != nil { + return fm.ActorID{}, err + } + + var actor ap.Actor + err = actor.UnmarshalJSON(userResponse) + if err != nil { + return fm.ActorID{}, err + } + + result, err := fm.NewActorID(actor.PublicKey.Owner.String()) + return result, err +} + +func FindOrCreateFederatedUserKey(ctx *context_service.Base, keyID string) (pubKey any, err error) { + var federatedUser *user.FederatedUser + var keyURL *url.URL + + keyURL, err = url.Parse(keyID) + if err != nil { + return nil, err + } + + // Try if the signing actor is an already known federated user + _, federatedUser, err = user.FindFederatedUserByKeyID(ctx, keyURL.String()) + if err != nil { + return nil, err + } + + if federatedUser == nil { + rawActorID, err := NewActorIDFromKeyID(ctx, keyID) + if err != nil { + return nil, err + } + + _, federatedUser, _, err = FindOrCreateFederatedUser(ctx, rawActorID.AsURI()) + if err != nil { + return nil, err + } + } else { + _, err = forgefed.GetFederationHost(ctx, federatedUser.FederationHostID) + if err != nil { + return nil, err + } + } + + if federatedUser.PublicKey.Valid { + pubKey, err := x509.ParsePKIXPublicKey(federatedUser.PublicKey.V) + if err != nil { + return nil, err + } + return pubKey, nil + } + + // Fetch missing public key + pubKey, pubKeyBytes, apPerson, err := fetchKeyFromAp(ctx, *keyURL) + if err != nil { + return nil, err + } + if apPerson.Type == ap.ActivityVocabularyType("Person") { + // Check federatedUser.id = person.id + if federatedUser.ExternalID != apPerson.ID.String() { + return nil, fmt.Errorf("federated user fetched (%v) does not match the stored one %v", apPerson, federatedUser) + } + // update federated user + federatedUser.KeyID = sql.NullString{ + String: apPerson.PublicKey.ID.String(), + Valid: true, + } + federatedUser.PublicKey = sql.Null[sql.RawBytes]{ + V: pubKeyBytes, + Valid: true, + } + err = user.UpdateFederatedUser(ctx, federatedUser) + if err != nil { + return nil, err + } + return pubKey, nil + } + return nil, nil +} + +func FindOrCreateFederationHostKey(ctx *context_service.Base, keyID string) (pubKey any, err error) { + keyURL, err := url.Parse(keyID) + if err != nil { + return nil, err + } + rawActorID, err := NewActorIDFromKeyID(ctx, keyID) + if err != nil { + return nil, err + } + + // Is there an already known federation host? + federationHost, err := forgefed.FindFederationHostByKeyID(ctx, keyURL.String()) + if err != nil { + return nil, err + } + + if federationHost == nil { + federationHost, err = FindOrCreateFederationHost(ctx, rawActorID.AsURI()) + if err != nil { + return nil, err + } + } + + // Is there an already an key? + if federationHost.PublicKey.Valid { + pubKey, err := x509.ParsePKIXPublicKey(federationHost.PublicKey.V) + if err != nil { + return nil, err + } + return pubKey, nil + } + + // If not, fetch missing public key + pubKey, pubKeyBytes, apPerson, err := fetchKeyFromAp(ctx, *keyURL) + if err != nil { + return nil, err + } + if apPerson.Type == ap.ActivityVocabularyType("Application") { + // Check federationhost.id = person.id + if federationHost.HostPort != rawActorID.HostPort || federationHost.HostFqdn != rawActorID.Host || + federationHost.HostSchema != rawActorID.HostSchema { + return nil, fmt.Errorf("federation host fetched (%v) does not match the stored one %v", apPerson, federationHost) + } + // update federation host + federationHost.KeyID = sql.NullString{ + String: apPerson.PublicKey.ID.String(), + Valid: true, + } + federationHost.PublicKey = sql.Null[sql.RawBytes]{ + V: pubKeyBytes, + Valid: true, + } + err = forgefed.UpdateFederationHost(ctx, federationHost) + if err != nil { + return nil, err + } + return pubKey, nil + } + return nil, nil +} + +func fetchKeyFromAp(ctx *context_service.Base, keyURL url.URL) (pubKey any, pubKeyBytes []byte, apPerson *ap.Person, err error) { + actionsUser := user.NewAPServerActor() + clientFactory, err := activitypub.GetClientFactory(ctx) + if err != nil { + return nil, nil, nil, err + } + + apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID()) + if err != nil { + return nil, nil, nil, err + } + + b, err := apClient.GetBody(keyURL.String()) + if err != nil { + return nil, nil, nil, err + } + + person := ap.PersonNew(ap.IRI(keyURL.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) + } + + pubKeyFromAp := person.PublicKey + if pubKeyFromAp.ID.String() != keyURL.String() { + return nil, nil, nil, fmt.Errorf("cannot find publicKey with id: %v in %v", keyURL, string(b)) + } + + pubKeyBytes, err = decodePublicKeyPem(pubKeyFromAp.PublicKeyPem) + if err != nil { + return nil, nil, nil, err + } + + pubKey, err = x509.ParsePKIXPublicKey(pubKeyBytes) + if err != nil { + return nil, nil, nil, err + } + + return pubKey, pubKeyBytes, person, err +} + +func decodePublicKeyPem(pubKeyPem string) ([]byte, error) { + block, _ := pem.Decode([]byte(pubKeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") + } + + return block.Bytes, nil +} diff --git a/tests/integration/activitypub_client_test.go b/tests/integration/activitypub_client_test.go index afafca52ae..2adb8304c2 100644 --- a/tests/integration/activitypub_client_test.go +++ b/tests/integration/activitypub_client_test.go @@ -29,7 +29,7 @@ func TestActivityPubClientBodySize(t *testing.T) { clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) require.NoError(t, err) - apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.KeyID()) require.NoError(t, err) url := u.JoinPath("/api/v1/nodeinfo").String() diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go index 778a34d785..42232bd640 100644 --- a/tests/integration/api_activitypub_actor_test.go +++ b/tests/integration/api_activitypub_actor_test.go @@ -4,12 +4,18 @@ package integration import ( + "fmt" "net/http" + "net/url" + "strconv" "testing" + "forgejo.org/modules/forgefed" "forgejo.org/modules/setting" "forgejo.org/modules/test" "forgejo.org/routers" + "forgejo.org/services/contexttest" + "forgejo.org/services/federation" "forgejo.org/tests" ap "github.com/go-ap/activitypub" @@ -31,10 +37,9 @@ func TestActivityPubActor(t *testing.T) { require.NoError(t, err) assert.Equal(t, ap.ApplicationType, actor.Type) - assert.Equal(t, setting.Domain, actor.PreferredUsername.String()) + assert.Equal(t, "ghost", actor.PreferredUsername.String()) keyID := actor.GetID().String() assert.Regexp(t, "activitypub/actor$", keyID) - assert.Regexp(t, "activitypub/actor/outbox$", actor.Outbox.GetID().String()) assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String()) pubKey := actor.PublicKey @@ -46,3 +51,27 @@ func TestActivityPubActor(t *testing.T) { assert.NotNil(t, pubKeyPem) assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) } + +func TestActorNewFromKeyId(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) { + ctx, _ := contexttest.MockAPIContext(t, "/api/v1/activitypub/actor") + sut, err := federation.NewActorIDFromKeyID(ctx.Base, fmt.Sprintf("%sapi/v1/activitypub/actor#main-key", u)) + require.NoError(t, err) + + port, err := strconv.ParseUint(u.Port(), 10, 16) + require.NoError(t, err) + + assert.Equal(t, forgefed.ActorID{ + ID: "actor", + HostSchema: "http", + Path: "api/v1/activitypub", + Host: setting.Domain, + HostPort: uint16(port), + UnvalidatedInput: fmt.Sprintf("http://%s:%d/api/v1/activitypub/actor", setting.Domain, port), + IsPortSupplemented: false, + }, sut) + }) +} diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go index c89951ecf1..ca3bc844d7 100644 --- a/tests/integration/api_activitypub_person_test.go +++ b/tests/integration/api_activitypub_person_test.go @@ -26,28 +26,37 @@ import ( func TestActivityPubPerson(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 - username := "user2" - userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID) - user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + defer federatedSrv.Close() - clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) + onGiteaRun(t, func(t *testing.T, localUrl *url.URL) { + defer test.MockVariableValue(&setting.AppURL, localUrl.String())() + + localUserID := 2 + localUserName := "user2" + localUserURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", localUrl, localUserID) + + // distantURL := federatedSrv.URL + // distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL) + + cf, err := activitypub.GetClientFactory(db.DefaultContext) require.NoError(t, err) - apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) + c, err := cf.WithKeysDirect(db.DefaultContext, mock.Persons[0].PrivKey, + mock.Persons[0].KeyID(federatedSrv.URL)) require.NoError(t, err) // Unsigned request t.Run("UnsignedRequest", func(t *testing.T) { - req := NewRequest(t, "GET", userURL) + req := NewRequest(t, "GET", localUserURL) MakeRequest(t, req, http.StatusBadRequest) }) t.Run("SignedRequestValidation", func(t *testing.T) { // Signed request - resp, err := apClient.GetBody(userURL) + resp, err := c.GetBody(localUserURL) require.NoError(t, err) var person ap.Person @@ -55,13 +64,12 @@ func TestActivityPubPerson(t *testing.T) { 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.Equal(t, localUserName, person.PreferredUsername.String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", localUserID), person.GetID()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/inbox$", localUserID), 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.Regexp(t, fmt.Sprintf("activitypub/user-id/%d#main-key$", localUserID), person.PublicKey.ID) assert.NotNil(t, person.PublicKey.PublicKeyPem) assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", person.PublicKey.PublicKeyPem) diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go index fd19b4ce33..14ea1a4b66 100644 --- a/tests/integration/api_activitypub_repository_test.go +++ b/tests/integration/api_activitypub_repository_test.go @@ -29,15 +29,18 @@ func TestActivityPubRepository(t *testing.T) { defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + defer federatedSrv.Close() + onGiteaRun(t, func(t *testing.T, u *url.URL) { repositoryID := 2 - apServerActor := user.NewAPServerActor() - cf, err := activitypub.GetClientFactory(db.DefaultContext) require.NoError(t, err) - c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) + c, err := cf.WithKeysDirect(db.DefaultContext, mock.Persons[0].PrivKey, + mock.Persons[0].KeyID(federatedSrv.URL)) require.NoError(t, err) resp, err := c.GetBody(fmt.Sprintf("%sapi/v1/activitypub/repository-id/%d", u, repositoryID)) @@ -53,9 +56,10 @@ func TestActivityPubRepository(t *testing.T) { } func TestActivityPubMissingRepository(t *testing.T) { - defer test.MockVariableValue(&setting.Federation.Enabled, true)() - defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() repositoryID := 9999999 req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID)) @@ -72,14 +76,14 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) { defer federatedSrv.Close() onGiteaRun(t, func(t *testing.T, u *url.URL) { - 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, apServerActor, apServerActor.APActorKeyID()) + c, err := cf.WithKeysDirect(db.DefaultContext, mock.Persons[0].PrivKey, + mock.Persons[0].KeyID(federatedSrv.URL)) require.NoError(t, err) repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() @@ -148,6 +152,7 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) { func TestActivityPubRepositoryInboxInvalid(t *testing.T) { defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() onGiteaRun(t, func(t *testing.T, u *url.URL) { @@ -157,7 +162,7 @@ func TestActivityPubRepositoryInboxInvalid(t *testing.T) { cf, err := activitypub.GetClientFactory(db.DefaultContext) require.NoError(t, err) - c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) + c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.KeyID()) require.NoError(t, err) repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() diff --git a/tests/integration/api_federation_httpsig_test.go b/tests/integration/api_federation_httpsig_test.go index a7a5ae26ed..a8deaa315f 100644 --- a/tests/integration/api_federation_httpsig_test.go +++ b/tests/integration/api_federation_httpsig_test.go @@ -35,7 +35,7 @@ func TestFederationHttpSigValidation(t *testing.T) { clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) require.NoError(t, err) - apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.KeyID()) require.NoError(t, err) // Unsigned request diff --git a/tests/integration/webfinger_test.go b/tests/integration/webfinger_test.go index 078be6fa54..9708fc1627 100644 --- a/tests/integration/webfinger_test.go +++ b/tests/integration/webfinger_test.go @@ -52,6 +52,15 @@ func TestWebfinger(t *testing.T) { assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject) assert.ElementsMatch(t, []string{user.HTMLURL(), appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(user.ID)}, jrd.Aliases) + instanceReq := NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:ghost@%s", appURL.Host)) + instanceResp := MakeRequest(t, instanceReq, http.StatusOK) + assert.Equal(t, "application/jrd+json", instanceResp.Header().Get("Content-Type")) + + var instanceActor webfingerJRD + DecodeJSON(t, instanceResp, &instanceActor) + assert.Equal(t, "acct:ghost@"+appURL.Host, instanceActor.Subject) + assert.ElementsMatch(t, []string{appURL.String() + "api/v1/activitypub/actor"}, instanceActor.Aliases) + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host")) MakeRequest(t, req, http.StatusBadRequest)