linstrom/web/shared/clientRfc9421.go

299 lines
8.2 KiB
Go

package webshared
import (
"bytes"
"cmp"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"github.com/yaronf/httpsign"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
)
/*
Links for home:
- https://pkg.go.dev/github.com/yaronf/httpsign#Client.Do
- https://www.ietf.org/archive/id/draft-richanna-http-message-signatures-00.html
- https://github.com/mastodon/mastodon/issues/29905
- https://github.com/fedify-dev/fedify/issues/208
- https://github.com/mastodon/mastodon/issues/21429
- https://github.com/go-ap/fedbox/blob/master/httpsig.go
- https://swicg.github.io/activitypub-http-signature/
- https://datatracker.ietf.org/doc/html/rfc9421
*/
// Perform a signed request. Tries RFC9421 first and on fail cavage.
// This double-knocking is because implementations currently use cavage (a precursor to RFC9421).
// However, Cavage is deprecated now and the RFC should be used instead. And so
// implementations have slowly begun to implement the RFC in addition to cavage
//
// Returns the unmodified response, if the request completed with RFC signing and an error, if any
func RequestSigned(
method, target string,
body []byte,
actor *models.User,
) (response *http.Response, wasRfc9421 bool, err error) {
if method == "POST" {
storedRequestId, err := prePostRequest(target, body, actor)
if err != nil {
return nil, false, err
}
defer postPostRequest(response, err, *storedRequestId)
}
wasRfc9421 = true
response, err = RequestSignedRFC9421(method, target, body, actor)
if err == nil && response.StatusCode < 400 {
return
}
wasRfc9421 = false
log.Debug().Str("target", target).Msg("RFC9421 signed request failed, trying cavage signed")
response, err = RequestSignedCavage(method, target, body, actor)
return
}
// Perform a request, signing it as specified in RFC 9421
func RequestSignedRFC9421(
method, target string,
body []byte,
actor *models.User,
) (*http.Response, error) {
req, err := http.NewRequest(method, target, bytes.NewBuffer(slices.Clone(body)))
if err != nil {
return nil, err
}
applyDefaultHeaders(req)
req.Header.Add("Accept", "application/activity+json")
req.Header.Add("Content-Type", "application/activity+json")
var signer *httpsign.Signer
signerFields := httpsign.Headers("@request-target", "content-digest")
if config.GlobalConfig.Experimental.UseEd25519Keys {
signer, err = httpsign.NewEd25519Signer(
actor.PrivateKeyEd,
httpsign.NewSignConfig(),
signerFields,
)
if err != nil {
return nil, err
}
} else {
key, err := x509.ParsePKCS1PrivateKey(actor.PrivateKeyRsa)
if err != nil {
return nil, err
}
signer, err = httpsign.NewRSASigner(*key, httpsign.NewSignConfig(), signerFields)
if err != nil {
return nil, err
}
}
clientConfig := httpsign.NewClientConfig().SetSigner(signer)
if config.GlobalConfig.Experimental.UseEd25519Keys {
clientConfig = clientConfig.SetSignatureName("sig-ed")
} else {
clientConfig = clientConfig.SetSignatureName("sig-rsa")
}
client := httpsign.NewClient(
RequestClient,
clientConfig,
)
res, err := client.Do(req)
return res, err
}
// Perform a request, signing it as specified in the cavage proposal
// (https://swicg.github.io/activitypub-http-signature/#how-to-sign-a-request)
func RequestSignedCavage(
method, target string,
body []byte,
actor *models.User,
) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := NewRequest(method, target, bodyReader)
if err != nil {
return nil, err
}
req.Header.Add("Accept", "application/activity+json")
req.Header.Add("Content-Type", "application/activity+json")
var keyBytes []byte
if config.GlobalConfig.Experimental.UseEd25519Keys {
keyBytes = actor.PrivateKeyEd
} else {
keyBytes = actor.PrivateKeyRsa
}
// Sign and send
err = SignRequest(
req,
actor.ID+"#main-key",
keyBytes,
body,
)
// err = webshared.SignRequestWithHttpsig(req, linstromActor.ID+"#main-key", keyBytes, nil)
if err != nil {
return nil, err
}
return RequestClient.Do(req)
}
func applyDefaultHeaders(r *http.Request) {
r.Header.Add(
"User-Agent",
"Linstrom "+shared.Version+" ("+config.GlobalConfig.General.GetFullDomain()+")",
)
r.Header.Add("Date", time.Now().UTC().Format(http.TimeFormat))
r.Header.Add("Accept", "application/activity+json")
}
func applyBodyHash(headers http.Header, body []byte) error {
if len(body) == 0 {
return nil
}
hash := sha256.Sum256(body)
header := "SHA-256=" + base64.StdEncoding.EncodeToString(hash[:])
headers.Set("Digest", header)
return nil
}
// Runs before a signed outbound request.
// If the request is POST, stores it in the db as not processed yet.
// This is to ensure data consistency
func prePostRequest(
target string,
body []byte,
actor *models.User,
) (*uint64, error) {
targetUrl, err := url.Parse(target)
if err != nil {
return nil, err
}
server, err := dbgen.RemoteServer.Where(dbgen.RemoteServer.Domain.Eq(targetUrl.Hostname())).
First()
if err != nil {
return nil, err
}
fr := dbgen.FailedOutboundRequest
now := time.Now()
data := models.FailedOutboundRequest{
TargetServer: server,
TargetServerId: server.ID,
ActingUserId: actor.ID,
ActingUser: actor,
NrOfAttempts: 1,
RawData: body,
FirstAttempt: now,
LastAttempt: now,
LastFailureReason: string(models.RequestFailureNotAttemptedYet),
}
err = fr.Create(&data)
if err != nil {
return nil, err
}
return &data.Id, nil
}
// Updates the db request based on the results of the request
func postPostRequest(resp *http.Response, reqErr error, dbId uint64) {
fr := dbgen.FailedOutboundRequest
failureReason := "generic"
update := true // Flag to tell defer func to not update since request info has been deleted
defer func() {
if !update {
return
}
_, err := fr.Where(fr.Id.Eq(dbId)).UpdateColumn(fr.LastFailureReason, failureReason)
if err != nil {
log.Error().
Err(err).
Str("reason", failureReason).
Uint64("request-id", dbId).
Msg("Failed to update failure reason")
}
}()
if reqErr != nil {
failureReason = string(models.RequestFailureRequestError)
return
}
// Only check response data after handling response error
// Response could be nil otherwise, causing a panic (or an extra, useless check)
// If response status is ok (< 400) delete entry in db to not process it again
if resp.StatusCode < 400 {
update = false
fr.Where(fr.Id.Eq(dbId)).Delete()
return
}
if resp.StatusCode == 429 {
// Always prefer the rate limit headers as defined by https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html
limit := cmp.Or(
resp.Header.Get("RateLimit-Limit"),
resp.Header.Get("X-RateLimit-Limit"),
resp.Header.Get("X-Rate-Limit-Limit"),
)
remaining := cmp.Or(
resp.Header.Get("RateLimit-Remaining"),
resp.Header.Get("X-RateLimit-Remaining"),
resp.Header.Get("X-Rate-Limit-Remaining"),
)
reset := cmp.Or(
resp.Header.Get("RateLimit-Reset"),
resp.Header.Get("X-RateLimit-Reset"),
resp.Header.Get("X-Rate-Limit-Reset"),
)
if cmp.Or(limit, remaining, reset) == "" {
failureReason = string(models.RequestFailureRateLimitedNoInfo)
return
} else {
limit = strings.Split(limit, ",")[0]
limit = strings.Split(limit, ";")[0]
if limit == "" {
limit = "-1"
}
limitNum, err := strconv.Atoi(limit)
if err != nil {
failureReason = string(models.RequestFailureRateLimitedNoInfo)
return
}
if remaining == "" {
remaining = "-1"
}
remainingNum, err := strconv.Atoi(remaining)
if err != nil {
failureReason = string(models.RequestFailureRateLimitedNoInfo)
return
}
if reset == "" {
reset = "-1"
}
resetNum, err := strconv.Atoi(reset)
if err != nil {
failureReason = string(models.RequestFailureRateLimitedNoInfo)
return
}
failureReason = fmt.Sprintf(string(models.RequestFailureRateLimitTemplate), limitNum, remainingNum, resetNum)
}
return
} else {
if resp.StatusCode >= 500 {
failureReason = string(models.RequestFailureInternalError)
} else {
failureReason = string(models.RequestFailureRejected)
}
}
}