...

Source file src/github.com/letsencrypt/boulder/akamai/cache-client.go

Documentation: github.com/letsencrypt/boulder/akamai

     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  	// ErrAllRetriesFailed indicates that all purge submission attempts have
    35  	// failed.
    36  	ErrAllRetriesFailed = errors.New("all attempts to submit purge request failed")
    37  
    38  	// errFatal is returned by the purge method of CachePurgeClient to indicate
    39  	// that it failed for a reason that cannot be remediated by retrying the
    40  	// request.
    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  // CachePurgeClient talks to the Akamai CCU REST API. It is safe to make
    56  // concurrent requests using this client.
    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  // NewCachePurgeClient performs some basic validation of supplied configuration
    75  // and returns a newly constructed CachePurgeClient.
    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  // makeAuthHeader constructs a special Akamai authorization header. This header
   127  // is used to identify clients to Akamai's EdgeGrid APIs. For a more detailed
   128  // description of the generation process see their docs:
   129  // https://developer.akamai.com/introduction/Client_Auth.html
   130  func (cpc *CachePurgeClient) makeAuthHeader(body []byte, apiPath string, nonce string) string {
   131  	// The akamai API is very time sensitive (recommending reliance on a stratum 2
   132  	// or better time source). Additionally, timestamps MUST be in UTC.
   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  		// Signed headers are not required for this request type.
   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  // signingKey makes a signing key by HMAC'ing the timestamp
   165  // using a client secret as the key.
   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  // PurgeTags constructs and dispatches a request to purge a batch of Tags.
   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  // purgeURLs constructs and dispatches a request to purge a batch of URLs.
   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  // authedRequest POSTs the JSON marshaled purge request to the provided endpoint
   193  // along with an Akamai authorization header.
   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  	// Success for a request to purge a URL or Cache tag is 'HTTP 201'.
   233  	// https://techdocs.akamai.com/purge-cache/reference/delete-url
   234  	// https://techdocs.akamai.com/purge-cache/reference/delete-tag
   235  	if resp.StatusCode != http.StatusCreated {
   236  		switch resp.StatusCode {
   237  		// https://techdocs.akamai.com/purge-cache/reference/403
   238  		case http.StatusForbidden:
   239  			return fmt.Errorf("client not authorized to make requests for URL %q: %w", resp.Request.URL, errFatal)
   240  
   241  		// https://techdocs.akamai.com/purge-cache/reference/504
   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  		// https://techdocs.akamai.com/purge-cache/reference/429
   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  		// https://techdocs.akamai.com/purge-cache/reference/413
   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  	// Ensure the unmarshaled body concurs with the status of the response
   264  	// received.
   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  // Purge dispatches the provided URLs in a request to the Akamai Fast-Purge API.
   278  // The request will be attempted cpc.retries number of times before giving up
   279  // and returning ErrAllRetriesFailed.
   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  // CheckSignature is exported for use in tests and akamai-test-srv.
   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  // makeOCSPCacheURLs constructs the 3 URLs associated with each cached OCSP
   347  // response.
   348  func makeOCSPCacheURLs(req []byte, ocspServer string) []string {
   349  	hash := md5.Sum(req)
   350  	encReq := base64.StdEncoding.EncodeToString(req)
   351  	return []string{
   352  		// POST Cache Key: the format of this entry is the URL that was POSTed
   353  		// to with a query string with the parameter 'body-md5' and the value of
   354  		// the first two uint32s in little endian order in hex of the MD5 hash
   355  		// of the OCSP request body.
   356  		//
   357  		// There is limited public documentation of this feature. However, this
   358  		// entry is what triggers the Akamai cache behavior that allows Akamai to
   359  		// identify POST based OCSP for purging. For more information, see:
   360  		// https://techdocs.akamai.com/property-mgr/reference/v2020-03-04-cachepost
   361  		// https://techdocs.akamai.com/property-mgr/docs/cache-post-responses
   362  		fmt.Sprintf("%s?body-md5=%x%x", ocspServer, reverseBytes(hash[0:4]), reverseBytes(hash[4:8])),
   363  
   364  		// URL (un-encoded): RFC 2560 and RFC 5019 state OCSP GET URLs 'MUST
   365  		// properly url-encode the base64 encoded' request but a large enough
   366  		// portion of tools do not properly do this (~10% of GET requests we
   367  		// receive) such that we must purge both the encoded and un-encoded
   368  		// URLs.
   369  		//
   370  		// Due to Akamai proxy/cache behavior which collapses '//' -> '/' we also
   371  		// collapse double slashes in the un-encoded URL so that we properly purge
   372  		// what is stored in the cache.
   373  		fmt.Sprintf("%s%s", ocspServer, strings.Replace(encReq, "//", "/", -1)),
   374  
   375  		// URL (encoded): this entry is the url-encoded GET URL used to request
   376  		// OCSP as specified in RFC 2560 and RFC 5019.
   377  		fmt.Sprintf("%s%s", ocspServer, url.QueryEscape(encReq)),
   378  	}
   379  }
   380  
   381  // GeneratePurgeURLs generates akamai URLs that can be POSTed to in order to
   382  // purge akamai's cache of the corresponding OCSP responses. The URLs encode
   383  // the contents of the OCSP request, so this method constructs a full OCSP
   384  // request.
   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  	// Create a GET and special Akamai POST style OCSP url for each endpoint in
   392  	// cert.OCSPServer.
   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