jojo/modules/test/distant_federation_server_mock.go
elle b00582aa0c
federation: add support for RFC9421 signatures
Adds support for verifying
[RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html)
HTTP message signatures.

Adds support for signing HTTP requests using the
[RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) algorithm.

Adds support for multiple RFC 9421 signers.

Only attempts to sign RSA v1.5 signatures, since they are the only
signatures supported by Mastodon.

However, this commit lays the groundwork for future support of
additional signature algorithms.

Adds a basic integration test case for RFC 9421 signing + verification.

Adds unit test cases for `ClientKey` RSA keys.

Adds unit tests for `ClientKey` ECDSA + Ed25519 key methods.

Adds more unit test cases for RSA key methods.

Adds basic unit tests for the `setting.Algorithm` enum type.
2026-05-12 15:52:28 +00:00

210 lines
6.6 KiB
Go

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package test
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"forgejo.org/modules/util"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"github.com/google/uuid"
)
type ApActorMock struct {
PrivKey string
PubKey string
}
type FederationServerMockPerson struct {
ID int64
Name string
PubKey string
PrivKey string
}
type FederationServerMockRepository struct {
ID int64
}
type FederationServerMock struct {
ApActor ApActorMock
Persons []FederationServerMockPerson
Repositories []FederationServerMockRepository
LastPost string
}
func NewApActorMock() ApActorMock {
priv, pub, _ := util.GenerateKeyPair(1024)
return ApActorMock{
PrivKey: priv,
PubKey: pub,
}
}
func (u *ApActorMock) KeyID(host string) string {
return fmt.Sprintf("%s/api/v1/activitypub/actor#main-key", host)
}
func (u *ApActorMock) marshal(host string) string {
baseID := fmt.Sprintf("http://%s/api/v1/activitypub/actor", host)
return fmt.Sprintf(
`{ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],`+
`"id": "%[1]s",`+
`"type": "Application",`+
`"preferredUsername": "ghost",`+
`"publicKey": {`+
` "id": "%[1]s#main-key",`+
` "owner": "%[1]s",`+
` "publicKeyPem": %[2]q }}`,
baseID,
u.PubKey,
)
}
func NewFederationServerMockPerson(id int64, name string) FederationServerMockPerson {
priv, pub, _ := util.GenerateKeyPair(3072)
return FederationServerMockPerson{
ID: id,
Name: name,
PubKey: pub,
PrivKey: priv,
}
}
func (p *FederationServerMockPerson) FederationID(host string) string {
return fmt.Sprintf("%[1]s/api/v1/activitypub/user-id/%[2]d", host, p.ID)
}
func (p *FederationServerMockPerson) KeyID(host string) string {
return fmt.Sprintf("%[1]s/api/v1/activitypub/user-id/%[2]d#main-key", host, p.ID)
}
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/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/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/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 NewFederationServerMockRepository(id int64) FederationServerMockRepository {
return FederationServerMockRepository{
ID: id,
}
}
func NewFederationServerMock() *FederationServerMock {
return &FederationServerMock{
ApActor: NewApActorMock(),
Persons: []FederationServerMockPerson{
NewFederationServerMockPerson(15, "stargoose1"),
NewFederationServerMockPerson(30, "stargoose2"),
},
Repositories: []FederationServerMockRepository{
NewFederationServerMockRepository(1),
},
LastPost: "",
}
}
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) FollowActorUnsigned(host string, localID int64, uri, inboxURL url.URL) error {
apID := fmt.Sprintf("%s/api/v1/activitypub/user-id/%d", host, localID)
activity := ap.Follow{}
activity.Type = ap.FollowType
activity.ID = ap.IRI(apID + "/follows/" + uuid.New().String())
activity.Actor = ap.IRI(apID)
activity.Object = ap.IRI(uri.String())
payload, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI)).Marshal(activity)
if err != nil {
return err
}
reader := bytes.NewReader(payload)
_, err = http.Post(inboxURL.String(), "application/activity+json", reader)
return err
}
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
// TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8
fmt.Fprintf(res, `{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host)
})
federatedRoutes.HandleFunc("/api/v1/nodeinfo",
func(res http.ResponseWriter, req *http.Request) {
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo
fmt.Fprint(res, `{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",`+
`"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},`+
`"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/alias%v", person.ID),
func(res http.ResponseWriter, req *http.Request) {
fmt.Fprint(res, person.marshal(req.Host))
})
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))
})
federatedRoutes.HandleFunc(fmt.Sprintf("POST /api/v1/activitypub/user-id/%v/inbox", person.ID),
func(res http.ResponseWriter, req *http.Request) {
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("GET /api/v1/activitypub/actor",
func(res http.ResponseWriter, req *http.Request) {
fmt.Fprint(res, mock.ApActor.marshal(req.Host))
})
federatedRoutes.HandleFunc("/",
func(res http.ResponseWriter, req *http.Request) {
t.Errorf("Unhandled %v request: %q", req.Method, req.URL.EscapedPath())
})
federatedSrv := httptest.NewServer(federatedRoutes)
return federatedSrv
}