371 lines
14 KiB
Go
371 lines
14 KiB
Go
/*
|
|
This code was originally forked from https://github.com/cloudflare/cfssl/blob/1a911ca1b1d6e899bf97dcfa4a14b38db0d31134/ocsp/responder.go
|
|
|
|
Copyright (c) 2014 CloudFlare Inc.
|
|
|
|
Redistribution and use in source and binary forms, with or without
|
|
modification, are permitted provided that the following conditions
|
|
are met:
|
|
|
|
Redistributions of source code must retain the above copyright notice,
|
|
this list of conditions and the following disclaimer.
|
|
|
|
Redistributions in binary form must reproduce the above copyright notice,
|
|
this list of conditions and the following disclaimer in the documentation
|
|
and/or other materials provided with the distribution.
|
|
|
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
|
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
*/
|
|
|
|
// Package responder implements an OCSP HTTP responder based on a generic
|
|
// storage backend.
|
|
package responder
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/jmhodges/clock"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"golang.org/x/crypto/ocsp"
|
|
|
|
"github.com/letsencrypt/boulder/core"
|
|
blog "github.com/letsencrypt/boulder/log"
|
|
)
|
|
|
|
// ErrNotFound indicates the request OCSP response was not found. It is used to
|
|
// indicate that the responder should reply with unauthorizedErrorResponse.
|
|
var ErrNotFound = errors.New("request OCSP Response not found")
|
|
|
|
// errOCSPResponseExpired indicates that the nextUpdate field of the requested
|
|
// OCSP response occurred in the past and an HTTP status code of 533 should be
|
|
// returned to the caller.
|
|
var errOCSPResponseExpired = errors.New("OCSP response is expired")
|
|
|
|
var responseTypeToString = map[ocsp.ResponseStatus]string{
|
|
ocsp.Success: "Success",
|
|
ocsp.Malformed: "Malformed",
|
|
ocsp.InternalError: "InternalError",
|
|
ocsp.TryLater: "TryLater",
|
|
ocsp.SignatureRequired: "SignatureRequired",
|
|
ocsp.Unauthorized: "Unauthorized",
|
|
}
|
|
|
|
// A Responder object provides an HTTP wrapper around a Source.
|
|
type Responder struct {
|
|
Source Source
|
|
timeout time.Duration
|
|
responseTypes *prometheus.CounterVec
|
|
responseAges prometheus.Histogram
|
|
requestSizes prometheus.Histogram
|
|
sampleRate int
|
|
clk clock.Clock
|
|
log blog.Logger
|
|
}
|
|
|
|
// NewResponder instantiates a Responder with the give Source.
|
|
func NewResponder(source Source, timeout time.Duration, stats prometheus.Registerer, logger blog.Logger, sampleRate int) *Responder {
|
|
requestSizes := prometheus.NewHistogram(
|
|
prometheus.HistogramOpts{
|
|
Name: "ocsp_request_sizes",
|
|
Help: "Size of OCSP requests",
|
|
Buckets: []float64{1, 100, 200, 400, 800, 1200, 2000, 5000, 10000},
|
|
},
|
|
)
|
|
stats.MustRegister(requestSizes)
|
|
|
|
// Set up 12-hour-wide buckets, measured in seconds.
|
|
buckets := make([]float64, 14)
|
|
for i := range buckets {
|
|
buckets[i] = 43200 * float64(i)
|
|
}
|
|
responseAges := prometheus.NewHistogram(prometheus.HistogramOpts{
|
|
Name: "ocsp_response_ages",
|
|
Help: "How old are the OCSP responses when we serve them. Must stay well below 84 hours.",
|
|
Buckets: buckets,
|
|
})
|
|
stats.MustRegister(responseAges)
|
|
|
|
responseTypes := prometheus.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Name: "ocsp_responses",
|
|
Help: "Number of OCSP responses returned by type",
|
|
},
|
|
[]string{"type"},
|
|
)
|
|
stats.MustRegister(responseTypes)
|
|
|
|
return &Responder{
|
|
Source: source,
|
|
timeout: timeout,
|
|
responseTypes: responseTypes,
|
|
responseAges: responseAges,
|
|
requestSizes: requestSizes,
|
|
clk: clock.New(),
|
|
log: logger,
|
|
sampleRate: sampleRate,
|
|
}
|
|
}
|
|
|
|
type logEvent struct {
|
|
IP string `json:"ip,omitempty"`
|
|
UA string `json:"ua,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Body string `json:"body,omitempty"`
|
|
Received time.Time `json:"received,omitempty"`
|
|
Took time.Duration `json:"took,omitempty"`
|
|
Headers http.Header `json:"headers,omitempty"`
|
|
|
|
Serial string `json:"serial,omitempty"`
|
|
IssuerKeyHash string `json:"issuerKeyHash,omitempty"`
|
|
IssuerNameHash string `json:"issuerNameHash,omitempty"`
|
|
HashAlg string `json:"hashAlg,omitempty"`
|
|
}
|
|
|
|
// hashToString contains mappings for the only hash functions
|
|
// x/crypto/ocsp supports
|
|
var hashToString = map[crypto.Hash]string{
|
|
crypto.SHA1: "SHA1",
|
|
crypto.SHA256: "SHA256",
|
|
crypto.SHA384: "SHA384",
|
|
crypto.SHA512: "SHA512",
|
|
}
|
|
|
|
func SampledError(log blog.Logger, sampleRate int, format string, a ...interface{}) {
|
|
if sampleRate > 0 && rand.Intn(sampleRate) == 0 {
|
|
log.Errf(format, a...)
|
|
}
|
|
}
|
|
|
|
func (rs Responder) sampledError(format string, a ...interface{}) {
|
|
SampledError(rs.log, rs.sampleRate, format, a...)
|
|
}
|
|
|
|
// ServeHTTP is a Responder that can process both GET and POST requests. The
|
|
// mapping from an OCSP request to an OCSP response is done by the Source; the
|
|
// Responder simply decodes the request, and passes back whatever response is
|
|
// provided by the source.
|
|
// The Responder will set these headers:
|
|
//
|
|
// Cache-Control: "max-age=(response.NextUpdate-now), public, no-transform, must-revalidate",
|
|
// Last-Modified: response.ThisUpdate,
|
|
// Expires: response.NextUpdate,
|
|
// ETag: the SHA256 hash of the response, and
|
|
// Content-Type: application/ocsp-response.
|
|
//
|
|
// Note: The caller must use http.StripPrefix to strip any path components
|
|
// (including '/') on GET requests.
|
|
// Do not use this responder in conjunction with http.NewServeMux, because the
|
|
// default handler will try to canonicalize path components by changing any
|
|
// strings of repeated '/' into a single '/', which will break the base64
|
|
// encoding.
|
|
func (rs Responder) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
|
// We specifically ignore request.Context() because we would prefer for clients
|
|
// to not be able to cancel our operations in arbitrary places. Instead we
|
|
// start a new context, and apply timeouts in our various RPCs.
|
|
// TODO(go1.22?): Use context.Detach()
|
|
span := trace.SpanFromContext(request.Context())
|
|
ctx := trace.ContextWithSpan(context.Background(), span)
|
|
request = request.WithContext(ctx)
|
|
|
|
if rs.timeout != 0 {
|
|
var cancel func()
|
|
ctx, cancel = context.WithTimeout(ctx, rs.timeout)
|
|
defer cancel()
|
|
}
|
|
|
|
le := logEvent{
|
|
IP: request.RemoteAddr,
|
|
UA: request.UserAgent(),
|
|
Method: request.Method,
|
|
Path: request.URL.Path,
|
|
Received: time.Now(),
|
|
}
|
|
|
|
defer func() {
|
|
le.Headers = response.Header()
|
|
le.Took = time.Since(le.Received)
|
|
jb, err := json.Marshal(le)
|
|
if err != nil {
|
|
// we log this error at the debug level as if we aren't at that level anyway
|
|
// we shouldn't really care about marshalling the log event object
|
|
rs.log.Debugf("failed to marshal log event object: %s", err)
|
|
return
|
|
}
|
|
rs.log.Debugf("Received request: %s", string(jb))
|
|
}()
|
|
// By default we set a 'max-age=0, no-cache' Cache-Control header, this
|
|
// is only returned to the client if a valid authorized OCSP response
|
|
// is not found or an error is returned. If a response if found the header
|
|
// will be altered to contain the proper max-age and modifiers.
|
|
response.Header().Add("Cache-Control", "max-age=0, no-cache")
|
|
// Read response from request
|
|
var requestBody []byte
|
|
var err error
|
|
switch request.Method {
|
|
case "GET":
|
|
base64Request, err := url.QueryUnescape(request.URL.Path)
|
|
if err != nil {
|
|
rs.log.Debugf("Error decoding URL: %s", request.URL.Path)
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
// url.QueryUnescape not only unescapes %2B escaping, but it additionally
|
|
// turns the resulting '+' into a space, which makes base64 decoding fail.
|
|
// So we go back afterwards and turn ' ' back into '+'. This means we
|
|
// accept some malformed input that includes ' ' or %20, but that's fine.
|
|
base64RequestBytes := []byte(base64Request)
|
|
for i := range base64RequestBytes {
|
|
if base64RequestBytes[i] == ' ' {
|
|
base64RequestBytes[i] = '+'
|
|
}
|
|
}
|
|
// In certain situations a UA may construct a request that has a double
|
|
// slash between the host name and the base64 request body due to naively
|
|
// constructing the request URL. In that case strip the leading slash
|
|
// so that we can still decode the request.
|
|
if len(base64RequestBytes) > 0 && base64RequestBytes[0] == '/' {
|
|
base64RequestBytes = base64RequestBytes[1:]
|
|
}
|
|
requestBody, err = base64.StdEncoding.DecodeString(string(base64RequestBytes))
|
|
if err != nil {
|
|
rs.log.Debugf("Error decoding base64 from URL: %s", string(base64RequestBytes))
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
|
|
return
|
|
}
|
|
case "POST":
|
|
requestBody, err = io.ReadAll(http.MaxBytesReader(nil, request.Body, 10000))
|
|
if err != nil {
|
|
rs.log.Errf("Problem reading body of POST: %s", err)
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
|
|
return
|
|
}
|
|
rs.requestSizes.Observe(float64(len(requestBody)))
|
|
default:
|
|
response.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
b64Body := base64.StdEncoding.EncodeToString(requestBody)
|
|
rs.log.Debugf("Received OCSP request: %s", b64Body)
|
|
if request.Method == http.MethodPost {
|
|
le.Body = b64Body
|
|
}
|
|
|
|
// All responses after this point will be OCSP.
|
|
// We could check for the content type of the request, but that
|
|
// seems unnecessariliy restrictive.
|
|
response.Header().Add("Content-Type", "application/ocsp-response")
|
|
|
|
// Parse response as an OCSP request
|
|
// XXX: This fails if the request contains the nonce extension.
|
|
// We don't intend to support nonces anyway, but maybe we
|
|
// should return unauthorizedRequest instead of malformed.
|
|
ocspRequest, err := ocsp.ParseRequest(requestBody)
|
|
if err != nil {
|
|
rs.log.Debugf("Error decoding request body: %s", b64Body)
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
response.Write(ocsp.MalformedRequestErrorResponse)
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Malformed]}).Inc()
|
|
return
|
|
}
|
|
le.Serial = fmt.Sprintf("%x", ocspRequest.SerialNumber.Bytes())
|
|
le.IssuerKeyHash = fmt.Sprintf("%x", ocspRequest.IssuerKeyHash)
|
|
le.IssuerNameHash = fmt.Sprintf("%x", ocspRequest.IssuerNameHash)
|
|
le.HashAlg = hashToString[ocspRequest.HashAlgorithm]
|
|
|
|
// Look up OCSP response from source
|
|
ocspResponse, err := rs.Source.Response(ctx, ocspRequest)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
rs.sampledError("No response found for request: serial %x, request body %s",
|
|
ocspRequest.SerialNumber, b64Body)
|
|
response.Write(ocsp.UnauthorizedErrorResponse)
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Unauthorized]}).Inc()
|
|
return
|
|
} else if errors.Is(err, errOCSPResponseExpired) {
|
|
rs.sampledError("Requested ocsp response is expired: serial %x, request body %s",
|
|
ocspRequest.SerialNumber, b64Body)
|
|
// HTTP StatusCode - unassigned
|
|
response.WriteHeader(533)
|
|
response.Write(ocsp.InternalErrorErrorResponse)
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Unauthorized]}).Inc()
|
|
return
|
|
}
|
|
rs.sampledError("Error retrieving response for request: serial %x, request body %s, error: %s",
|
|
ocspRequest.SerialNumber, b64Body, err)
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
response.Write(ocsp.InternalErrorErrorResponse)
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.InternalError]}).Inc()
|
|
return
|
|
}
|
|
|
|
// Write OCSP response
|
|
response.Header().Add("Last-Modified", ocspResponse.ThisUpdate.Format(time.RFC1123))
|
|
response.Header().Add("Expires", ocspResponse.NextUpdate.Format(time.RFC1123))
|
|
now := rs.clk.Now()
|
|
var maxAge int
|
|
if now.Before(ocspResponse.NextUpdate) {
|
|
maxAge = int(ocspResponse.NextUpdate.Sub(now) / time.Second)
|
|
} else {
|
|
// TODO(#530): we want max-age=0 but this is technically an authorized OCSP response
|
|
// (despite being stale) and 5019 forbids attaching no-cache
|
|
maxAge = 0
|
|
}
|
|
response.Header().Set(
|
|
"Cache-Control",
|
|
fmt.Sprintf(
|
|
"max-age=%d, public, no-transform, must-revalidate",
|
|
maxAge,
|
|
),
|
|
)
|
|
responseHash := sha256.Sum256(ocspResponse.Raw)
|
|
response.Header().Add("ETag", fmt.Sprintf("\"%X\"", responseHash))
|
|
|
|
serialString := core.SerialToString(ocspResponse.SerialNumber)
|
|
if len(serialString) > 2 {
|
|
// Set a cache tag that is equal to the last two bytes of the serial.
|
|
// We expect that to be randomly distributed, so each tag should map to
|
|
// about 1/256 of our responses.
|
|
response.Header().Add("Edge-Cache-Tag", serialString[len(serialString)-2:])
|
|
}
|
|
|
|
// RFC 7232 says that a 304 response must contain the above
|
|
// headers if they would also be sent for a 200 for the same
|
|
// request, so we have to wait until here to do this
|
|
if etag := request.Header.Get("If-None-Match"); etag != "" {
|
|
if etag == fmt.Sprintf("\"%X\"", responseHash) {
|
|
response.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
}
|
|
response.WriteHeader(http.StatusOK)
|
|
response.Write(ocspResponse.Raw)
|
|
rs.responseAges.Observe(rs.clk.Now().Sub(ocspResponse.ThisUpdate).Seconds())
|
|
rs.responseTypes.With(prometheus.Labels{"type": responseTypeToString[ocsp.Success]}).Inc()
|
|
}
|