299 lines
8.2 KiB
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(
|
|
method, 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)
|
|
}
|
|
}
|
|
}
|