Federated via graft
4.7 kB
141 lines
1package activitypub
2
3import (
4 "bytes"
5 "crypto"
6 "crypto/rand"
7 "crypto/rsa"
8 "crypto/sha256"
9 "encoding/base64"
10 "fmt"
11 "io"
12 "net/http"
13 "strings"
14 "time"
15)
16
17// signedHeaders is the fixed set of headers this package signs and
18// verifies — the subset draft-cavage HTTP Signatures needs to bind a
19// request to its actual method, path, host, date, and body, which is what
20// every mainstream ActivityPub implementation (Mastodon included) expects.
21var signedHeaders = []string{"(request-target)", "host", "date", "digest"}
22
23// SignRequest signs req per draft-cavage HTTP Signatures: (request-target),
24// host, date, and a Digest of body are covered. Sets the Digest, Date, and
25// Signature headers on req.
26func SignRequest(req *http.Request, body []byte, keyID string, priv *rsa.PrivateKey) error {
27 digest := sha256.Sum256(body)
28 req.Header.Set("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(digest[:]))
29 req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
30
31 signingString := buildSigningString(signedHeaders, req.Method, req.URL.RequestURI(), req.URL.Host, req.Header)
32 hashed := sha256.Sum256([]byte(signingString))
33 sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hashed[:])
34 if err != nil {
35 return fmt.Errorf("sign request: %w", err)
36 }
37
38 req.Header.Set("Signature", fmt.Sprintf(
39 `keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"`,
40 keyID, strings.Join(signedHeaders, " "), base64.StdEncoding.EncodeToString(sig),
41 ))
42 return nil
43}
44
45// VerifyRequest verifies an inbound request's Signature header against
46// pub, re-deriving the signing string from whichever headers the
47// signature itself claims to cover (a remote server may sign a different
48// header set than we do when sending).
49func VerifyRequest(r *http.Request, body []byte, pub *rsa.PublicKey) error {
50 params := parseSignatureHeader(r.Header.Get("Signature"))
51 if params["signature"] == "" {
52 return fmt.Errorf("missing or unparseable Signature header")
53 }
54
55 if digestHeader := r.Header.Get("Digest"); digestHeader != "" {
56 sum := sha256.Sum256(body)
57 want := "SHA-256=" + base64.StdEncoding.EncodeToString(sum[:])
58 if !strings.EqualFold(digestHeader, want) {
59 return fmt.Errorf("digest does not match body")
60 }
61 }
62
63 headers := strings.Fields(params["headers"])
64 if len(headers) == 0 {
65 headers = []string{"date"}
66 }
67 host := r.Header.Get("Host")
68 if host == "" {
69 host = r.Host
70 }
71 signingString := buildSigningString(headers, r.Method, r.URL.RequestURI(), host, r.Header)
72
73 sigBytes, err := base64.StdEncoding.DecodeString(params["signature"])
74 if err != nil {
75 return fmt.Errorf("decode signature: %w", err)
76 }
77 hashed := sha256.Sum256([]byte(signingString))
78 if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, hashed[:], sigBytes); err != nil {
79 return fmt.Errorf("signature does not verify: %w", err)
80 }
81 return nil
82}
83
84// KeyID extracts the keyId parameter from an inbound request's Signature
85// header — usually the remote actor's URI with a #main-key fragment,
86// telling us whose public key to fetch and verify against.
87func KeyID(r *http.Request) string {
88 return parseSignatureHeader(r.Header.Get("Signature"))["keyId"]
89}
90
91func buildSigningString(headers []string, method, requestURI, host string, reqHeaders http.Header) string {
92 var lines []string
93 for _, h := range headers {
94 switch strings.ToLower(h) {
95 case "(request-target)":
96 lines = append(lines, fmt.Sprintf("(request-target): %s %s", strings.ToLower(method), requestURI))
97 case "host":
98 lines = append(lines, "host: "+host)
99 default:
100 lines = append(lines, strings.ToLower(h)+": "+reqHeaders.Get(h))
101 }
102 }
103 return strings.Join(lines, "\n")
104}
105
106func parseSignatureHeader(s string) map[string]string {
107 out := map[string]string{}
108 for _, part := range strings.Split(s, ",") {
109 part = strings.TrimSpace(part)
110 kv := strings.SplitN(part, "=", 2)
111 if len(kv) != 2 {
112 continue
113 }
114 out[kv[0]] = strings.Trim(kv[1], `"`)
115 }
116 return out
117}
118
119// PostSigned sends a signed POST of an activity+json body, authenticated
120// as keyID using priv.
121func PostSigned(client *http.Client, url, keyID string, priv *rsa.PrivateKey, body []byte) error {
122 req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
123 if err != nil {
124 return fmt.Errorf("build request: %w", err)
125 }
126 req.Header.Set("Content-Type", activityContentType)
127 if err := SignRequest(req, body, keyID, priv); err != nil {
128 return err
129 }
130
131 resp, err := client.Do(req)
132 if err != nil {
133 return fmt.Errorf("post %s: %w", url, err)
134 }
135 defer resp.Body.Close()
136 if resp.StatusCode >= 300 {
137 respBody, _ := io.ReadAll(resp.Body)
138 return fmt.Errorf("post %s: HTTP %d: %s", url, resp.StatusCode, string(respBody))
139 }
140 return nil
141}