1 package akamai
2
3 import (
4 "bytes"
5 "crypto/hmac"
6 "crypto/md5"
7 "crypto/sha256"
8 "crypto/x509"
9 "encoding/base64"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "net/url"
16 "strings"
17 "time"
18
19 "github.com/jmhodges/clock"
20 "github.com/letsencrypt/boulder/core"
21 blog "github.com/letsencrypt/boulder/log"
22 "github.com/letsencrypt/boulder/metrics"
23 "github.com/prometheus/client_golang/prometheus"
24 "golang.org/x/crypto/ocsp"
25 )
26
27 const (
28 timestampFormat = "20060102T15:04:05-0700"
29 v3PurgePath = "/ccu/v3/delete/url/"
30 v3PurgeTagPath = "/ccu/v3/delete/tag/"
31 )
32
33 var (
34
35
36 ErrAllRetriesFailed = errors.New("all attempts to submit purge request failed")
37
38
39
40
41 errFatal = errors.New("fatal error")
42 )
43
44 type v3PurgeRequest struct {
45 Objects []string `json:"objects"`
46 }
47
48 type purgeResponse struct {
49 HTTPStatus int `json:"httpStatus"`
50 Detail string `json:"detail"`
51 EstimatedSeconds int `json:"estimatedSeconds"`
52 PurgeID string `json:"purgeId"`
53 }
54
55
56
57 type CachePurgeClient struct {
58 client *http.Client
59 apiEndpoint string
60 apiHost string
61 apiScheme string
62 clientToken string
63 clientSecret string
64 accessToken string
65 v3Network string
66 retries int
67 retryBackoff time.Duration
68 log blog.Logger
69 purgeLatency prometheus.Histogram
70 purges *prometheus.CounterVec
71 clk clock.Clock
72 }
73
74
75
76 func NewCachePurgeClient(
77 baseURL,
78 clientToken,
79 secret,
80 accessToken,
81 network string,
82 retries int,
83 retryBackoff time.Duration,
84 log blog.Logger, scope prometheus.Registerer,
85 ) (*CachePurgeClient, error) {
86 if network != "production" && network != "staging" {
87 return nil, fmt.Errorf("'V3Network' must be \"staging\" or \"production\", got %q", network)
88 }
89
90 endpoint, err := url.Parse(strings.TrimSuffix(baseURL, "/"))
91 if err != nil {
92 return nil, fmt.Errorf("failed to parse 'BaseURL' as a URL: %s", err)
93 }
94
95 purgeLatency := prometheus.NewHistogram(prometheus.HistogramOpts{
96 Name: "ccu_purge_latency",
97 Help: "Histogram of latencies of CCU purges",
98 Buckets: metrics.InternetFacingBuckets,
99 })
100 scope.MustRegister(purgeLatency)
101
102 purges := prometheus.NewCounterVec(prometheus.CounterOpts{
103 Name: "ccu_purges",
104 Help: "A counter of CCU purges labelled by the result",
105 }, []string{"type"})
106 scope.MustRegister(purges)
107
108 return &CachePurgeClient{
109 client: new(http.Client),
110 apiEndpoint: endpoint.String(),
111 apiHost: endpoint.Host,
112 apiScheme: strings.ToLower(endpoint.Scheme),
113 clientToken: clientToken,
114 clientSecret: secret,
115 accessToken: accessToken,
116 v3Network: network,
117 retries: retries,
118 retryBackoff: retryBackoff,
119 log: log,
120 clk: clock.New(),
121 purgeLatency: purgeLatency,
122 purges: purges,
123 }, nil
124 }
125
126
127
128
129
130 func (cpc *CachePurgeClient) makeAuthHeader(body []byte, apiPath string, nonce string) string {
131
132
133 timestamp := cpc.clk.Now().UTC().Format(timestampFormat)
134 header := fmt.Sprintf(
135 "EG1-HMAC-SHA256 client_token=%s;access_token=%s;timestamp=%s;nonce=%s;",
136 cpc.clientToken,
137 cpc.accessToken,
138 timestamp,
139 nonce,
140 )
141 bodyHash := sha256.Sum256(body)
142 tbs := fmt.Sprintf(
143 "%s\t%s\t%s\t%s\t%s\t%s\t%s",
144 "POST",
145 cpc.apiScheme,
146 cpc.apiHost,
147 apiPath,
148
149 "",
150 base64.StdEncoding.EncodeToString(bodyHash[:]),
151 header,
152 )
153 cpc.log.Debugf("To-be-signed Akamai EdgeGrid authentication %q", tbs)
154
155 h := hmac.New(sha256.New, signingKey(cpc.clientSecret, timestamp))
156 h.Write([]byte(tbs))
157 return fmt.Sprintf(
158 "%ssignature=%s",
159 header,
160 base64.StdEncoding.EncodeToString(h.Sum(nil)),
161 )
162 }
163
164
165
166 func signingKey(clientSecret string, timestamp string) []byte {
167 h := hmac.New(sha256.New, []byte(clientSecret))
168 h.Write([]byte(timestamp))
169 key := make([]byte, base64.StdEncoding.EncodedLen(32))
170 base64.StdEncoding.Encode(key, h.Sum(nil))
171 return key
172 }
173
174
175 func (cpc *CachePurgeClient) PurgeTags(tags []string) error {
176 purgeReq := v3PurgeRequest{
177 Objects: tags,
178 }
179 endpoint := fmt.Sprintf("%s%s%s", cpc.apiEndpoint, v3PurgeTagPath, cpc.v3Network)
180 return cpc.authedRequest(endpoint, purgeReq)
181 }
182
183
184 func (cpc *CachePurgeClient) purgeURLs(urls []string) error {
185 purgeReq := v3PurgeRequest{
186 Objects: urls,
187 }
188 endpoint := fmt.Sprintf("%s%s%s", cpc.apiEndpoint, v3PurgePath, cpc.v3Network)
189 return cpc.authedRequest(endpoint, purgeReq)
190 }
191
192
193
194 func (cpc *CachePurgeClient) authedRequest(endpoint string, body v3PurgeRequest) error {
195 reqBody, err := json.Marshal(body)
196 if err != nil {
197 return fmt.Errorf("%s: %w", err, errFatal)
198 }
199
200 req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(reqBody))
201 if err != nil {
202 return fmt.Errorf("%s: %w", err, errFatal)
203 }
204
205 endpointURL, err := url.Parse(endpoint)
206 if err != nil {
207 return fmt.Errorf("while parsing %q as URL: %s: %w", endpoint, err, errFatal)
208 }
209
210 authorization := cpc.makeAuthHeader(reqBody, endpointURL.Path, core.RandomString(16))
211 req.Header.Set("Authorization", authorization)
212 req.Header.Set("Content-Type", "application/json")
213 cpc.log.Debugf("POSTing to endpoint %q (header %q) (body %q)", endpoint, authorization, reqBody)
214
215 start := cpc.clk.Now()
216 resp, err := cpc.client.Do(req)
217 cpc.purgeLatency.Observe(cpc.clk.Since(start).Seconds())
218 if err != nil {
219 return fmt.Errorf("while POSTing to endpoint %q: %w", endpointURL, err)
220 }
221 defer resp.Body.Close()
222
223 if resp.Body == nil {
224 return fmt.Errorf("response body was empty from URL %q", resp.Request.URL)
225 }
226
227 respBody, err := io.ReadAll(resp.Body)
228 if err != nil {
229 return err
230 }
231
232
233
234
235 if resp.StatusCode != http.StatusCreated {
236 switch resp.StatusCode {
237
238 case http.StatusForbidden:
239 return fmt.Errorf("client not authorized to make requests for URL %q: %w", resp.Request.URL, errFatal)
240
241
242 case http.StatusGatewayTimeout:
243 return fmt.Errorf("server timed out, got HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL)
244
245
246 case http.StatusTooManyRequests:
247 return fmt.Errorf("exceeded request count rate limit, got HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL)
248
249
250 case http.StatusRequestEntityTooLarge:
251 return fmt.Errorf("exceeded request size rate limit, got HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL)
252 default:
253 return fmt.Errorf("received HTTP %d (body %q) for URL %q", resp.StatusCode, respBody, resp.Request.URL)
254 }
255 }
256
257 var purgeInfo purgeResponse
258 err = json.Unmarshal(respBody, &purgeInfo)
259 if err != nil {
260 return fmt.Errorf("while unmarshalling body %q from URL %q as JSON: %w", respBody, resp.Request.URL, err)
261 }
262
263
264
265 if purgeInfo.HTTPStatus != http.StatusCreated {
266 if purgeInfo.HTTPStatus == http.StatusForbidden {
267 return fmt.Errorf("client not authorized to make requests to URL %q: %w", resp.Request.URL, errFatal)
268 }
269 return fmt.Errorf("unmarshaled HTTP %d (body %q) from URL %q", purgeInfo.HTTPStatus, respBody, resp.Request.URL)
270 }
271
272 cpc.log.AuditInfof("Purge request sent successfully (ID %s) (body %s). Purge expected in %ds",
273 purgeInfo.PurgeID, reqBody, purgeInfo.EstimatedSeconds)
274 return nil
275 }
276
277
278
279
280 func (cpc *CachePurgeClient) Purge(urls []string) error {
281 successful := false
282 for i := 0; i <= cpc.retries; i++ {
283 cpc.clk.Sleep(core.RetryBackoff(i, cpc.retryBackoff, time.Minute, 1.3))
284
285 err := cpc.purgeURLs(urls)
286 if err != nil {
287 if errors.Is(err, errFatal) {
288 cpc.purges.WithLabelValues("fatal failure").Inc()
289 return err
290 }
291 cpc.log.AuditErrf("Akamai cache purge failed, retrying: %s", err)
292 cpc.purges.WithLabelValues("retryable failure").Inc()
293 continue
294 }
295 successful = true
296 break
297 }
298
299 if !successful {
300 cpc.purges.WithLabelValues("fatal failure").Inc()
301 return ErrAllRetriesFailed
302 }
303
304 cpc.purges.WithLabelValues("success").Inc()
305 return nil
306 }
307
308
309 func CheckSignature(secret string, url string, r *http.Request, body []byte) error {
310 bodyHash := sha256.Sum256(body)
311 bodyHashB64 := base64.StdEncoding.EncodeToString(bodyHash[:])
312
313 authorization := r.Header.Get("Authorization")
314 authValues := make(map[string]string)
315 for _, v := range strings.Split(authorization, ";") {
316 splitValue := strings.Split(v, "=")
317 authValues[splitValue[0]] = splitValue[1]
318 }
319 headerTimestamp := authValues["timestamp"]
320 splitHeader := strings.Split(authorization, "signature=")
321 shortenedHeader, signature := splitHeader[0], splitHeader[1]
322 hostPort := strings.Split(url, "://")[1]
323 h := hmac.New(sha256.New, signingKey(secret, headerTimestamp))
324 input := []byte(fmt.Sprintf("POST\thttp\t%s\t%s\t\t%s\t%s",
325 hostPort,
326 r.URL.Path,
327 bodyHashB64,
328 shortenedHeader,
329 ))
330 h.Write(input)
331 expectedSignature := base64.StdEncoding.EncodeToString(h.Sum(nil))
332 if signature != expectedSignature {
333 return fmt.Errorf("expected signature %q, got %q in %q",
334 signature, authorization, expectedSignature)
335 }
336 return nil
337 }
338
339 func reverseBytes(b []byte) []byte {
340 for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
341 b[i], b[j] = b[j], b[i]
342 }
343 return b
344 }
345
346
347
348 func makeOCSPCacheURLs(req []byte, ocspServer string) []string {
349 hash := md5.Sum(req)
350 encReq := base64.StdEncoding.EncodeToString(req)
351 return []string{
352
353
354
355
356
357
358
359
360
361
362 fmt.Sprintf("%s?body-md5=%x%x", ocspServer, reverseBytes(hash[0:4]), reverseBytes(hash[4:8])),
363
364
365
366
367
368
369
370
371
372
373 fmt.Sprintf("%s%s", ocspServer, strings.Replace(encReq, "//", "/", -1)),
374
375
376
377 fmt.Sprintf("%s%s", ocspServer, url.QueryEscape(encReq)),
378 }
379 }
380
381
382
383
384
385 func GeneratePurgeURLs(cert, issuer *x509.Certificate) ([]string, error) {
386 req, err := ocsp.CreateRequest(cert, issuer, nil)
387 if err != nil {
388 return nil, err
389 }
390
391
392
393 urls := []string{}
394 for _, ocspServer := range cert.OCSPServer {
395 if !strings.HasSuffix(ocspServer, "/") {
396 ocspServer += "/"
397 }
398 urls = append(urls, makeOCSPCacheURLs(req, ocspServer)...)
399 }
400 return urls, nil
401 }
402
View as plain text