Federated via graft
5.8 kB
171 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// maxClockSkew bounds how far a signed request's Date header may drift
46// from this server's clock before it's rejected — without this, a
47// captured valid signed request (Follow, reply, ...) could be replayed
48// indefinitely.
49const maxClockSkew = 5 * time.Minute
50
51// VerifyRequest verifies an inbound request's Signature header against
52// pub, re-deriving the signing string from whichever headers the
53// signature itself claims to cover (a remote server may sign a different
54// header set than we do when sending).
55func VerifyRequest(r *http.Request, body []byte, pub *rsa.PublicKey) error {
56 params := parseSignatureHeader(r.Header.Get("Signature"))
57 if params["signature"] == "" {
58 return fmt.Errorf("missing or unparseable Signature header")
59 }
60
61 if dateHeader := r.Header.Get("Date"); dateHeader != "" {
62 signedAt, err := http.ParseTime(dateHeader)
63 if err != nil {
64 return fmt.Errorf("unparseable Date header: %w", err)
65 }
66 if skew := time.Since(signedAt); skew > maxClockSkew || skew < -maxClockSkew {
67 return fmt.Errorf("Date header too far from current time (skew %s), possible replay", skew)
68 }
69 } else {
70 return fmt.Errorf("missing Date header")
71 }
72
73 if digestHeader := r.Header.Get("Digest"); digestHeader != "" {
74 sum := sha256.Sum256(body)
75 want := "SHA-256=" + base64.StdEncoding.EncodeToString(sum[:])
76 if !strings.EqualFold(digestHeader, want) {
77 return fmt.Errorf("digest does not match body")
78 }
79 }
80
81 headers := strings.Fields(params["headers"])
82 if len(headers) == 0 {
83 headers = []string{"date"}
84 }
85 host := r.Header.Get("Host")
86 if host == "" {
87 host = r.Host
88 }
89 signingString := buildSigningString(headers, r.Method, r.URL.RequestURI(), host, r.Header)
90
91 sigBytes, err := base64.StdEncoding.DecodeString(params["signature"])
92 if err != nil {
93 return fmt.Errorf("decode signature: %w", err)
94 }
95 hashed := sha256.Sum256([]byte(signingString))
96 if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, hashed[:], sigBytes); err != nil {
97 return fmt.Errorf("signature does not verify: %w", err)
98 }
99 return nil
100}
101
102// KeyID extracts the keyId parameter from an inbound request's Signature
103// header — usually the remote actor's URI with a #main-key fragment,
104// telling us whose public key to fetch and verify against.
105func KeyID(r *http.Request) string {
106 return parseSignatureHeader(r.Header.Get("Signature"))["keyId"]
107}
108
109func buildSigningString(headers []string, method, requestURI, host string, reqHeaders http.Header) string {
110 var lines []string
111 for _, h := range headers {
112 switch strings.ToLower(h) {
113 case "(request-target)":
114 lines = append(lines, fmt.Sprintf("(request-target): %s %s", strings.ToLower(method), requestURI))
115 case "host":
116 lines = append(lines, "host: "+host)
117 default:
118 lines = append(lines, strings.ToLower(h)+": "+reqHeaders.Get(h))
119 }
120 }
121 return strings.Join(lines, "\n")
122}
123
124func parseSignatureHeader(s string) map[string]string {
125 out := map[string]string{}
126 for _, part := range strings.Split(s, ",") {
127 part = strings.TrimSpace(part)
128 kv := strings.SplitN(part, "=", 2)
129 if len(kv) != 2 {
130 continue
131 }
132 out[kv[0]] = strings.Trim(kv[1], `"`)
133 }
134 return out
135}
136
137// PostSigned sends a signed POST of an activity+json body, authenticated
138// as keyID using priv.
139func PostSigned(client *http.Client, url, keyID string, priv *rsa.PrivateKey, body []byte) error {
140 req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
141 if err != nil {
142 return fmt.Errorf("build request: %w", err)
143 }
144 req.Header.Set("Content-Type", activityContentType)
145 if err := SignRequest(req, body, keyID, priv); err != nil {
146 return err
147 }
148
149 resp, err := client.Do(req)
150 if err != nil {
151 return fmt.Errorf("post %s: %w", url, err)
152 }
153 defer resp.Body.Close()
154 if resp.StatusCode >= 300 {
155 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
156 return fmt.Errorf("post %s: HTTP %d: %s", url, resp.StatusCode, truncateForLog(respBody))
157 }
158 return nil
159}
160
161// truncateForLog caps how much of a remote error body ends up embedded in
162// an error message (and from there, in logs) — a remote server's response
163// is untrusted content, and shouldn't get an unbounded write into our own
164// logging.
165func truncateForLog(b []byte) string {
166 const max = 500
167 if len(b) <= max {
168 return string(b)
169 }
170 return string(b[:max]) + "... (truncated)"
171}