Add an OCSP responder to Vault's PKI plugin (#16723)

* Refactor existing CRL function to storage getRevocationConfig

* Introduce ocsp_disable config option in config/crl

* Introduce OCSPSigning usage flag on issuer

* Add ocsp-request passthrough within lower layers of Vault

* Add OCSP responder to Vault PKI

* Add API documentation for OCSP

* Add cl

* Revert PKI storage migration modifications for OCSP

* Smaller PR feedback items

 - pki.mdx doc update
 - parens around logical.go comment to indicate DER encoded request is
   related to OCSP and not the snapshots
 - Use AllIssuers instead of writing them all out
 - Drop zero initialization of crl config's Disable flag if not present
 - Upgrade issuer on the fly instead of an initial migration

* Additional clean up backing out the writeRevocationConfig refactoring

* Remove Dirty issuer flag and update comment about not writing upgrade to
storage

* Address PR feedback and return Unknown response when mismatching issuer

* make fmt

* PR Feedback.

* More PR feedback

 - Leverage ocsp response constant
 - Remove duplicate errors regarding unknown issuers
This commit is contained in:
Steven Clark
2022-08-22 14:06:15 -04:00
committed by GitHub
parent 867c3bc11e
commit c8fb36a377
15 changed files with 1240 additions and 52 deletions

View File

@@ -8,6 +8,8 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
atomic2 "go.uber.org/atomic"
"github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/helper/consts"
"github.com/armon/go-metrics" "github.com/armon/go-metrics"
@@ -84,6 +86,8 @@ func Backend(conf *logical.BackendConfig) *backend {
"issuer/+/der", "issuer/+/der",
"issuer/+/json", "issuer/+/json",
"issuers", "issuers",
"ocsp", // OCSP POST
"ocsp/*", // OCSP GET
}, },
LocalStorage: []string{ LocalStorage: []string{
@@ -158,6 +162,10 @@ func Backend(conf *logical.BackendConfig) *backend {
pathFetchValidRaw(&b), pathFetchValidRaw(&b),
pathFetchValid(&b), pathFetchValid(&b),
pathFetchListCerts(&b), pathFetchListCerts(&b),
// OCSP APIs
buildPathOcspGet(&b),
buildPathOcspPost(&b),
}, },
Secrets: []*framework.Secret{ Secrets: []*framework.Secret{
@@ -179,6 +187,7 @@ func Backend(conf *logical.BackendConfig) *backend {
b.pkiStorageVersion.Store(0) b.pkiStorageVersion.Store(0)
b.crlBuilder = &crlBuilder{} b.crlBuilder = &crlBuilder{}
b.isOcspDisabled = atomic2.NewBool(false)
return &b return &b
} }
@@ -199,6 +208,9 @@ type backend struct {
// Write lock around issuers and keys. // Write lock around issuers and keys.
issuersLock sync.RWMutex issuersLock sync.RWMutex
// Optimization to not read the CRL config on every OCSP request.
isOcspDisabled *atomic2.Bool
} }
type ( 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 // initialize is used to perform a possible PKI storage migration if needed
func (b *backend) initialize(ctx context.Context, _ *logical.InitializationRequest) error { 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 // 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. // the storage flag midway through the request stream of other requests.
b.issuersLock.Lock() b.issuersLock.Lock()
@@ -320,6 +335,14 @@ func (b *backend) initialize(ctx context.Context, _ *logical.InitializationReque
return nil 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 { func (b *backend) useLegacyBundleCaStorage() bool {
// This helper function is here to choose whether or not we use the newer // 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. // 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 { } else {
b.Logger().Debug("Ignoring invalidation updates for issuer as the PKI migration has yet to complete.") 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)
} }
} }

View File

@@ -15,6 +15,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math/big"
"net" "net"
"net/url" "net/url"
"regexp" "regexp"
@@ -150,6 +151,10 @@ func (sc *storageContext) fetchCAInfoByIssuerId(issuerId issuerID, usage issuerU
return caInfo, nil 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 // Allows fetching certificates from the backend; it handles the slightly
// separate pathing for CRL, and revoked certificates. // separate pathing for CRL, and revoked certificates.
// //
@@ -915,6 +920,7 @@ func signCert(b *backend,
// otherNameRaw describes a name related to a certificate which is not in one // otherNameRaw describes a name related to a certificate which is not in one
// of the standard name formats. RFC 5280, 4.2.1.6: // of the standard name formats. RFC 5280, 4.2.1.6:
//
// OtherName ::= SEQUENCE { // OtherName ::= SEQUENCE {
// type-id OBJECT IDENTIFIER, // type-id OBJECT IDENTIFIER,
// value [0] EXPLICIT ANY DEFINED BY type-id } // value [0] EXPLICIT ANY DEFINED BY type-id }

View File

@@ -3,6 +3,7 @@ package pki
import ( import (
"context" "context"
"encoding/asn1" "encoding/asn1"
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -26,6 +27,64 @@ func TestBackend_CRL_EnableDisableRoot(t *testing.T) {
crlEnableDisableTestForBackend(t, b, s, []string{caSerial}) 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) { func TestBackend_CRL_AllKeyTypeSigAlgos(t *testing.T) {
type testCase struct { type testCase struct {
KeyType string KeyType string

View File

@@ -611,7 +611,7 @@ func augmentWithRevokedIssuers(issuerIDEntryMap map[issuerID]*issuerEntry, issue
// Builds a CRL by going through the list of revoked certificates and building // Builds a CRL by going through the list of revoked certificates and building
// a new CRL with the stored revocation times and serial numbers. // 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 { 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 { if err != nil {
return errutil.InternalError{Err: fmt.Sprintf("error fetching CRL config information: %s", err)} return errutil.InternalError{Err: fmt.Sprintf("error fetching CRL config information: %s", err)}
} }

396
builtin/logical/pki/ocsp.go Normal file
View File

@@ -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
`

View File

@@ -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.")
}
}

View File

@@ -14,6 +14,7 @@ import (
type crlConfig struct { type crlConfig struct {
Expiry string `json:"expiry"` Expiry string `json:"expiry"`
Disable bool `json:"disable"` Disable bool `json:"disable"`
OcspDisable bool `json:"ocsp_disable"`
} }
func pathConfigCRL(b *backend) *framework.Path { func pathConfigCRL(b *backend) *framework.Path {
@@ -30,6 +31,10 @@ valid; defaults to 72 hours`,
Type: framework.TypeBool, Type: framework.TypeBool,
Description: `If set to true, disables generating the CRL entirely.`, 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{ Operations: map[logical.Operation]framework.OperationHandler{
@@ -49,29 +54,9 @@ 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -80,12 +65,14 @@ func (b *backend) pathCRLRead(ctx context.Context, req *logical.Request, _ *fram
Data: map[string]interface{}{ Data: map[string]interface{}{
"expiry": config.Expiry, "expiry": config.Expiry,
"disable": config.Disable, "disable": config.Disable,
"ocsp_disable": config.OcspDisable,
}, },
}, nil }, nil
} }
func (b *backend) pathCRLWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -105,6 +92,12 @@ func (b *backend) pathCRLWrite(ctx context.Context, req *logical.Request, d *fra
config.Disable = disableRaw.(bool) 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) entry, err := logical.StorageEntryJSON("config/crl", config)
if err != nil { if err != nil {
return nil, err 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 return nil, nil
} }

View File

@@ -31,6 +31,8 @@ const (
maxRolesToScanOnIssuerChange = 100 maxRolesToScanOnIssuerChange = 100
maxRolesToFindOnIssuerChange = 10 maxRolesToFindOnIssuerChange = 10
latestIssuerVersion = 1
) )
type keyID string type keyID string
@@ -80,17 +82,19 @@ const (
ReadOnlyUsage issuerUsage = iota ReadOnlyUsage issuerUsage = iota
IssuanceUsage issuerUsage = 1 << iota IssuanceUsage issuerUsage = 1 << iota
CRLSigningUsage 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 // 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, // mask field on the IssuerEntry and handle migrations to a newer mask,
// inferring a value for the new bits. // inferring a value for the new bits.
AllIssuerUsages issuerUsage = ReadOnlyUsage | IssuanceUsage | CRLSigningUsage AllIssuerUsages = ReadOnlyUsage | IssuanceUsage | CRLSigningUsage | OCSPSigningUsage
) )
var namedIssuerUsages = map[string]issuerUsage{ var namedIssuerUsages = map[string]issuerUsage{
"read-only": ReadOnlyUsage, "read-only": ReadOnlyUsage,
"issuing-certificates": IssuanceUsage, "issuing-certificates": IssuanceUsage,
"crl-signing": CRLSigningUsage, "crl-signing": CRLSigningUsage,
"ocsp-signing": OCSPSigningUsage,
} }
func (i *issuerUsage) ToggleUsage(usages ...issuerUsage) { func (i *issuerUsage) ToggleUsage(usages ...issuerUsage) {
@@ -151,6 +155,7 @@ type issuerEntry struct {
RevocationTime int64 `json:"revocation_time"` RevocationTime int64 `json:"revocation_time"`
RevocationTimeUTC time.Time `json:"revocation_time_utc"` RevocationTimeUTC time.Time `json:"revocation_time_utc"`
AIAURIs *certutil.URLEntries `json:"aia_uris,omitempty"` AIAURIs *certutil.URLEntries `json:"aia_uris,omitempty"`
Version uint `json:"version"`
} }
type localCRLConfigEntry struct { 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)} return nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch pki key: %v", err)}
} }
if entry == nil { 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())} 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)} return nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch pki issuer: %v", err)}
} }
if entry == nil { 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())} 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 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 { func (sc *storageContext) writeIssuer(issuer *issuerEntry) error {
@@ -679,7 +703,8 @@ func (sc *storageContext) importIssuer(certValue string, issuerName string) (*is
result.Name = issuerName result.Name = issuerName
result.Certificate = certValue result.Certificate = certValue
result.LeafNotAfterBehavior = certutil.ErrNotAfterBehavior 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 // We shouldn't add CSRs or multiple certificates in this
countCertificates := strings.Count(result.Certificate, "-BEGIN ") countCertificates := strings.Count(result.Certificate, "-BEGIN ")
@@ -1048,3 +1073,22 @@ func (sc *storageContext) checkForRolesReferencing(issuerId string) (timeout boo
return false, inUseBy, nil 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
}

View File

@@ -193,7 +193,7 @@ func getLegacyCertBundle(ctx context.Context, s logical.Storage) (*issuerEntry,
SerialNumber: cb.SerialNumber, SerialNumber: cb.SerialNumber,
LeafNotAfterBehavior: certutil.ErrNotAfterBehavior, LeafNotAfterBehavior: certutil.ErrNotAfterBehavior,
} }
issuer.Usage.ToggleUsage(IssuanceUsage, CRLSigningUsage) issuer.Usage.ToggleUsage(AllIssuerUsages)
return issuer, cb, nil return issuer, cb, nil
} }

View File

@@ -164,6 +164,41 @@ func Test_KeysIssuerImport(t *testing.T) {
require.Equal(t, "", key2Ref.Name) 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) { func genIssuerAndKey(t *testing.T, b *backend, s logical.Storage) (issuerEntry, keyEntry) {
certBundle := genCertBundle(t, b, s) 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", Certificate: strings.TrimSpace(certBundle.Certificate) + "\n",
CAChain: certBundle.CAChain, CAChain: certBundle.CAChain,
SerialNumber: certBundle.SerialNumber, SerialNumber: certBundle.SerialNumber,
Usage: AllIssuerUsages,
Version: latestIssuerVersion,
} }
return pkiIssuer, pkiKey return pkiIssuer, pkiKey

View File

@@ -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) { func CBDelete(b *backend, s logical.Storage, path string) (*logical.Response, error) {
return CBReq(b, s, logical.DeleteOperation, path, make(map[string]interface{})) 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...)
}

View File

@@ -4,6 +4,7 @@ import (
"crypto" "crypto"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"math/big"
"regexp" "regexp"
"strings" "strings"
@@ -27,7 +28,11 @@ var (
) )
func serialFromCert(cert *x509.Certificate) string { 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 { func normalizeSerial(serial string) string {

4
changelog/16723.txt Normal file
View File

@@ -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.
```

View File

@@ -103,10 +103,11 @@ func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.
bufferedBody := newBufferedReader(r.Body) bufferedBody := newBufferedReader(r.Body)
r.Body = bufferedBody r.Body = bufferedBody
// If we are uploading a snapshot we don't want to parse it. Instead // If we are uploading a snapshot or receiving an ocsp-request (which
// we will simply add the HTTP request to the logical request object // is der encoded) we don't want to parse it. Instead, we will simply
// for later consumption. // add the HTTP request to the logical request object for later consumption.
if path == "sys/storage/raft/snapshot" || path == "sys/storage/raft/snapshot-force" { contentType := r.Header.Get("Content-Type")
if path == "sys/storage/raft/snapshot" || path == "sys/storage/raft/snapshot-force" || isOcspRequest(contentType) {
passHTTPReq = true passHTTPReq = true
origBody = r.Body origBody = r.Body
} else { } else {
@@ -121,7 +122,7 @@ func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.
return nil, nil, status, fmt.Errorf("error reading data") 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) formData, err := parseFormRequest(r)
if err != nil { if err != nil {
status := http.StatusBadRequest status := http.StatusBadRequest
@@ -209,6 +210,15 @@ func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.
return req, origBody, 0, nil 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) { func buildLogicalPath(r *http.Request) (string, int, error) {
ns, err := namespace.FromContext(r.Context()) ns, err := namespace.FromContext(r.Context())
if err != nil { if err != nil {

View File

@@ -34,6 +34,7 @@ update your API calls accordingly.
- [Read Issuer Certificate](#read-issuer-certificate) - [Read Issuer Certificate](#read-issuer-certificate)
- [Read Default Issuer Certificate Chain](#read-default-issuer-certificate-chain) - [Read Default Issuer Certificate Chain](#read-default-issuer-certificate-chain)
- [Read Issuer CRL](#read-issuer-crl) - [Read Issuer CRL](#read-issuer-crl)
- [OCSP Request](#ocsp-request)
- [List Certificates](#list-certificates) - [List Certificates](#list-certificates)
- [Read Certificate](#read-certificate) - [Read Certificate](#read-certificate)
- [Managing Keys and Issuers](#managing-keys-and-issuers) - [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/<base 64 encoded ocsp DER request>` | 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 ### List Certificates
This endpoint returns a list of the current certificates by serial number only. 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", "key_id": "baadd98d-ec5a-66ac-06b7-dfc91c02c9cf",
"leaf_not_after_behavior": "truncate", "leaf_not_after_behavior": "truncate",
"manual_chain": null, "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 `/ca_chain` path. Setting `manual_chain` thus allows controlling
the presented chain as desired. 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: usages for this issuer. Valid options are:
- `read-only`, to allow this issuer to be read; implict; always allowed; - `read-only`, to allow this issuer to be read; implict; always allowed;
- `issuing-certificates`, to allow this issuer to be used for issuing other - `issuing-certificates`, to allow this issuer to be used for issuing other
certificates; or certificates; or
- `crl-signing`, to allow this issuer to be used for signing CRLs. - `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, ~> 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, 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 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 (and the CRL updated), but preventing new certificate issuance. After all
certificates issued under this certificate have expired, this certificate certificates issued under this certificate have expired, this certificate
could be marked `usage=read-only`, freezing the CRL. Finally, after a grace 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", "key_id": "baadd98d-ec5a-66ac-06b7-dfc91c02c9cf",
"leaf_not_after_behavior": "truncate", "leaf_not_after_behavior": "truncate",
"manual_chain": null, "manual_chain": null,
"usage": "read-only,issuing-certificates,crl-signing", "usage": "read-only,issuing-certificates,crl-signing,ocsp-signing",
"revocation_signature_algorithm": "", "revocation_signature_algorithm": "",
"issuing_certificates": ["<url1>", "<url2>"], "issuing_certificates": ["<url1>", "<url2>"],
"crl_distribution_points": ["<url1>", "<url2>"], "crl_distribution_points": ["<url1>", "<url2>"],
@@ -2952,7 +2982,8 @@ $ curl \
"lease_duration": 0, "lease_duration": 0,
"data": { "data": {
"disable": false, "disable": false,
"expiry": "72h" "expiry": "72h",
"ocsp_disable": false
}, },
"auth": null "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 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. 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 ~> **Note**: This parameter is global, across all clusters and issuers. Use
the per-issuer `usage` field to disable CRL building for a specific the per-issuer `usage` field to disable CRL building for a specific
issuer, while leaving the global CRL building enabled. 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. - `expiry` `(string: "72h")` - The amount of time the generated CRL should be valid.
- `disable` `(bool: false)` - Disables or enables CRL building. - `disable` `(bool: false)` - Disables or enables CRL building.
- `ocsp_disable` `(bool: false)` - Disables or enables the OCSP responder in Vault.
#### Sample Payload #### Sample Payload
```json ```json
{ {
"expiry": "48h" "expiry": "48h",
"disable": "false",
"ocsp_disable": "false"
} }
``` ```