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" } ```