Improved signature handling & instance actor (#8275)
Some checks are pending
/ release (push) Waiting to run
testing-integration / test-unit (push) Waiting to run
testing-integration / test-sqlite (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions

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 <earl-warren@noreply.codeberg.org>
Co-authored-by: Michael Jerger <michael.jerger@meissa-gmbh.de>
Co-committed-by: Michael Jerger <michael.jerger@meissa-gmbh.de>
This commit is contained in:
Michael Jerger 2025-07-01 19:49:00 +02:00 committed by Earl Warren
parent 7a8ff20bf3
commit 6f501b1fdf
20 changed files with 726 additions and 443 deletions

View file

@ -34,9 +34,6 @@ forgejo.org/models/dbfs
Create Create
Rename Rename
forgejo.org/models/forgefed
GetFederationHost
forgejo.org/models/forgejo/semver forgejo.org/models/forgejo/semver
GetVersion GetVersion
SetVersionString SetVersionString
@ -68,7 +65,6 @@ forgejo.org/models/user
DeleteUserSetting DeleteUserSetting
GetFederatedUser GetFederatedUser
GetFederatedUserByUserID GetFederatedUserByUserID
UpdateFederatedUser
GetFollowersForUser GetFollowersForUser
AddFollower AddFollower
RemoveFollower RemoveFollower
@ -249,6 +245,9 @@ forgejo.org/routers/web/org
forgejo.org/services/context forgejo.org/services/context
GetPrivateContext GetPrivateContext
forgejo.org/services/federation
Init
forgejo.org/services/repository forgejo.org/services/repository
IsErrForkAlreadyExist IsErrForkAlreadyExist

View file

@ -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))) 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 // KeyID returns the ID of the user's public key
func (u *User) APActorKeyID() string { func (u *User) KeyID() string {
return u.APActorID() + "#main-key" return u.APActorID() + "#main-key"
} }

View file

@ -57,14 +57,6 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat
return committer.Commit() 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) { func FindFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) {
federatedUser := new(FederatedUser) federatedUser := new(FederatedUser)
user := new(User) user := new(User)
@ -219,7 +211,6 @@ func RemoveFollower(ctx context.Context, followedUser *User, followingUser *Fede
return err 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) { func IsFollowingAp(ctx context.Context, followedUser *User, followingUser *FederatedUser) (bool, error) {
if res, err := validation.IsValid(followedUser); !res { if res, err := validation.IsValid(followedUser); !res {
return false, err return false, err

View file

@ -150,7 +150,7 @@ func TestAPActorID_APActorID(t *testing.T) {
func TestKeyID(t *testing.T) { func TestKeyID(t *testing.T) {
user := user_model.User{ID: 1} 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" expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key"
assert.Equal(t, expected, url) assert.Equal(t, expected, url)
} }

View file

@ -89,6 +89,7 @@ func NewClientFactory() (c *ClientFactory, err error) {
type APClientFactory interface { type APClientFactory interface {
WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error)
WithKeysDirect(ctx context.Context, privateKey, pubID string) (APClient, error)
} }
// Client struct // Client struct
@ -103,12 +104,8 @@ type Client struct {
} }
// NewRequest function // NewRequest function
func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) { func (cf *ClientFactory) WithKeysDirect(ctx context.Context, privateKey, pubID string) (APClient, error) {
priv, err := GetPrivateKey(ctx, user) privPem, _ := pem.Decode([]byte(privateKey))
if err != nil {
return nil, err
}
privPem, _ := pem.Decode([]byte(priv))
privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
if err != nil { if err != nil {
return nil, err return nil, err
@ -126,6 +123,14 @@ func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pu
return &c, nil 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 // NewRequest function
func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) { func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) {
buf := bytes.NewBuffer(b) buf := bytes.NewBuffer(b)
@ -149,6 +154,7 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
return nil, err return nil, err
} }
if c.pubID != "" {
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
if err != nil { if err != nil {
return nil, err return nil, err
@ -156,6 +162,7 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil { if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil {
return nil, err return nil, err
} }
}
resp, err = c.client.Do(req) resp, err = c.client.Do(req)
return resp, err return resp, err
@ -167,6 +174,8 @@ func (c *Client) Get(to string) (resp *http.Response, err error) {
if req, err = c.newRequest(http.MethodGet, nil, to); err != nil { if req, err = c.newRequest(http.MethodGet, nil, to); err != nil {
return nil, err return nil, err
} }
if c.pubID != "" {
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime) signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime)
if err != nil { if err != nil {
return nil, err return nil, err
@ -174,6 +183,7 @@ func (c *Client) Get(to string) (resp *http.Response, err error) {
if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil { if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil {
return nil, err return nil, err
} }
}
resp, err = c.client.Do(req) resp, err = c.client.Do(req)
return resp, err return resp, err

View file

@ -1,4 +1,5 @@
// Copyright 2022 The Gitea Authors. All rights reserved. // Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package structs package structs
@ -7,3 +8,15 @@ package structs
type ActivityPub struct { type ActivityPub struct {
Context string `json:"@context"` 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"`
}

View file

@ -10,56 +10,79 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"forgejo.org/modules/util"
) )
type FederationServerMockPerson struct { type FederationServerMockPerson struct {
ID int64 ID int64
Name string Name string
PubKey string PubKey string
PrivKey string
} }
type FederationServerMockRepository struct { type FederationServerMockRepository struct {
ID int64 ID int64
} }
type ApActorMock struct {
PrivKey string
PubKey string
}
type FederationServerMock struct { type FederationServerMock struct {
ApActor ApActorMock
Persons []FederationServerMockPerson Persons []FederationServerMockPerson
Repositories []FederationServerMockRepository Repositories []FederationServerMockRepository
LastPost string LastPost string
} }
func NewFederationServerMockPerson(id int64, name string) FederationServerMockPerson { func NewFederationServerMockPerson(id int64, name string) FederationServerMockPerson {
priv, pub, _ := util.GenerateKeyPair(3072)
return FederationServerMockPerson{ return FederationServerMockPerson{
ID: id, ID: id,
Name: name, Name: name,
PubKey: `"-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA18H5s7N6ItZUAh9tneII\nIuZdTTa3cZlLa/9ejWAHTkcp3WLW+/zbsumlMrWYfBy2/yTm56qasWt38iY4D6ul\n` + PubKey: pub,
`CPiwhAqX3REvVq8tM79a2CEqZn9ka6vuXoDgBg/sBf/BUWqf7orkjUXwk/U0Egjf\nk5jcurF4vqf1u+rlAHH37dvSBaDjNj6Qnj4OP12bjfaY/yvs7+jue/eNXFHjzN4E\n` + PrivKey: priv,
`T2H4B/yeKTJ4UuAwTlLaNbZJul2baLlHelJPAsxiYaziVuV5P+IGWckY6RSerRaZ\nAkc4mmGGtjAyfN9aewe+lNVfwS7ElFx546PlLgdQgjmeSwLX8FWxbPE5A/PmaXCs\n` +
`nx+nou+3dD7NluULLtdd7K+2x02trObKXCAzmi5/Dc+yKTzpFqEz+hLNCz7TImP/\ncK//NV9Q+X67J9O27baH9R9ZF4zMw8rv2Pg0WLSw1z7lLXwlgIsDapeMCsrxkVO4\n` +
`LXX5AQ1xQNtlssnVoUBqBrvZsX2jUUKUocvZqMGuE4hfAgMBAAE=\n-----END PUBLIC KEY-----\n"`,
} }
} }
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 { func NewFederationServerMockRepository(id int64) FederationServerMockRepository {
return FederationServerMockRepository{ return FederationServerMockRepository{
ID: id, 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 { func (p FederationServerMockPerson) marshal(host string) string {
return fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],`+ 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",`+ `"type":"Person",`+
`"icon":{"type":"Image","mediaType":"image/png","url":"http://%[1]v/avatars/1bb05d9a5f6675ed0272af9ea193063c"},`+ `"icon":{"type":"Image","mediaType":"image/png","url":"http://%[1]v/avatars/1bb05d9a5f6675ed0272af9ea193063c"},`+
`"url":"http://%[1]v/%[2]v",`+ `"url":"http://%[1]v/%[2]v",`+
`"inbox":"http://%[1]v/api/activitypub/user-id/%[2]v/inbox",`+ `"inbox":"http://%[1]v/api/v1/activitypub/user-id/%[2]v/inbox",`+
`"outbox":"http://%[1]v/api/activitypub/user-id/%[2]v/outbox",`+ `"outbox":"http://%[1]v/api/v1/activitypub/user-id/%[2]v/outbox",`+
`"preferredUsername":"%[3]v",`+ `"preferredUsername":"%[3]v",`+
`"publicKey":{"id":"http://%[1]v/api/activitypub/user-id/%[2]v#main-key",`+ `"publicKey":{"id":"http://%[1]v/api/v1/activitypub/user-id/%[2]v#main-key",`+
`"owner":"http://%[1]v/api/activitypub/user-id/%[2]v",`+ `"owner":"http://%[1]v/api/v1/activitypub/user-id/%[2]v",`+
`"publicKeyPem":%[4]v}}`, host, p.ID, p.Name, p.PubKey) `"publicKeyPem":%[4]q}}`, host, p.ID, p.Name, p.PubKey)
} }
func NewFederationServerMock() *FederationServerMock { func NewFederationServerMock() *FederationServerMock {
return &FederationServerMock{ return &FederationServerMock{
ApActor: NewApActorMock(),
Persons: []FederationServerMockPerson{ Persons: []FederationServerMockPerson{
NewFederationServerMockPerson(15, "stargoose1"), NewFederationServerMockPerson(15, "stargoose1"),
NewFederationServerMockPerson(30, "stargoose2"), 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 { func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
federatedRoutes := http.NewServeMux() federatedRoutes := http.NewServeMux()
federatedRoutes.HandleFunc("/.well-known/nodeinfo", federatedRoutes.HandleFunc("/.well-known/nodeinfo",
func(res http.ResponseWriter, req *http.Request) { func(res http.ResponseWriter, req *http.Request) {
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo // 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"]},`+ `"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},`+
`"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`) `"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`)
}) })
for _, person := range mock.Persons { for _, person := range mock.Persons {
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/%v", person.ID), federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/%v", person.ID),
func(res http.ResponseWriter, req *http.Request) { func(res http.ResponseWriter, req *http.Request) {
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2 // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2
fmt.Fprint(res, person.marshal(req.Host)) fmt.Fprint(res, person.marshal(req.Host))
}) })
} federatedRoutes.HandleFunc(fmt.Sprintf("POST /api/v1/activitypub/user-id/%v/inbox", person.ID),
for _, repository := range mock.Repositories {
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repository.ID),
func(res http.ResponseWriter, req *http.Request) { func(res http.ResponseWriter, req *http.Request) {
if req.Method != "POST" { mock.recordLastPost(t, req)
t.Errorf("POST expected at: %q", req.URL.EscapedPath()) })
} }
buf := new(strings.Builder)
_, err := io.Copy(buf, req.Body) for _, repository := range mock.Repositories {
if err != nil { federatedRoutes.HandleFunc(fmt.Sprintf("POST /api/v1/activitypub/repository-id/%v/inbox", repository.ID),
t.Errorf("Error reading body: %q", err) func(res http.ResponseWriter, req *http.Request) {
} mock.recordLastPost(t, req)
mock.LastPost = buf.String()
}) })
} }
federatedRoutes.HandleFunc("/", federatedRoutes.HandleFunc("/",
func(res http.ResponseWriter, req *http.Request) { 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) federatedSrv := httptest.NewServer(federatedRoutes)
return federatedSrv return federatedSrv

View file

@ -32,7 +32,7 @@ func Actor(ctx *context.APIContext) {
actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType)
actor.PreferredUsername = ap.NaturalLanguageValuesNew() actor.PreferredUsername = ap.NaturalLanguageValuesNew()
err := actor.PreferredUsername.Set("en", ap.Content(setting.Domain)) err := actor.PreferredUsername.Set("en", ap.Content("ghost"))
if err != nil { if err != nil {
ctx.ServerError("PreferredUsername.Set", err) ctx.ServerError("PreferredUsername.Set", err)
return return
@ -41,8 +41,6 @@ func Actor(ctx *context.APIContext) {
actor.URL = ap.IRI(setting.AppURL) actor.URL = ap.IRI(setting.AppURL)
actor.Inbox = ap.IRI(link + "/inbox") actor.Inbox = ap.IRI(link + "/inbox")
actor.Outbox = ap.IRI(link + "/outbox")
actor.PublicKey.ID = ap.IRI(link + "#main-key") actor.PublicKey.ID = ap.IRI(link + "#main-key")
actor.PublicKey.Owner = ap.IRI(link) actor.PublicKey.Owner = ap.IRI(link)

View file

@ -4,132 +4,17 @@
package activitypub package activitypub
import ( import (
"crypto"
"crypto/x509"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"net/http" "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/log"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
gitea_context "forgejo.org/services/context" gitea_context "forgejo.org/services/context"
"forgejo.org/services/federation" "forgejo.org/services/federation"
"github.com/42wim/httpsig" "github.com/42wim/httpsig"
ap "github.com/go-ap/activitypub"
) )
func decodePublicKeyPem(pubKeyPem string) ([]byte, error) { func verifyHTTPUserOrInstanceSignature(ctx *gitea_context.APIContext) (authenticated bool, err 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) {
if !setting.Federation.SignatureEnforced { if !setting.Federation.SignatureEnforced {
return true, nil return true, nil
} }
@ -142,84 +27,64 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er
return false, err return false, err
} }
ID := v.KeyId() signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0])
idIRI, err := url.Parse(ID) 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 { if err != nil {
return false, err return false, err
} }
signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0]) signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0])
pubKey, err := federation.FindOrCreateFederatedUserKey(ctx.Base, v.KeyId())
// 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())
if err != nil { if err != nil {
return false, err return false, err
} }
if federationUser != nil && federationUser.PublicKey.Valid { err = v.Verify(pubKey, signatureAlgorithm)
pubKey, err := x509.ParsePKIXPublicKey(federationUser.PublicKey.V)
if err != nil { if err != nil {
return false, err return false, err
} }
return true, nil
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
}
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
} }
// ReqHTTPSignature function // ReqHTTPSignature function
func ReqHTTPSignature() func(ctx *gitea_context.APIContext) { func ReqHTTPUserOrInstanceSignature() func(ctx *gitea_context.APIContext) {
return 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) log.Warn("verifyHttpSignatures failed: %v", err)
ctx.Error(http.StatusBadRequest, "reqSignature", "request signature verification failed") ctx.Error(http.StatusBadRequest, "reqSignature", "request signature verification failed")
} else if !authenticated { } else if !authenticated {

View file

@ -92,6 +92,7 @@ import (
_ "forgejo.org/routers/api/v1/swagger" // for swagger generation _ "forgejo.org/routers/api/v1/swagger" // for swagger generation
"code.forgejo.org/go-chi/binding" "code.forgejo.org/go-chi/binding"
ap "github.com/go-ap/activitypub"
) )
func sudo() func(ctx *context.APIContext) { func sudo() func(ctx *context.APIContext) {
@ -826,24 +827,22 @@ func Routes() *web.Route {
if setting.Federation.Enabled { if setting.Federation.Enabled {
m.Get("/nodeinfo", misc.NodeInfo) m.Get("/nodeinfo", misc.NodeInfo)
m.Group("/activitypub", func() { 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.Group("/user-id/{user-id}", func() {
m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person) m.Get("", activitypub.ReqHTTPUserOrInstanceSignature(), activitypub.Person)
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) m.Post("/inbox",
activitypub.ReqHTTPUserSignature(),
bind(ap.Activity{}),
activitypub.PersonInbox)
}, context.UserIDAssignmentAPI(), checkTokenPublicOnly()) }, context.UserIDAssignmentAPI(), checkTokenPublicOnly())
m.Group("/actor", func() { m.Group("/actor", func() {
m.Get("", activitypub.Actor) 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.Group("/repository-id/{repository-id}", func() {
m.Get("", activitypub.ReqHTTPSignature(), activitypub.Repository) m.Get("", activitypub.ReqHTTPUserSignature(), activitypub.Repository)
m.Post("/inbox", m.Post("/inbox",
bind(forgefed.ForgeLike{}), bind(forgefed.ForgeLike{}),
activitypub.ReqHTTPSignature(), activitypub.ReqHTTPUserSignature(),
activitypub.RepositoryInbox) activitypub.RepositoryInbox)
}, context.RepositoryIDAssignmentAPI()) }, context.RepositoryIDAssignmentAPI())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub))

View file

@ -58,7 +58,33 @@ func WebfingerQuery(ctx *context.Context) {
return 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]) u, err = user_model.GetUserByName(ctx, parts[0])
case "mailto": case "mailto":
u, err = user_model.GetUserByEmail(ctx, resource.Opaque) u, err = user_model.GetUserByEmail(ctx, resource.Opaque)
if u != nil && u.KeepEmailPrivate { if u != nil && u.KeepEmailPrivate {

View file

@ -5,15 +5,12 @@ package federation
import ( import (
"context" "context"
"errors" "database/sql"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"forgejo.org/models/forgefed" "forgejo.org/models/forgefed"
"forgejo.org/models/repo"
"forgejo.org/models/user" "forgejo.org/models/user"
"forgejo.org/modules/activitypub" "forgejo.org/modules/activitypub"
"forgejo.org/modules/auth/password" "forgejo.org/modules/auth/password"
@ -21,91 +18,84 @@ import (
"forgejo.org/modules/log" "forgejo.org/modules/log"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
"forgejo.org/modules/validation" "forgejo.org/modules/validation"
context_service "forgejo.org/services/context"
"github.com/google/uuid" "github.com/google/uuid"
) )
// ProcessLikeActivity receives a ForgeLike activity and does the following: func Init() error {
// Validation of the activity return nil
// 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)
// parse actorID (person) func FindOrCreateFederationHost(ctx *context_service.Base, actorURI string) (*forgefed.FederationHost, error) {
actorURI := activity.Actor.GetID().String() rawActorID, err := fm.NewActorID(actorURI)
log.Info("actorURI was: %v", actorURI)
federationHost, err := GetFederationHostForURI(ctx, actorURI)
if err != nil { if err != nil {
return http.StatusInternalServerError, "Wrong FederationHost", err return nil, err
} }
if !activity.IsNewer(federationHost.LatestActivity) { federationHost, err := forgefed.FindFederationHostByFqdnAndPort(ctx, rawActorID.Host, rawActorID.HostPort)
return http.StatusNotAcceptable, "Activity out of order.", errors.New("Activity already processed") 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)) actorID, err := fm.NewPersonID(actorURI, string(federationHost.NodeInfo.SoftwareName))
if err != nil { 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))
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 0, "", nil user, federatedUser, err := user.FindFederatedUser(ctx, actorID.ID, federationHost.ID)
if err != nil {
return nil, nil, nil, err
} }
func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) { return user, federatedUser, federationHost, nil
}
func createFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) {
actionsUser := user.NewAPServerActor() actionsUser := user.NewAPServerActor()
clientFactory, err := activitypub.GetClientFactory(ctx) clientFactory, err := activitypub.GetClientFactory(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -130,6 +120,7 @@ func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge
return nil, err 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) result, err := forgefed.NewFederationHost(actorID.Host, nodeInfo, actorID.HostPort, actorID.HostSchema)
if err != nil { if err != nil {
return nil, err return nil, err
@ -143,34 +134,14 @@ func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge
return &result, nil return &result, nil
} }
func GetFederationHostForURI(ctx context.Context, actorURI string) (*forgefed.FederationHost, error) { func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, 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) {
actionsUser := user.NewAPServerActor() actionsUser := user.NewAPServerActor()
clientFactory, err := activitypub.GetClientFactory(ctx) clientFactory, err := activitypub.GetClientFactory(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.KeyID())
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -216,6 +187,11 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
return nil, nil, err return nil, nil, err
} }
pubKeyBytes, err := decodePublicKeyPem(person.PublicKey.PublicKeyPem)
if err != nil {
return nil, nil, err
}
newUser := user.User{ newUser := user.User{
LowerName: strings.ToLower(name), LowerName: strings.ToLower(name),
Name: name, Name: name,
@ -234,86 +210,30 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
FederationHostID: federationHostID, FederationHostID: federationHostID,
InboxPath: inbox.Path, InboxPath: inbox.Path,
NormalizedOriginalURL: personID.AsURI(), 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
log.Info("Created federatedUser:%q", federatedUser) log.Info("Created federatedUser:%q", federatedUser)
return &newUser, &federatedUser, nil 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
} }

View file

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

View file

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

View file

@ -29,7 +29,7 @@ func TestActivityPubClientBodySize(t *testing.T) {
clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) clientFactory, err := activitypub.GetClientFactory(db.DefaultContext)
require.NoError(t, err) 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) require.NoError(t, err)
url := u.JoinPath("/api/v1/nodeinfo").String() url := u.JoinPath("/api/v1/nodeinfo").String()

View file

@ -4,12 +4,18 @@
package integration package integration
import ( import (
"fmt"
"net/http" "net/http"
"net/url"
"strconv"
"testing" "testing"
"forgejo.org/modules/forgefed"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
"forgejo.org/modules/test" "forgejo.org/modules/test"
"forgejo.org/routers" "forgejo.org/routers"
"forgejo.org/services/contexttest"
"forgejo.org/services/federation"
"forgejo.org/tests" "forgejo.org/tests"
ap "github.com/go-ap/activitypub" ap "github.com/go-ap/activitypub"
@ -31,10 +37,9 @@ func TestActivityPubActor(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, ap.ApplicationType, actor.Type) 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() keyID := actor.GetID().String()
assert.Regexp(t, "activitypub/actor$", keyID) 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()) assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String())
pubKey := actor.PublicKey pubKey := actor.PublicKey
@ -46,3 +51,27 @@ func TestActivityPubActor(t *testing.T) {
assert.NotNil(t, pubKeyPem) assert.NotNil(t, pubKeyPem)
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", 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)
})
}

View file

@ -26,28 +26,37 @@ import (
func TestActivityPubPerson(t *testing.T) { func TestActivityPubPerson(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 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) 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) require.NoError(t, err)
// Unsigned request // Unsigned request
t.Run("UnsignedRequest", func(t *testing.T) { t.Run("UnsignedRequest", func(t *testing.T) {
req := NewRequest(t, "GET", userURL) req := NewRequest(t, "GET", localUserURL)
MakeRequest(t, req, http.StatusBadRequest) MakeRequest(t, req, http.StatusBadRequest)
}) })
t.Run("SignedRequestValidation", func(t *testing.T) { t.Run("SignedRequestValidation", func(t *testing.T) {
// Signed request // Signed request
resp, err := apClient.GetBody(userURL) resp, err := c.GetBody(localUserURL)
require.NoError(t, err) require.NoError(t, err)
var person ap.Person var person ap.Person
@ -55,13 +64,12 @@ func TestActivityPubPerson(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, ap.PersonType, person.Type) assert.Equal(t, ap.PersonType, person.Type)
assert.Equal(t, username, person.PreferredUsername.String()) assert.Equal(t, localUserName, person.PreferredUsername.String())
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", userID), person.GetID()) assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", localUserID), 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$", localUserID), person.Inbox.GetID().String())
assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/inbox$", userID), person.Inbox.GetID().String())
assert.NotNil(t, person.PublicKey) 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.NotNil(t, person.PublicKey.PublicKeyPem)
assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", person.PublicKey.PublicKeyPem) assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", person.PublicKey.PublicKeyPem)

View file

@ -29,15 +29,18 @@ func TestActivityPubRepository(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 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) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
repositoryID := 2 repositoryID := 2
apServerActor := user.NewAPServerActor()
cf, err := activitypub.GetClientFactory(db.DefaultContext) cf, err := activitypub.GetClientFactory(db.DefaultContext)
require.NoError(t, err) 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) require.NoError(t, err)
resp, err := c.GetBody(fmt.Sprintf("%sapi/v1/activitypub/repository-id/%d", u, repositoryID)) 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) { func TestActivityPubMissingRepository(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
defer tests.PrepareTestEnv(t)() 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 repositoryID := 9999999
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID)) 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() defer federatedSrv.Close()
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
apServerActor := user.NewAPServerActor()
repositoryID := 2 repositoryID := 2
timeNow := time.Now().UTC() timeNow := time.Now().UTC()
cf, err := activitypub.GetClientFactory(db.DefaultContext) cf, err := activitypub.GetClientFactory(db.DefaultContext)
require.NoError(t, err) 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) require.NoError(t, err)
repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() 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) { func TestActivityPubRepositoryInboxInvalid(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)() defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
@ -157,7 +162,7 @@ func TestActivityPubRepositoryInboxInvalid(t *testing.T) {
cf, err := activitypub.GetClientFactory(db.DefaultContext) cf, err := activitypub.GetClientFactory(db.DefaultContext)
require.NoError(t, err) 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) require.NoError(t, err)
repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String()

View file

@ -35,7 +35,7 @@ func TestFederationHttpSigValidation(t *testing.T) {
clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) clientFactory, err := activitypub.GetClientFactory(db.DefaultContext)
require.NoError(t, err) 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) require.NoError(t, err)
// Unsigned request // Unsigned request

View file

@ -52,6 +52,15 @@ func TestWebfinger(t *testing.T) {
assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject) 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) 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")) req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host"))
MakeRequest(t, req, http.StatusBadRequest) MakeRequest(t, req, http.StatusBadRequest)