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