diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go
index 7f184017ce..49ff9f67a1 100644
--- a/builtin/logical/pki/backend.go
+++ b/builtin/logical/pki/backend.go
@@ -8,6 +8,8 @@ import (
"sync/atomic"
"time"
+ atomic2 "go.uber.org/atomic"
+
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/armon/go-metrics"
@@ -84,6 +86,8 @@ func Backend(conf *logical.BackendConfig) *backend {
"issuer/+/der",
"issuer/+/json",
"issuers",
+ "ocsp", // OCSP POST
+ "ocsp/*", // OCSP GET
},
LocalStorage: []string{
@@ -158,6 +162,10 @@ func Backend(conf *logical.BackendConfig) *backend {
pathFetchValidRaw(&b),
pathFetchValid(&b),
pathFetchListCerts(&b),
+
+ // OCSP APIs
+ buildPathOcspGet(&b),
+ buildPathOcspPost(&b),
},
Secrets: []*framework.Secret{
@@ -179,6 +187,7 @@ func Backend(conf *logical.BackendConfig) *backend {
b.pkiStorageVersion.Store(0)
b.crlBuilder = &crlBuilder{}
+ b.isOcspDisabled = atomic2.NewBool(false)
return &b
}
@@ -199,6 +208,9 @@ type backend struct {
// Write lock around issuers and keys.
issuersLock sync.RWMutex
+
+ // Optimization to not read the CRL config on every OCSP request.
+ isOcspDisabled *atomic2.Bool
}
type (
@@ -295,6 +307,9 @@ func (b *backend) metricsWrap(callType string, roleMode int, ofunc roleOperation
// initialize is used to perform a possible PKI storage migration if needed
func (b *backend) initialize(ctx context.Context, _ *logical.InitializationRequest) error {
+ // load ocsp enabled status
+ setOcspStatus(b, ctx)
+
// Grab the lock prior to the updating of the storage lock preventing us flipping
// the storage flag midway through the request stream of other requests.
b.issuersLock.Lock()
@@ -320,6 +335,14 @@ func (b *backend) initialize(ctx context.Context, _ *logical.InitializationReque
return nil
}
+func setOcspStatus(b *backend, ctx context.Context) {
+ sc := b.makeStorageContext(ctx, b.storage)
+ config, err := sc.getRevocationConfig()
+ if config != nil && err == nil {
+ b.isOcspDisabled.Store(config.OcspDisable)
+ }
+}
+
func (b *backend) useLegacyBundleCaStorage() bool {
// This helper function is here to choose whether or not we use the newer
// issuer/key storage format or the older legacy ca bundle format.
@@ -373,6 +396,9 @@ func (b *backend) invalidate(ctx context.Context, key string) {
} else {
b.Logger().Debug("Ignoring invalidation updates for issuer as the PKI migration has yet to complete.")
}
+ case key == "config/crl":
+ // We may need to reload our OCSP status flag
+ setOcspStatus(b, ctx)
}
}
diff --git a/builtin/logical/pki/cert_util.go b/builtin/logical/pki/cert_util.go
index 019641f4f6..0cc7a9be5e 100644
--- a/builtin/logical/pki/cert_util.go
+++ b/builtin/logical/pki/cert_util.go
@@ -15,6 +15,7 @@ import (
"errors"
"fmt"
"io"
+ "math/big"
"net"
"net/url"
"regexp"
@@ -150,6 +151,10 @@ func (sc *storageContext) fetchCAInfoByIssuerId(issuerId issuerID, usage issuerU
return caInfo, nil
}
+func fetchCertBySerialBigInt(ctx context.Context, b *backend, req *logical.Request, prefix string, serial *big.Int) (*logical.StorageEntry, error) {
+ return fetchCertBySerial(ctx, b, req, prefix, serialFromBigInt(serial))
+}
+
// Allows fetching certificates from the backend; it handles the slightly
// separate pathing for CRL, and revoked certificates.
//
@@ -915,9 +920,10 @@ func signCert(b *backend,
// otherNameRaw describes a name related to a certificate which is not in one
// of the standard name formats. RFC 5280, 4.2.1.6:
-// OtherName ::= SEQUENCE {
-// type-id OBJECT IDENTIFIER,
-// value [0] EXPLICIT ANY DEFINED BY type-id }
+//
+// OtherName ::= SEQUENCE {
+// type-id OBJECT IDENTIFIER,
+// value [0] EXPLICIT ANY DEFINED BY type-id }
type otherNameRaw struct {
TypeID asn1.ObjectIdentifier
Value asn1.RawValue
diff --git a/builtin/logical/pki/crl_test.go b/builtin/logical/pki/crl_test.go
index e7271d22a6..8f9c6b3d57 100644
--- a/builtin/logical/pki/crl_test.go
+++ b/builtin/logical/pki/crl_test.go
@@ -3,6 +3,7 @@ package pki
import (
"context"
"encoding/asn1"
+ "fmt"
"strings"
"testing"
"time"
@@ -26,6 +27,64 @@ func TestBackend_CRL_EnableDisableRoot(t *testing.T) {
crlEnableDisableTestForBackend(t, b, s, []string{caSerial})
}
+func TestBackend_CRLConfig(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ expiry string
+ disable bool
+ ocspDisable bool
+ }{
+ {expiry: "24h", disable: true, ocspDisable: true},
+ {expiry: "16h", disable: false, ocspDisable: true},
+ {expiry: "8h", disable: true, ocspDisable: false},
+ }
+ for _, tc := range tests {
+ name := fmt.Sprintf("%s-%t-%t", tc.expiry, tc.disable, tc.ocspDisable)
+ t.Run(name, func(t *testing.T) {
+ b, s := createBackendWithStorage(t)
+
+ resp, err := CBWrite(b, s, "config/crl", map[string]interface{}{
+ "expiry": tc.expiry,
+ "disable": tc.disable,
+ "ocsp_disable": tc.ocspDisable,
+ })
+ requireSuccessNilResponse(t, resp, err)
+
+ resp, err = CBRead(b, s, "config/crl")
+ requireSuccessNonNilResponse(t, resp, err)
+ requireFieldsSetInResp(t, resp, "disable", "expiry", "ocsp_disable")
+
+ require.Equal(t, tc.expiry, resp.Data["expiry"])
+ require.Equal(t, tc.disable, resp.Data["disable"])
+ require.Equal(t, tc.ocspDisable, resp.Data["ocsp_disable"])
+ })
+ }
+
+ badValueTests := []struct {
+ expiry string
+ disable string
+ ocspDisable string
+ }{
+ {expiry: "not a duration", disable: "true", ocspDisable: "true"},
+ {expiry: "16h", disable: "not a boolean", ocspDisable: "true"},
+ {expiry: "8h", disable: "true", ocspDisable: "not a boolean"},
+ }
+ for _, tc := range badValueTests {
+ name := fmt.Sprintf("bad-%s-%s-%s", tc.expiry, tc.disable, tc.ocspDisable)
+ t.Run(name, func(t *testing.T) {
+ b, s := createBackendWithStorage(t)
+
+ _, err := CBWrite(b, s, "config/crl", map[string]interface{}{
+ "expiry": tc.expiry,
+ "disable": tc.disable,
+ "ocsp_disable": tc.ocspDisable,
+ })
+ require.Error(t, err)
+ })
+ }
+}
+
func TestBackend_CRL_AllKeyTypeSigAlgos(t *testing.T) {
type testCase struct {
KeyType string
diff --git a/builtin/logical/pki/crl_util.go b/builtin/logical/pki/crl_util.go
index 3a04d825a8..bd9028c911 100644
--- a/builtin/logical/pki/crl_util.go
+++ b/builtin/logical/pki/crl_util.go
@@ -611,7 +611,7 @@ func augmentWithRevokedIssuers(issuerIDEntryMap map[issuerID]*issuerEntry, issue
// Builds a CRL by going through the list of revoked certificates and building
// a new CRL with the stored revocation times and serial numbers.
func buildCRL(sc *storageContext, forceNew bool, thisIssuerId issuerID, revoked []pkix.RevokedCertificate, identifier crlID, crlNumber int64) error {
- crlInfo, err := sc.Backend.CRL(sc.Context, sc.Storage)
+ crlInfo, err := sc.getRevocationConfig()
if err != nil {
return errutil.InternalError{Err: fmt.Sprintf("error fetching CRL config information: %s", err)}
}
diff --git a/builtin/logical/pki/ocsp.go b/builtin/logical/pki/ocsp.go
new file mode 100644
index 0000000000..67302e6c3f
--- /dev/null
+++ b/builtin/logical/pki/ocsp.go
@@ -0,0 +1,396 @@
+package pki
+
+import (
+ "bytes"
+ "context"
+ "crypto"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "math/big"
+ "net/http"
+ "time"
+
+ "github.com/hashicorp/vault/sdk/helper/errutil"
+
+ "golang.org/x/crypto/ocsp"
+
+ "github.com/hashicorp/vault/sdk/framework"
+ "github.com/hashicorp/vault/sdk/helper/certutil"
+ "github.com/hashicorp/vault/sdk/logical"
+)
+
+const (
+ ocspReqParam = "req"
+ ocspResponseContentType = "application/ocsp-response"
+)
+
+type ocspRespInfo struct {
+ formattedSerialNumber string
+ serialNumber *big.Int
+ ocspStatus int
+ revocationTimeUTC *time.Time
+ issuerID issuerID
+}
+
+// These response variables should not be mutated, instead treat them as constants
+var (
+ OcspUnauthorizedResponse = &logical.Response{
+ Data: map[string]interface{}{
+ logical.HTTPContentType: ocspResponseContentType,
+ logical.HTTPStatusCode: http.StatusUnauthorized,
+ logical.HTTPRawBody: ocsp.UnauthorizedErrorResponse,
+ },
+ }
+ OcspMalformedResponse = &logical.Response{
+ Data: map[string]interface{}{
+ logical.HTTPContentType: ocspResponseContentType,
+ logical.HTTPStatusCode: http.StatusBadRequest,
+ logical.HTTPRawBody: ocsp.MalformedRequestErrorResponse,
+ },
+ }
+ OcspInternalErrorResponse = &logical.Response{
+ Data: map[string]interface{}{
+ logical.HTTPContentType: ocspResponseContentType,
+ logical.HTTPStatusCode: http.StatusInternalServerError,
+ logical.HTTPRawBody: ocsp.InternalErrorErrorResponse,
+ },
+ }
+
+ ErrMissingOcspUsage = errors.New("issuer entry did not have the OCSPSigning usage")
+ ErrIssuerHasNoKey = errors.New("issuer has no key")
+ ErrUnknownIssuer = errors.New("unknown issuer")
+)
+
+func buildPathOcspGet(b *backend) *framework.Path {
+ return &framework.Path{
+ Pattern: "ocsp/" + framework.MatchAllRegex(ocspReqParam),
+ Fields: map[string]*framework.FieldSchema{
+ ocspReqParam: {
+ Type: framework.TypeString,
+ Description: "base-64 encoded ocsp request",
+ },
+ },
+ Operations: map[logical.Operation]framework.OperationHandler{
+ logical.ReadOperation: &framework.PathOperation{
+ Callback: b.ocspHandler,
+ },
+ },
+
+ HelpSynopsis: pathOcspHelpSyn,
+ HelpDescription: pathOcspHelpDesc,
+ }
+}
+
+func buildPathOcspPost(b *backend) *framework.Path {
+ return &framework.Path{
+ Pattern: "ocsp",
+ Operations: map[logical.Operation]framework.OperationHandler{
+ logical.UpdateOperation: &framework.PathOperation{
+ Callback: b.ocspHandler,
+ },
+ },
+
+ HelpSynopsis: pathOcspHelpSyn,
+ HelpDescription: pathOcspHelpDesc,
+ }
+}
+
+func (b *backend) ocspHandler(ctx context.Context, request *logical.Request, data *framework.FieldData) (*logical.Response, error) {
+ if b.isOcspDisabled.Load() {
+ return OcspUnauthorizedResponse, nil
+ }
+
+ derReq, err := fetchDerEncodedRequest(request, data)
+ if err != nil {
+ return OcspMalformedResponse, nil
+ }
+
+ ocspReq, err := ocsp.ParseRequest(derReq)
+ if err != nil {
+ return OcspMalformedResponse, nil
+ }
+
+ sc := b.makeStorageContext(ctx, request.Storage)
+
+ ocspStatus, err := getOcspStatus(sc, request, ocspReq)
+ if err != nil {
+ return logAndReturnInternalError(b, err), nil
+ }
+
+ caBundle, err := lookupOcspIssuer(sc, ocspReq, ocspStatus.issuerID)
+ if err != nil {
+ if errors.Is(err, ErrUnknownIssuer) {
+ // Since we were not able to find a matching issuer for the incoming request
+ // generate an Unknown OCSP response. This might turn into an Unauthorized if
+ // we find out that we don't have a default issuer or it's missing the proper Usage flags
+ return generateUnknownResponse(sc, ocspReq), nil
+ }
+ if errors.Is(err, ErrMissingOcspUsage) {
+ // If we did find a matching issuer but aren't allowed to sign, the spec says
+ // we should be responding with an Unauthorized response as we don't have the
+ // ability to sign the response.
+ // https://www.rfc-editor.org/rfc/rfc5019#section-2.2.3
+ return OcspUnauthorizedResponse, nil
+ }
+ return logAndReturnInternalError(b, err), nil
+ }
+
+ byteResp, err := genResponse(caBundle, ocspStatus, ocspReq.HashAlgorithm)
+ if err != nil {
+ return logAndReturnInternalError(b, err), nil
+ }
+
+ return &logical.Response{
+ Data: map[string]interface{}{
+ logical.HTTPContentType: ocspResponseContentType,
+ logical.HTTPStatusCode: http.StatusOK,
+ logical.HTTPRawBody: byteResp,
+ },
+ }, nil
+}
+
+func generateUnknownResponse(sc *storageContext, ocspReq *ocsp.Request) *logical.Response {
+ // Generate an Unknown OCSP response, signing with the default issuer from the mount as we did
+ // not match the request's issuer. If no default issuer can be used, return with Unauthorized as there
+ // isn't much else we can do at this point.
+ config, err := sc.getIssuersConfig()
+ if err != nil {
+ return logAndReturnInternalError(sc.Backend, err)
+ }
+
+ if config.DefaultIssuerId == "" {
+ // If we don't have any issuers or default issuers set, no way to sign a response so Unauthorized it is.
+ return OcspUnauthorizedResponse
+ }
+
+ caBundle, issuer, err := getOcspIssuerParsedBundle(sc, config.DefaultIssuerId)
+ if err != nil {
+ if errors.Is(err, ErrUnknownIssuer) || errors.Is(err, ErrIssuerHasNoKey) {
+ // We must have raced on a delete/update of the default issuer, anyways
+ // no way to sign a response so Unauthorized it is.
+ return OcspUnauthorizedResponse
+ }
+ return logAndReturnInternalError(sc.Backend, err)
+ }
+
+ if !issuer.Usage.HasUsage(OCSPSigningUsage) {
+ // If we don't have any issuers or default issuers set, no way to sign a response so Unauthorized it is.
+ return OcspUnauthorizedResponse
+ }
+
+ info := &ocspRespInfo{
+ serialNumber: ocspReq.SerialNumber,
+ ocspStatus: ocsp.Unknown,
+ }
+
+ byteResp, err := genResponse(caBundle, info, ocspReq.HashAlgorithm)
+ if err != nil {
+ return logAndReturnInternalError(sc.Backend, err)
+ }
+
+ return &logical.Response{
+ Data: map[string]interface{}{
+ logical.HTTPContentType: ocspResponseContentType,
+ logical.HTTPStatusCode: http.StatusOK,
+ logical.HTTPRawBody: byteResp,
+ },
+ }
+}
+
+func fetchDerEncodedRequest(request *logical.Request, data *framework.FieldData) ([]byte, error) {
+ switch request.Operation {
+ case logical.ReadOperation:
+ // The param within the GET request should have a base64 encoded version of a DER request.
+ base64Req := data.Get(ocspReqParam).(string)
+ if base64Req == "" {
+ return nil, errors.New("no base64 encoded ocsp request was found")
+ }
+
+ return base64.StdEncoding.DecodeString(base64Req)
+ case logical.UpdateOperation:
+ // POST bodies should contain the binary form of the DER request.
+ rawBody := request.HTTPRequest.Body
+ defer rawBody.Close()
+
+ buf := bytes.Buffer{}
+ _, err := buf.ReadFrom(rawBody)
+ if err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+ default:
+ return nil, fmt.Errorf("unsupported request method: %s", request.HTTPRequest.Method)
+ }
+}
+
+func logAndReturnInternalError(b *backend, err error) *logical.Response {
+ // Since OCSP might be a high traffic endpoint, we will log at debug level only
+ // any internal errors we do get. There is no way for us to return to the end-user
+ // errors, so we rely on the log statement to help in debugging possible
+ // issues in the field.
+ b.Logger().Debug("OCSP internal error", "error", err)
+ return OcspInternalErrorResponse
+}
+
+func getOcspStatus(sc *storageContext, request *logical.Request, ocspReq *ocsp.Request) (*ocspRespInfo, error) {
+ revEntryRaw, err := fetchCertBySerialBigInt(sc.Context, sc.Backend, request, revokedPath, ocspReq.SerialNumber)
+ if err != nil {
+ return nil, err
+ }
+
+ info := ocspRespInfo{
+ serialNumber: ocspReq.SerialNumber,
+ ocspStatus: ocsp.Good,
+ }
+
+ if revEntryRaw != nil {
+ var revEntry revocationInfo
+ if err := revEntryRaw.DecodeJSON(&revEntry); err != nil {
+ return nil, err
+ }
+
+ info.ocspStatus = ocsp.Revoked
+ info.revocationTimeUTC = &revEntry.RevocationTimeUTC
+ info.issuerID = revEntry.CertificateIssuer // This might be empty if the CRL hasn't been rebuilt
+ }
+
+ return &info, nil
+}
+
+func lookupOcspIssuer(sc *storageContext, req *ocsp.Request, optRevokedIssuer issuerID) (*certutil.ParsedCertBundle, error) {
+ reqHash := req.HashAlgorithm
+ if !reqHash.Available() {
+ return nil, x509.ErrUnsupportedAlgorithm
+ }
+
+ // This will prime up issuerIds, with either the optRevokedIssuer value if set
+ // or if we are operating in legacy storage mode, the shim bundle id or finally
+ // a list of all our issuers in this mount.
+ issuerIds, err := lookupIssuerIds(sc, optRevokedIssuer)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, issuerId := range issuerIds {
+ parsedBundle, issuer, err := getOcspIssuerParsedBundle(sc, issuerId)
+ if err != nil {
+ // A bit touchy here as if we get an ErrUnknownIssuer for an issuer id that we picked up
+ // from a revocation entry, we still return an ErrUnknownOcspIssuer as we can't validate
+ // the end-user actually meant this specific issuer's cert with serial X.
+ if errors.Is(err, ErrUnknownIssuer) || errors.Is(err, ErrIssuerHasNoKey) {
+ // This skips either bad issuer ids, or root certs with no keys that we can't use.
+ continue
+ }
+ return nil, err
+ }
+
+ // Make sure the client and Vault are talking about the same issuer, otherwise
+ // we might have a case of a matching serial number for a different issuer which
+ // we should not respond back in the affirmative about.
+ matches, err := doesRequestMatchIssuer(parsedBundle, req)
+ if err != nil {
+ return nil, err
+ }
+
+ if matches {
+ if !issuer.Usage.HasUsage(OCSPSigningUsage) {
+ // We found the correct issuer, but it's not allowed to sign the
+ // response so give up.
+ return nil, ErrMissingOcspUsage
+ }
+
+ return parsedBundle, nil
+ }
+ }
+
+ return nil, ErrUnknownIssuer
+}
+
+func getOcspIssuerParsedBundle(sc *storageContext, issuerId issuerID) (*certutil.ParsedCertBundle, *issuerEntry, error) {
+ issuer, bundle, err := sc.fetchCertBundleByIssuerId(issuerId, true)
+ if err != nil {
+ switch err.(type) {
+ case errutil.UserError:
+ // Most likely the issuer id no longer exists skip it
+ return nil, nil, ErrUnknownIssuer
+ default:
+ return nil, nil, err
+ }
+ }
+
+ if issuer.KeyID == "" {
+ // No point if the key does not exist from the issuer to use as a signer.
+ return nil, nil, ErrIssuerHasNoKey
+ }
+
+ caBundle, err := parseCABundle(sc.Context, sc.Backend, bundle)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return caBundle, issuer, nil
+}
+
+func lookupIssuerIds(sc *storageContext, optRevokedIssuer issuerID) ([]issuerID, error) {
+ if optRevokedIssuer != "" {
+ return []issuerID{optRevokedIssuer}, nil
+ }
+
+ if sc.Backend.useLegacyBundleCaStorage() {
+ return []issuerID{legacyBundleShimID}, nil
+ }
+
+ return sc.listIssuers()
+}
+
+func doesRequestMatchIssuer(parsedBundle *certutil.ParsedCertBundle, req *ocsp.Request) (bool, error) {
+ var pkInfo struct {
+ Algorithm pkix.AlgorithmIdentifier
+ PublicKey asn1.BitString
+ }
+ if _, err := asn1.Unmarshal(parsedBundle.Certificate.RawSubjectPublicKeyInfo, &pkInfo); err != nil {
+ return false, err
+ }
+
+ h := req.HashAlgorithm.New()
+ h.Write(pkInfo.PublicKey.RightAlign())
+ issuerKeyHash := h.Sum(nil)
+
+ h.Reset()
+ h.Write(parsedBundle.Certificate.RawSubject)
+ issuerNameHash := h.Sum(nil)
+
+ return bytes.Equal(req.IssuerKeyHash, issuerKeyHash) && bytes.Equal(req.IssuerNameHash, issuerNameHash), nil
+}
+
+func genResponse(caBundle *certutil.ParsedCertBundle, info *ocspRespInfo, reqHash crypto.Hash) ([]byte, error) {
+ curTime := time.Now()
+ template := ocsp.Response{
+ IssuerHash: reqHash,
+ Status: info.ocspStatus,
+ SerialNumber: info.serialNumber,
+ ThisUpdate: curTime,
+ NextUpdate: curTime,
+ Certificate: caBundle.Certificate,
+ ExtraExtensions: []pkix.Extension{},
+ }
+
+ if info.ocspStatus == ocsp.Revoked {
+ template.RevokedAt = *info.revocationTimeUTC
+ template.RevocationReason = ocsp.Unspecified
+ }
+
+ return ocsp.CreateResponse(caBundle.Certificate, caBundle.Certificate, template, caBundle.PrivateKey)
+}
+
+const pathOcspHelpSyn = `
+Query a certificate's revocation status through OCSP'
+`
+
+const pathOcspHelpDesc = `
+This endpoint expects DER encoded OCSP requests and returns DER encoded OCSP responses
+`
diff --git a/builtin/logical/pki/ocsp_test.go b/builtin/logical/pki/ocsp_test.go
new file mode 100644
index 0000000000..de281ddd28
--- /dev/null
+++ b/builtin/logical/pki/ocsp_test.go
@@ -0,0 +1,543 @@
+package pki
+
+import (
+ "bytes"
+ "context"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/vault/sdk/logical"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/crypto/ocsp"
+)
+
+// If the ocsp_disabled flag is set to true in the crl configuration make sure we always
+// return an Unauthorized error back as we assume an end-user disabling the feature does
+// not want us to act as the OCSP authority and the RFC specifies this is the appropriate response.
+func TestOcsp_Disabled(t *testing.T) {
+ t.Parallel()
+ type testArgs struct {
+ reqType string
+ }
+ var tests []testArgs
+ for _, reqType := range []string{"get", "post"} {
+ tests = append(tests, testArgs{
+ reqType: reqType,
+ })
+ }
+ for _, tt := range tests {
+ localTT := tt
+ t.Run(localTT.reqType, func(t *testing.T) {
+ b, s, testEnv := setupOcspEnv(t, "rsa")
+ resp, err := CBWrite(b, s, "config/crl", map[string]interface{}{
+ "ocsp_disable": "true",
+ })
+ requireSuccessNilResponse(t, resp, err)
+ resp, err = sendOcspRequest(t, b, s, localTT.reqType, testEnv.leafCertIssuer1, testEnv.issuer1, crypto.SHA1)
+ require.NoError(t, err)
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 401, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ require.Equal(t, ocsp.UnauthorizedErrorResponse, respDer)
+ })
+ }
+}
+
+// If we can't find the issuer within the request and have no default issuer to sign an Unknown response
+// with return an UnauthorizedErrorResponse/according to/the RFC, similar to if we are disabled (lack of authority)
+// This behavior differs from CRLs when an issuer is removed from a mount.
+func TestOcsp_UnknownIssuerWithNoDefault(t *testing.T) {
+ t.Parallel()
+
+ _, _, testEnv := setupOcspEnv(t, "ec")
+ // Create another completely empty mount so the created issuer/certificate above is unknown
+ b, s := createBackendWithStorage(t)
+
+ resp, err := sendOcspRequest(t, b, s, "get", testEnv.leafCertIssuer1, testEnv.issuer1, crypto.SHA1)
+ require.NoError(t, err)
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 401, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ require.Equal(t, ocsp.UnauthorizedErrorResponse, respDer)
+}
+
+// If the issuer in the request does exist, but the request coming in associates the serial with the
+// wrong issuer return an Unknown response back to the caller.
+func TestOcsp_WrongIssuerInRequest(t *testing.T) {
+ t.Parallel()
+
+ b, s, testEnv := setupOcspEnv(t, "ec")
+ serial := serialFromCert(testEnv.leafCertIssuer1)
+ resp, err := CBWrite(b, s, "revoke", map[string]interface{}{
+ "serial_number": serial,
+ })
+ requireSuccessNonNilResponse(t, resp, err, "revoke")
+
+ resp, err = sendOcspRequest(t, b, s, "get", testEnv.leafCertIssuer1, testEnv.issuer2, crypto.SHA1)
+ require.NoError(t, err)
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 200, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ ocspResp, err := ocsp.ParseResponse(respDer, testEnv.issuer1)
+ require.NoError(t, err, "parsing ocsp get response")
+
+ require.Equal(t, ocsp.Unknown, ocspResp.Status)
+}
+
+// Verify that requests we can't properly decode result in the correct response of MalformedRequestError
+func TestOcsp_MalformedRequests(t *testing.T) {
+ t.Parallel()
+ type testArgs struct {
+ reqType string
+ }
+ var tests []testArgs
+ for _, reqType := range []string{"get", "post"} {
+ tests = append(tests, testArgs{
+ reqType: reqType,
+ })
+ }
+ for _, tt := range tests {
+ localTT := tt
+ t.Run(localTT.reqType, func(t *testing.T) {
+ b, s, _ := setupOcspEnv(t, "rsa")
+ badReq := []byte("this is a bad request")
+ var resp *logical.Response
+ var err error
+ switch localTT.reqType {
+ case "get":
+ resp, err = sendOcspGetRequest(b, s, badReq)
+ case "post":
+ resp, err = sendOcspPostRequest(b, s, badReq)
+ default:
+ t.Fatalf("bad request type")
+ }
+ require.NoError(t, err)
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 400, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ require.Equal(t, ocsp.MalformedRequestErrorResponse, respDer)
+ })
+ }
+}
+
+// Validate that we properly handle a revocation entry that contains an issuer ID that no longer exists,
+// the best we can do in this use case is to respond back with the default issuer that we don't know
+// the issuer that they are requesting (we can't guarantee that the client is actually requesting a serial
+// from that issuer)
+func TestOcsp_InvalidIssuerIdInRevocationEntry(t *testing.T) {
+ t.Parallel()
+
+ b, s, testEnv := setupOcspEnv(t, "ec")
+ ctx := context.Background()
+
+ // Revoke the entry
+ serial := serialFromCert(testEnv.leafCertIssuer1)
+ resp, err := CBWrite(b, s, "revoke", map[string]interface{}{
+ "serial_number": serial,
+ })
+ requireSuccessNonNilResponse(t, resp, err, "revoke")
+
+ // Twiddle the entry so that the issuer id is no longer valid.
+ storagePath := revokedPath + normalizeSerial(serial)
+ var revInfo revocationInfo
+ revEntry, err := s.Get(ctx, storagePath)
+ require.NoError(t, err, "failed looking up storage path: %s", storagePath)
+ err = revEntry.DecodeJSON(&revInfo)
+ require.NoError(t, err, "failed decoding storage entry: %v", revEntry)
+ revInfo.CertificateIssuer = "00000000-0000-0000-0000-000000000000"
+ revEntry, err = logical.StorageEntryJSON(storagePath, revInfo)
+ require.NoError(t, err, "failed re-encoding revocation info: %v", revInfo)
+ err = s.Put(ctx, revEntry)
+ require.NoError(t, err, "failed writing out new revocation entry: %v", revEntry)
+
+ // Send the request
+ resp, err = sendOcspRequest(t, b, s, "get", testEnv.leafCertIssuer1, testEnv.issuer1, crypto.SHA1)
+ require.NoError(t, err)
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 200, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ ocspResp, err := ocsp.ParseResponse(respDer, testEnv.issuer1)
+ require.NoError(t, err, "parsing ocsp get response")
+
+ require.Equal(t, ocsp.Unknown, ocspResp.Status)
+}
+
+// Validate that we properly handle an unknown issuer use-case but that the default issuer
+// does not have the OCSP usage flag set, we can't do much else other than reply with an
+// Unauthorized response.
+func TestOcsp_UnknownIssuerIdWithDefaultHavingOcspUsageRemoved(t *testing.T) {
+ t.Parallel()
+
+ b, s, testEnv := setupOcspEnv(t, "ec")
+ ctx := context.Background()
+
+ // Revoke the entry
+ serial := serialFromCert(testEnv.leafCertIssuer1)
+ resp, err := CBWrite(b, s, "revoke", map[string]interface{}{
+ "serial_number": serial,
+ })
+ requireSuccessNonNilResponse(t, resp, err, "revoke")
+
+ // Twiddle the entry so that the issuer id is no longer valid.
+ storagePath := revokedPath + normalizeSerial(serial)
+ var revInfo revocationInfo
+ revEntry, err := s.Get(ctx, storagePath)
+ require.NoError(t, err, "failed looking up storage path: %s", storagePath)
+ err = revEntry.DecodeJSON(&revInfo)
+ require.NoError(t, err, "failed decoding storage entry: %v", revEntry)
+ revInfo.CertificateIssuer = "00000000-0000-0000-0000-000000000000"
+ revEntry, err = logical.StorageEntryJSON(storagePath, revInfo)
+ require.NoError(t, err, "failed re-encoding revocation info: %v", revInfo)
+ err = s.Put(ctx, revEntry)
+ require.NoError(t, err, "failed writing out new revocation entry: %v", revEntry)
+
+ // Update our issuers to no longer have the OcspSigning usage
+ resp, err = CBPatch(b, s, "issuer/"+testEnv.issuerId1.String(), map[string]interface{}{
+ "usage": "read-only,issuing-certificates,crl-signing",
+ })
+ requireSuccessNonNilResponse(t, resp, err, "failed resetting usage flags on issuer1")
+ resp, err = CBPatch(b, s, "issuer/"+testEnv.issuerId2.String(), map[string]interface{}{
+ "usage": "read-only,issuing-certificates,crl-signing",
+ })
+ requireSuccessNonNilResponse(t, resp, err, "failed resetting usage flags on issuer2")
+
+ // Send the request
+ resp, err = sendOcspRequest(t, b, s, "get", testEnv.leafCertIssuer1, testEnv.issuer1, crypto.SHA1)
+ require.NoError(t, err)
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 401, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ require.Equal(t, ocsp.UnauthorizedErrorResponse, respDer)
+}
+
+// Verify that if we do have a revoked certificate entry for the request, that matches an
+// issuer but that issuer does not have the OcspUsage flag set that we return an Unauthorized
+// response back to the caller
+func TestOcsp_RevokedCertHasIssuerWithoutOcspUsage(t *testing.T) {
+ b, s, testEnv := setupOcspEnv(t, "ec")
+
+ // Revoke our certificate
+ resp, err := CBWrite(b, s, "revoke", map[string]interface{}{
+ "serial_number": serialFromCert(testEnv.leafCertIssuer1),
+ })
+ requireSuccessNonNilResponse(t, resp, err, "revoke")
+
+ // Update our issuer to no longer have the OcspSigning usage
+ resp, err = CBPatch(b, s, "issuer/"+testEnv.issuerId1.String(), map[string]interface{}{
+ "usage": "read-only,issuing-certificates,crl-signing",
+ })
+ requireSuccessNonNilResponse(t, resp, err, "failed resetting usage flags on issuer")
+ requireFieldsSetInResp(t, resp, "usage")
+
+ // Do not assume a specific ordering for usage...
+ usages, err := NewIssuerUsageFromNames(strings.Split(resp.Data["usage"].(string), ","))
+ require.NoError(t, err, "failed parsing usage return value")
+ require.True(t, usages.HasUsage(IssuanceUsage))
+ require.True(t, usages.HasUsage(CRLSigningUsage))
+ require.False(t, usages.HasUsage(OCSPSigningUsage))
+
+ // Request an OCSP request from it, we should get an Unauthorized response back
+ resp, err = sendOcspRequest(t, b, s, "get", testEnv.leafCertIssuer1, testEnv.issuer1, crypto.SHA1)
+ requireSuccessNonNilResponse(t, resp, err, "ocsp get request")
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 401, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ require.Equal(t, ocsp.UnauthorizedErrorResponse, respDer)
+}
+
+// Verify if our matching issuer for a revocation entry has no key associated with it that
+// we bail with an Unauthorized response.
+func TestOcsp_RevokedCertHasIssuerWithoutAKey(t *testing.T) {
+ b, s, testEnv := setupOcspEnv(t, "ec")
+
+ // Revoke our certificate
+ resp, err := CBWrite(b, s, "revoke", map[string]interface{}{
+ "serial_number": serialFromCert(testEnv.leafCertIssuer1),
+ })
+ requireSuccessNonNilResponse(t, resp, err, "revoke")
+
+ // Delete the key associated with our issuer
+ resp, err = CBRead(b, s, "issuer/"+testEnv.issuerId1.String())
+ requireSuccessNonNilResponse(t, resp, err, "failed reading issuer")
+ requireFieldsSetInResp(t, resp, "key_id")
+ keyId := resp.Data["key_id"].(keyID)
+
+ // This is a bit naughty but allow me to delete the key...
+ sc := b.makeStorageContext(context.Background(), s)
+ issuer, err := sc.fetchIssuerById(testEnv.issuerId1)
+ require.NoError(t, err, "failed to get issuer from storage")
+ issuer.KeyID = ""
+ err = sc.writeIssuer(issuer)
+ require.NoError(t, err, "failed to write issuer update")
+
+ resp, err = CBDelete(b, s, "key/"+keyId.String())
+ requireSuccessNonNilResponse(t, resp, err, "failed deleting key")
+
+ // Request an OCSP request from it, we should get an Unauthorized response back
+ resp, err = sendOcspRequest(t, b, s, "get", testEnv.leafCertIssuer1, testEnv.issuer1, crypto.SHA1)
+ requireSuccessNonNilResponse(t, resp, err, "ocsp get request")
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 401, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ require.Equal(t, ocsp.UnauthorizedErrorResponse, respDer)
+}
+
+func TestOcsp_ValidRequests(t *testing.T) {
+ t.Parallel()
+ type testArgs struct {
+ reqType string
+ caKeyType string
+ reqHash crypto.Hash
+ }
+ var tests []testArgs
+ for _, reqType := range []string{"get", "post"} {
+ for _, caKeyType := range []string{"rsa", "ec"} { // "ed25519" is not supported at the moment in x/crypto/ocsp
+ for _, requestHash := range []crypto.Hash{crypto.SHA1, crypto.SHA256} {
+ tests = append(tests, testArgs{
+ reqType: reqType,
+ caKeyType: caKeyType,
+ reqHash: requestHash,
+ })
+ }
+ }
+ }
+ for _, tt := range tests {
+ localTT := tt
+ testName := fmt.Sprintf("%s-%s-%s", localTT.reqType, localTT.caKeyType, localTT.reqHash)
+ t.Run(testName, func(t *testing.T) {
+ runOcspRequestTest(t, localTT.reqType, localTT.caKeyType, localTT.reqHash)
+ })
+ }
+}
+
+func runOcspRequestTest(t *testing.T, requestType string, caKeyType string, requestHash crypto.Hash) {
+ b, s, testEnv := setupOcspEnv(t, caKeyType)
+
+ // Non-revoked cert
+ resp, err := sendOcspRequest(t, b, s, requestType, testEnv.leafCertIssuer1, testEnv.issuer1, requestHash)
+ requireSuccessNonNilResponse(t, resp, err, "ocsp get request")
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 200, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer := resp.Data["http_raw_body"].([]byte)
+
+ ocspResp, err := ocsp.ParseResponse(respDer, testEnv.issuer1)
+ require.NoError(t, err, "parsing ocsp get response")
+
+ require.Equal(t, ocsp.Good, ocspResp.Status)
+ require.Equal(t, requestHash, ocspResp.IssuerHash)
+ require.Equal(t, testEnv.issuer1, ocspResp.Certificate)
+ require.Equal(t, 0, ocspResp.RevocationReason)
+ require.Equal(t, testEnv.leafCertIssuer1.SerialNumber, ocspResp.SerialNumber)
+
+ requireOcspSignatureAlgoForKey(t, testEnv.issuer1.PublicKey, ocspResp.SignatureAlgorithm)
+ requireOcspResponseSignedBy(t, ocspResp, testEnv.issuer1.PublicKey)
+
+ // Now revoke it
+ resp, err = CBWrite(b, s, "revoke", map[string]interface{}{
+ "serial_number": serialFromCert(testEnv.leafCertIssuer1),
+ })
+ requireSuccessNonNilResponse(t, resp, err, "revoke")
+
+ resp, err = sendOcspRequest(t, b, s, requestType, testEnv.leafCertIssuer1, testEnv.issuer1, requestHash)
+ requireSuccessNonNilResponse(t, resp, err, "ocsp get request with revoked")
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 200, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer = resp.Data["http_raw_body"].([]byte)
+
+ ocspResp, err = ocsp.ParseResponse(respDer, testEnv.issuer1)
+ require.NoError(t, err, "parsing ocsp get response with revoked")
+
+ require.Equal(t, ocsp.Revoked, ocspResp.Status)
+ require.Equal(t, requestHash, ocspResp.IssuerHash)
+ require.Equal(t, testEnv.issuer1, ocspResp.Certificate)
+ require.Equal(t, 0, ocspResp.RevocationReason)
+ require.Equal(t, testEnv.leafCertIssuer1.SerialNumber, ocspResp.SerialNumber)
+
+ requireOcspSignatureAlgoForKey(t, testEnv.issuer1.PublicKey, ocspResp.SignatureAlgorithm)
+ requireOcspResponseSignedBy(t, ocspResp, testEnv.issuer1.PublicKey)
+
+ // Request status for our second issuer
+ resp, err = sendOcspRequest(t, b, s, requestType, testEnv.leafCertIssuer2, testEnv.issuer2, requestHash)
+ requireSuccessNonNilResponse(t, resp, err, "ocsp get request")
+ requireFieldsSetInResp(t, resp, "http_content_type", "http_status_code", "http_raw_body")
+ require.Equal(t, 200, resp.Data["http_status_code"])
+ require.Equal(t, ocspResponseContentType, resp.Data["http_content_type"])
+ respDer = resp.Data["http_raw_body"].([]byte)
+
+ ocspResp, err = ocsp.ParseResponse(respDer, testEnv.issuer2)
+ require.NoError(t, err, "parsing ocsp get response")
+
+ require.Equal(t, ocsp.Good, ocspResp.Status)
+ require.Equal(t, requestHash, ocspResp.IssuerHash)
+ require.Equal(t, testEnv.issuer2, ocspResp.Certificate)
+ require.Equal(t, 0, ocspResp.RevocationReason)
+ require.Equal(t, testEnv.leafCertIssuer2.SerialNumber, ocspResp.SerialNumber)
+
+ requireOcspSignatureAlgoForKey(t, testEnv.issuer2.PublicKey, ocspResp.SignatureAlgorithm)
+ requireOcspResponseSignedBy(t, ocspResp, testEnv.issuer2.PublicKey)
+}
+
+func requireOcspSignatureAlgoForKey(t *testing.T, key crypto.PublicKey, algorithm x509.SignatureAlgorithm) {
+ switch key.(type) {
+ case *rsa.PublicKey:
+ require.Equal(t, x509.SHA256WithRSA, algorithm)
+ case *ecdsa.PublicKey:
+ require.Equal(t, x509.ECDSAWithSHA256, algorithm)
+ case ed25519.PublicKey:
+ require.Equal(t, x509.PureEd25519, algorithm)
+ default:
+ t.Fatalf("unsupported public key type %T", key)
+ }
+}
+
+type ocspTestEnv struct {
+ issuer1 *x509.Certificate
+ issuer2 *x509.Certificate
+
+ issuerId1 issuerID
+ issuerId2 issuerID
+
+ leafCertIssuer1 *x509.Certificate
+ leafCertIssuer2 *x509.Certificate
+}
+
+func setupOcspEnv(t *testing.T, keyType string) (*backend, logical.Storage, *ocspTestEnv) {
+ b, s := createBackendWithStorage(t)
+ var issuerCerts []*x509.Certificate
+ var leafCerts []*x509.Certificate
+ var issuerIds []issuerID
+
+ for i := 0; i < 2; i++ {
+ resp, err := CBWrite(b, s, "root/generate/internal", map[string]interface{}{
+ "key_type": keyType,
+ "ttl": "40h",
+ "common_name": "example-ocsp.com",
+ })
+ requireSuccessNonNilResponse(t, resp, err, "root/generate/internal")
+ requireFieldsSetInResp(t, resp, "issuer_id")
+ issuerId := resp.Data["issuer_id"].(issuerID)
+
+ resp, err = CBWrite(b, s, "roles/test"+strconv.FormatInt(int64(i), 10), map[string]interface{}{
+ "allow_bare_domains": true,
+ "allow_subdomains": true,
+ "allowed_domains": "foobar.com",
+ "no_store": false,
+ "generate_lease": false,
+ "issuer_ref": issuerId,
+ "key_type": keyType,
+ })
+ requireSuccessNilResponse(t, resp, err, "roles/test"+strconv.FormatInt(int64(i), 10))
+
+ resp, err = CBWrite(b, s, "issue/test"+strconv.FormatInt(int64(i), 10), map[string]interface{}{
+ "common_name": "test.foobar.com",
+ })
+ requireSuccessNonNilResponse(t, resp, err, "roles/test"+strconv.FormatInt(int64(i), 10))
+ requireFieldsSetInResp(t, resp, "certificate", "issuing_ca", "serial_number")
+ leafCert := parseCert(t, resp.Data["certificate"].(string))
+ issuingCa := parseCert(t, resp.Data["issuing_ca"].(string))
+
+ issuerCerts = append(issuerCerts, issuingCa)
+ leafCerts = append(leafCerts, leafCert)
+ issuerIds = append(issuerIds, issuerId)
+ }
+
+ testEnv := &ocspTestEnv{
+ issuerId1: issuerIds[0],
+ issuer1: issuerCerts[0],
+ leafCertIssuer1: leafCerts[0],
+
+ issuerId2: issuerIds[1],
+ issuer2: issuerCerts[1],
+ leafCertIssuer2: leafCerts[1],
+ }
+
+ return b, s, testEnv
+}
+
+func sendOcspRequest(t *testing.T, b *backend, s logical.Storage, getOrPost string, cert, issuer *x509.Certificate, requestHash crypto.Hash) (*logical.Response, error) {
+ ocspRequest := generateRequest(t, requestHash, cert, issuer)
+
+ switch strings.ToLower(getOrPost) {
+ case "get":
+ return sendOcspGetRequest(b, s, ocspRequest)
+ case "post":
+ return sendOcspPostRequest(b, s, ocspRequest)
+ default:
+ t.Fatalf("unsupported value for sendOcspRequest getOrPost arg: %s", getOrPost)
+ }
+ return nil, nil
+}
+
+func sendOcspGetRequest(b *backend, s logical.Storage, ocspRequest []byte) (*logical.Response, error) {
+ urlEncoded := base64.StdEncoding.EncodeToString(ocspRequest)
+ return CBRead(b, s, "ocsp/"+urlEncoded)
+}
+
+func sendOcspPostRequest(b *backend, s logical.Storage, ocspRequest []byte) (*logical.Response, error) {
+ reader := io.NopCloser(bytes.NewReader(ocspRequest))
+ resp, err := b.HandleRequest(context.Background(), &logical.Request{
+ Operation: logical.UpdateOperation,
+ Path: "ocsp",
+ Storage: s,
+ MountPoint: "pki/",
+ HTTPRequest: &http.Request{
+ Body: reader,
+ },
+ })
+
+ return resp, err
+}
+
+func generateRequest(t *testing.T, requestHash crypto.Hash, cert *x509.Certificate, issuer *x509.Certificate) []byte {
+ opts := &ocsp.RequestOptions{Hash: requestHash}
+ ocspRequestDer, err := ocsp.CreateRequest(cert, issuer, opts)
+ require.NoError(t, err, "Failed generating OCSP request")
+ return ocspRequestDer
+}
+
+func requireOcspResponseSignedBy(t *testing.T, ocspResp *ocsp.Response, key crypto.PublicKey) {
+ require.Contains(t, []x509.SignatureAlgorithm{x509.SHA256WithRSA, x509.ECDSAWithSHA256}, ocspResp.SignatureAlgorithm)
+
+ hasher := sha256.New()
+ hashAlgo := crypto.SHA256
+ hasher.Write(ocspResp.TBSResponseData)
+ hashData := hasher.Sum(nil)
+
+ switch key.(type) {
+ case *rsa.PublicKey:
+ err := rsa.VerifyPKCS1v15(key.(*rsa.PublicKey), hashAlgo, hashData, ocspResp.Signature)
+ require.NoError(t, err, "the ocsp response was not signed by the expected public rsa key.")
+ case *ecdsa.PublicKey:
+ verify := ecdsa.VerifyASN1(key.(*ecdsa.PublicKey), hashData, ocspResp.Signature)
+ require.True(t, verify, "the certificate was not signed by the expected public ecdsa key.")
+ }
+}
diff --git a/builtin/logical/pki/path_config_crl.go b/builtin/logical/pki/path_config_crl.go
index 3810c97e65..d1d806339e 100644
--- a/builtin/logical/pki/path_config_crl.go
+++ b/builtin/logical/pki/path_config_crl.go
@@ -12,8 +12,9 @@ import (
// CRLConfig holds basic CRL configuration information
type crlConfig struct {
- Expiry string `json:"expiry"`
- Disable bool `json:"disable"`
+ Expiry string `json:"expiry"`
+ Disable bool `json:"disable"`
+ OcspDisable bool `json:"ocsp_disable"`
}
func pathConfigCRL(b *backend) *framework.Path {
@@ -30,6 +31,10 @@ valid; defaults to 72 hours`,
Type: framework.TypeBool,
Description: `If set to true, disables generating the CRL entirely.`,
},
+ "ocsp_disable": {
+ Type: framework.TypeBool,
+ Description: `If set to true, ocsp unauthorized responses will be returned.`,
+ },
},
Operations: map[logical.Operation]framework.OperationHandler{
@@ -49,43 +54,25 @@ valid; defaults to 72 hours`,
}
}
-func (b *backend) CRL(ctx context.Context, s logical.Storage) (*crlConfig, error) {
- entry, err := s.Get(ctx, "config/crl")
- if err != nil {
- return nil, err
- }
-
- var result crlConfig
- result.Expiry = b.crlLifetime.String()
- result.Disable = false
-
- if entry == nil {
- return &result, nil
- }
-
- if err := entry.DecodeJSON(&result); err != nil {
- return nil, err
- }
-
- return &result, nil
-}
-
func (b *backend) pathCRLRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
- config, err := b.CRL(ctx, req.Storage)
+ sc := b.makeStorageContext(ctx, req.Storage)
+ config, err := sc.getRevocationConfig()
if err != nil {
return nil, err
}
return &logical.Response{
Data: map[string]interface{}{
- "expiry": config.Expiry,
- "disable": config.Disable,
+ "expiry": config.Expiry,
+ "disable": config.Disable,
+ "ocsp_disable": config.OcspDisable,
},
}, nil
}
func (b *backend) pathCRLWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
- config, err := b.CRL(ctx, req.Storage)
+ sc := b.makeStorageContext(ctx, req.Storage)
+ config, err := sc.getRevocationConfig()
if err != nil {
return nil, err
}
@@ -105,6 +92,12 @@ func (b *backend) pathCRLWrite(ctx context.Context, req *logical.Request, d *fra
config.Disable = disableRaw.(bool)
}
+ var oldOcspDisable bool
+ if ocspDisableRaw, ok := d.GetOk("ocsp_disable"); ok {
+ oldOcspDisable = config.OcspDisable
+ config.OcspDisable = ocspDisableRaw.(bool)
+ }
+
entry, err := logical.StorageEntryJSON("config/crl", config)
if err != nil {
return nil, err
@@ -127,6 +120,10 @@ func (b *backend) pathCRLWrite(ctx context.Context, req *logical.Request, d *fra
}
}
+ if oldOcspDisable != config.OcspDisable {
+ setOcspStatus(b, ctx)
+ }
+
return nil, nil
}
diff --git a/builtin/logical/pki/storage.go b/builtin/logical/pki/storage.go
index be7b88e37e..0145b2a37e 100644
--- a/builtin/logical/pki/storage.go
+++ b/builtin/logical/pki/storage.go
@@ -31,6 +31,8 @@ const (
maxRolesToScanOnIssuerChange = 100
maxRolesToFindOnIssuerChange = 10
+
+ latestIssuerVersion = 1
)
type keyID string
@@ -77,20 +79,22 @@ func (e keyEntry) isManagedPrivateKey() bool {
type issuerUsage uint
const (
- ReadOnlyUsage issuerUsage = iota
- IssuanceUsage issuerUsage = 1 << iota
- CRLSigningUsage issuerUsage = 1 << iota
+ ReadOnlyUsage issuerUsage = iota
+ IssuanceUsage issuerUsage = 1 << iota
+ CRLSigningUsage issuerUsage = 1 << iota
+ OCSPSigningUsage issuerUsage = 1 << iota
// When adding a new usage in the future, we'll need to create a usage
// mask field on the IssuerEntry and handle migrations to a newer mask,
// inferring a value for the new bits.
- AllIssuerUsages issuerUsage = ReadOnlyUsage | IssuanceUsage | CRLSigningUsage
+ AllIssuerUsages = ReadOnlyUsage | IssuanceUsage | CRLSigningUsage | OCSPSigningUsage
)
var namedIssuerUsages = map[string]issuerUsage{
"read-only": ReadOnlyUsage,
"issuing-certificates": IssuanceUsage,
"crl-signing": CRLSigningUsage,
+ "ocsp-signing": OCSPSigningUsage,
}
func (i *issuerUsage) ToggleUsage(usages ...issuerUsage) {
@@ -151,6 +155,7 @@ type issuerEntry struct {
RevocationTime int64 `json:"revocation_time"`
RevocationTimeUTC time.Time `json:"revocation_time_utc"`
AIAURIs *certutil.URLEntries `json:"aia_uris,omitempty"`
+ Version uint `json:"version"`
}
type localCRLConfigEntry struct {
@@ -204,7 +209,6 @@ func (sc *storageContext) fetchKeyById(keyId keyID) (*keyEntry, error) {
return nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch pki key: %v", err)}
}
if entry == nil {
- // FIXME: Dedicated/specific error for this?
return nil, errutil.UserError{Err: fmt.Sprintf("pki key id %s does not exist", keyId.String())}
}
@@ -561,7 +565,6 @@ func (sc *storageContext) fetchIssuerById(issuerId issuerID) (*issuerEntry, erro
return nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch pki issuer: %v", err)}
}
if entry == nil {
- // FIXME: Dedicated/specific error for this?
return nil, errutil.UserError{Err: fmt.Sprintf("pki issuer id %s does not exist", issuerId.String())}
}
@@ -570,7 +573,28 @@ func (sc *storageContext) fetchIssuerById(issuerId issuerID) (*issuerEntry, erro
return nil, errutil.InternalError{Err: fmt.Sprintf("unable to decode pki issuer with id %s: %v", issuerId.String(), err)}
}
- return &issuer, nil
+ return sc.upgradeIssuerIfRequired(&issuer), nil
+}
+
+func (sc *storageContext) upgradeIssuerIfRequired(issuer *issuerEntry) *issuerEntry {
+ // *NOTE*: Don't attempt to write out the issuer here as it may cause ErrReadOnly that will direct the
+ // request all the way up to the primary cluster which would be horrible for local cluster operations such
+ // as generating a leaf cert or a revoke.
+ // Also even though we could tell if we are the primary cluster's active node, we can't tell if we have the
+ // a full rw issuer lock, so it might not be safe to write.
+ if issuer.Version == latestIssuerVersion {
+ return issuer
+ }
+
+ if issuer.Version == 0 {
+ // handle our new OCSPSigning usage flag for earlier versions
+ if issuer.Usage.HasUsage(CRLSigningUsage) && !issuer.Usage.HasUsage(OCSPSigningUsage) {
+ issuer.Usage.ToggleUsage(OCSPSigningUsage)
+ }
+ }
+
+ issuer.Version = latestIssuerVersion
+ return issuer
}
func (sc *storageContext) writeIssuer(issuer *issuerEntry) error {
@@ -679,7 +703,8 @@ func (sc *storageContext) importIssuer(certValue string, issuerName string) (*is
result.Name = issuerName
result.Certificate = certValue
result.LeafNotAfterBehavior = certutil.ErrNotAfterBehavior
- result.Usage.ToggleUsage(IssuanceUsage, CRLSigningUsage)
+ result.Usage.ToggleUsage(AllIssuerUsages)
+ result.Version = latestIssuerVersion
// We shouldn't add CSRs or multiple certificates in this
countCertificates := strings.Count(result.Certificate, "-BEGIN ")
@@ -1048,3 +1073,22 @@ func (sc *storageContext) checkForRolesReferencing(issuerId string) (timeout boo
return false, inUseBy, nil
}
+
+func (sc *storageContext) getRevocationConfig() (*crlConfig, error) {
+ entry, err := sc.Storage.Get(sc.Context, "config/crl")
+ if err != nil {
+ return nil, err
+ }
+
+ var result crlConfig
+ if entry == nil {
+ result.Expiry = sc.Backend.crlLifetime.String()
+ return &result, nil
+ }
+
+ if err = entry.DecodeJSON(&result); err != nil {
+ return nil, err
+ }
+
+ return &result, nil
+}
diff --git a/builtin/logical/pki/storage_migrations.go b/builtin/logical/pki/storage_migrations.go
index c56815a3d2..79ff697124 100644
--- a/builtin/logical/pki/storage_migrations.go
+++ b/builtin/logical/pki/storage_migrations.go
@@ -193,7 +193,7 @@ func getLegacyCertBundle(ctx context.Context, s logical.Storage) (*issuerEntry,
SerialNumber: cb.SerialNumber,
LeafNotAfterBehavior: certutil.ErrNotAfterBehavior,
}
- issuer.Usage.ToggleUsage(IssuanceUsage, CRLSigningUsage)
+ issuer.Usage.ToggleUsage(AllIssuerUsages)
return issuer, cb, nil
}
diff --git a/builtin/logical/pki/storage_test.go b/builtin/logical/pki/storage_test.go
index 6adc2aa124..856175f045 100644
--- a/builtin/logical/pki/storage_test.go
+++ b/builtin/logical/pki/storage_test.go
@@ -164,6 +164,41 @@ func Test_KeysIssuerImport(t *testing.T) {
require.Equal(t, "", key2Ref.Name)
}
+func Test_IssuerUpgrade(t *testing.T) {
+ t.Parallel()
+ b, s := createBackendWithStorage(t)
+ sc := b.makeStorageContext(ctx, s)
+
+ // Make sure that we add OCSP signing to v0 issuers if CRLSigning is enabled
+ issuer, _ := genIssuerAndKey(t, b, s)
+ issuer.Version = 0
+ issuer.Usage.ToggleUsage(OCSPSigningUsage)
+
+ err := sc.writeIssuer(&issuer)
+ require.NoError(t, err, "failed writing out issuer")
+
+ newIssuer, err := sc.fetchIssuerById(issuer.ID)
+ require.NoError(t, err, "failed fetching issuer")
+
+ require.Equal(t, uint(1), newIssuer.Version)
+ require.True(t, newIssuer.Usage.HasUsage(OCSPSigningUsage))
+
+ // If CRLSigning is not present on a v0, we should not have OCSP signing after upgrade.
+ issuer, _ = genIssuerAndKey(t, b, s)
+ issuer.Version = 0
+ issuer.Usage.ToggleUsage(OCSPSigningUsage)
+ issuer.Usage.ToggleUsage(CRLSigningUsage)
+
+ err = sc.writeIssuer(&issuer)
+ require.NoError(t, err, "failed writing out issuer")
+
+ newIssuer, err = sc.fetchIssuerById(issuer.ID)
+ require.NoError(t, err, "failed fetching issuer")
+
+ require.Equal(t, uint(1), newIssuer.Version)
+ require.False(t, newIssuer.Usage.HasUsage(OCSPSigningUsage))
+}
+
func genIssuerAndKey(t *testing.T, b *backend, s logical.Storage) (issuerEntry, keyEntry) {
certBundle := genCertBundle(t, b, s)
@@ -183,6 +218,8 @@ func genIssuerAndKey(t *testing.T, b *backend, s logical.Storage) (issuerEntry,
Certificate: strings.TrimSpace(certBundle.Certificate) + "\n",
CAChain: certBundle.CAChain,
SerialNumber: certBundle.SerialNumber,
+ Usage: AllIssuerUsages,
+ Version: latestIssuerVersion,
}
return pkiIssuer, pkiKey
diff --git a/builtin/logical/pki/test_helpers.go b/builtin/logical/pki/test_helpers.go
index 80e9a7bc51..6e6ecff376 100644
--- a/builtin/logical/pki/test_helpers.go
+++ b/builtin/logical/pki/test_helpers.go
@@ -295,3 +295,27 @@ func CBList(b *backend, s logical.Storage, path string) (*logical.Response, erro
func CBDelete(b *backend, s logical.Storage, path string) (*logical.Response, error) {
return CBReq(b, s, logical.DeleteOperation, path, make(map[string]interface{}))
}
+
+func requireFieldsSetInResp(t *testing.T, resp *logical.Response, fields ...string) {
+ var missingFields []string
+ for _, field := range fields {
+ value, ok := resp.Data[field]
+ if !ok || value == nil {
+ missingFields = append(missingFields, field)
+ }
+ }
+
+ require.Empty(t, missingFields, "The following fields were required but missing from response:\n%v", resp.Data)
+}
+
+func requireSuccessNonNilResponse(t *testing.T, resp *logical.Response, err error, msgAndArgs ...interface{}) {
+ require.NoError(t, err, msgAndArgs...)
+ require.False(t, resp.IsError(), msgAndArgs...)
+ require.NotNil(t, resp, msgAndArgs...)
+}
+
+func requireSuccessNilResponse(t *testing.T, resp *logical.Response, err error, msgAndArgs ...interface{}) {
+ require.NoError(t, err, msgAndArgs...)
+ require.False(t, resp.IsError(), msgAndArgs...)
+ require.Nil(t, resp, msgAndArgs...)
+}
diff --git a/builtin/logical/pki/util.go b/builtin/logical/pki/util.go
index feabc2855a..bde344b9a5 100644
--- a/builtin/logical/pki/util.go
+++ b/builtin/logical/pki/util.go
@@ -4,6 +4,7 @@ import (
"crypto"
"crypto/x509"
"fmt"
+ "math/big"
"regexp"
"strings"
@@ -27,7 +28,11 @@ var (
)
func serialFromCert(cert *x509.Certificate) string {
- return strings.TrimSpace(certutil.GetHexFormatted(cert.SerialNumber.Bytes(), ":"))
+ return serialFromBigInt(cert.SerialNumber)
+}
+
+func serialFromBigInt(serial *big.Int) string {
+ return strings.TrimSpace(certutil.GetHexFormatted(serial.Bytes(), ":"))
}
func normalizeSerial(serial string) string {
diff --git a/changelog/16723.txt b/changelog/16723.txt
new file mode 100644
index 0000000000..68753265e3
--- /dev/null
+++ b/changelog/16723.txt
@@ -0,0 +1,4 @@
+```release-note:feature
+secrets/pki: Add an OCSP responder that implements a subset of RFC6960, answering single serial number OCSP requests for
+a specific cluster's revoked certificates in a mount.
+```
diff --git a/http/logical.go b/http/logical.go
index 34c60d18ee..bf5ce06c93 100644
--- a/http/logical.go
+++ b/http/logical.go
@@ -103,10 +103,11 @@ func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.
bufferedBody := newBufferedReader(r.Body)
r.Body = bufferedBody
- // If we are uploading a snapshot we don't want to parse it. Instead
- // we will simply add the HTTP request to the logical request object
- // for later consumption.
- if path == "sys/storage/raft/snapshot" || path == "sys/storage/raft/snapshot-force" {
+ // If we are uploading a snapshot or receiving an ocsp-request (which
+ // is der encoded) we don't want to parse it. Instead, we will simply
+ // add the HTTP request to the logical request object for later consumption.
+ contentType := r.Header.Get("Content-Type")
+ if path == "sys/storage/raft/snapshot" || path == "sys/storage/raft/snapshot-force" || isOcspRequest(contentType) {
passHTTPReq = true
origBody = r.Body
} else {
@@ -121,7 +122,7 @@ func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.
return nil, nil, status, fmt.Errorf("error reading data")
}
- if isForm(head, r.Header.Get("Content-Type")) {
+ if isForm(head, contentType) {
formData, err := parseFormRequest(r)
if err != nil {
status := http.StatusBadRequest
@@ -209,6 +210,15 @@ func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.
return req, origBody, 0, nil
}
+func isOcspRequest(contentType string) bool {
+ contentType, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return false
+ }
+
+ return contentType == "application/ocsp-request"
+}
+
func buildLogicalPath(r *http.Request) (string, int, error) {
ns, err := namespace.FromContext(r.Context())
if err != nil {
diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx
index a7d378ec39..0aa5ad3573 100644
--- a/website/content/api-docs/secret/pki.mdx
+++ b/website/content/api-docs/secret/pki.mdx
@@ -34,6 +34,7 @@ update your API calls accordingly.
- [Read Issuer Certificate](#read-issuer-certificate)
- [Read Default Issuer Certificate Chain](#read-default-issuer-certificate-chain)
- [Read Issuer CRL](#read-issuer-crl)
+ - [OCSP Request](#ocsp-request)
- [List Certificates](#list-certificates)
- [Read Certificate](#read-certificate)
- [Managing Keys and Issuers](#managing-keys-and-issuers)
@@ -1143,6 +1144,34 @@ $ curl \
}
```
+### OCSP Request
+
+This endpoint retrieves an OCSP response (revocation status) for a given serial number. The request/response formats are
+based on [RFC 6960](https://datatracker.ietf.org/doc/html/rfc6960)
+
+At this time there are certain limitations of the OCSP implementation at this path.
+ 1. Only a single serial number within the request will appear in the response
+ 1. None of the extensions defined in the RFC are supported for requests or responses
+ 1. Ed25519 backed CA's are not supported for OCSP requests
+ 1. Note that this api will not work with the Vault client as both request and responses are DER encoded
+
+These are unauthenticated endpoints.
+
+| Method | Path | Response Format |
+| :----- |:-----------------------------------------------|:----------------------------------------------------------------------------------|
+| `GET` | `/pki/ocsp/` | DER [\[1\]](#vault-cli-with-der-pem-responses "Vault CLI With DER/PEM Responses") |
+| `POST` | `/pki/ocsp` | DER [\[1\]](#vault-cli-with-der-pem-responses "Vault CLI With DER/PEM Responses") |
+
+#### Parameters
+
+ - None
+
+#### Sample Request
+
+```shell-session
+openssl ocsp -no_nonce -issuer issuer.pem -CAfile ca_chain.pem -cert cert-to-revoke.pem -text -url $VAULT_ADDR/v1/pki/ocsp
+```
+
### List Certificates
This endpoint returns a list of the current certificates by serial number only.
@@ -1891,7 +1920,7 @@ $ curl \
"key_id": "baadd98d-ec5a-66ac-06b7-dfc91c02c9cf",
"leaf_not_after_behavior": "truncate",
"manual_chain": null,
- "usage": "read-only,issuing-certificates,crl-signing"
+ "usage": "read-only,issuing-certificates,crl-signing,ocsp-signing"
}
}
```
@@ -1966,18 +1995,19 @@ do so, import a new issuer and a new `issuer_id` will be assigned.
the `/ca_chain` path. Setting `manual_chain` thus allows controlling
the presented chain as desired.
-- `usage` `([]string: read-only,issuing-certificates,crl-signing)` - Allowed
+- `usage` `([]string: read-only,issuing-certificates,crl-signing,ocsp-signing)` - Allowed
usages for this issuer. Valid options are:
- `read-only`, to allow this issuer to be read; implict; always allowed;
- `issuing-certificates`, to allow this issuer to be used for issuing other
certificates; or
- `crl-signing`, to allow this issuer to be used for signing CRLs.
+ - `ocsp-signing`, to allow this issuer to be used for signing OCSP responses
~> Note: The `usage` field allows for a soft-delete capability on the issuer,
or to prevent use of the issuer prior to it being enabled. For example,
as issuance is rotated to a new issuer, the old issuer could be marked
- `usage=read-only,crl-signing`, allowing existing certificates to be revoked
+ `usage=read-only,crl-signing,ocsp-signing`, allowing existing certificates to be revoked
(and the CRL updated), but preventing new certificate issuance. After all
certificates issued under this certificate have expired, this certificate
could be marked `usage=read-only`, freezing the CRL. Finally, after a grace
@@ -2051,7 +2081,7 @@ $ curl \
"key_id": "baadd98d-ec5a-66ac-06b7-dfc91c02c9cf",
"leaf_not_after_behavior": "truncate",
"manual_chain": null,
- "usage": "read-only,issuing-certificates,crl-signing",
+ "usage": "read-only,issuing-certificates,crl-signing,ocsp-signing",
"revocation_signature_algorithm": "",
"issuing_certificates": ["", ""],
"crl_distribution_points": ["", ""],
@@ -2952,7 +2982,8 @@ $ curl \
"lease_duration": 0,
"data": {
"disable": false,
- "expiry": "72h"
+ "expiry": "72h",
+ "ocsp_disable": false
},
"auth": null
}
@@ -2964,6 +2995,9 @@ This endpoint allows setting the duration for which the generated CRL should be
marked valid. If the CRL is disabled, it will return a signed but zero-length
CRL for any request. If enabled, it will re-build the CRL.
+If the `ocsp_disable` key is set to `true`, the OCSP responder will always
+respond with an Unauthorized OCSP response to any request.
+
~> **Note**: This parameter is global, across all clusters and issuers. Use
the per-issuer `usage` field to disable CRL building for a specific
issuer, while leaving the global CRL building enabled.
@@ -2983,12 +3017,15 @@ the CRL.
- `expiry` `(string: "72h")` - The amount of time the generated CRL should be valid.
- `disable` `(bool: false)` - Disables or enables CRL building.
+- `ocsp_disable` `(bool: false)` - Disables or enables the OCSP responder in Vault.
#### Sample Payload
```json
{
- "expiry": "48h"
+ "expiry": "48h",
+ "disable": "false",
+ "ocsp_disable": "false"
}
```