Add ACME revocation handlers (#20340)
* Add ACME revocation handlers This refactors path_revoke to expose Proof of Possession verification, which is reused by ACME to allow methods 1 and 2: 1. Revocation of a certificate issued by the account, using account signature as sufficient proof. 2. Revocation of a certificate via proving possession of its private key, using this private key to create the JWS signature. We do not support the third mechanism, completing challenges equivalent to those on the existing certificate and then performing a revocation under an account which didn't issue the certificate but which did solve those challenges. We additionally create another map account->cert->order, allowing us to quickly look up if a cert was issued by this particular account. Note that the inverse lookup of cert->(account, order) lookup isn't yet possible due to Vault's storage structure. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Update ACME pkiext tests to revoke certs Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add auth handler checks Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Address review feedback Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> --------- Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
parent
3a995707b5
commit
d3722a33c8
|
@ -519,6 +519,54 @@ func (a *acmeState) ListOrderIds(ac *acmeContext, accountId string) ([]string, e
|
|||
return orderIds, nil
|
||||
}
|
||||
|
||||
type acmeCertEntry struct {
|
||||
Serial string `json:"-"`
|
||||
Account string `json:"-"`
|
||||
Order string `json:"order"`
|
||||
}
|
||||
|
||||
func (a *acmeState) TrackIssuedCert(ac *acmeContext, accountId string, serial string, orderId string) error {
|
||||
path := acmeAccountPrefix + accountId + "/certs/" + normalizeSerial(serial)
|
||||
entry := acmeCertEntry{
|
||||
Order: orderId,
|
||||
}
|
||||
|
||||
json, err := logical.StorageEntryJSON(path, &entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error serializing acme cert entry: %w", err)
|
||||
}
|
||||
|
||||
if err = ac.sc.Storage.Put(ac.sc.Context, json); err != nil {
|
||||
return fmt.Errorf("error writing acme cert entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *acmeState) GetIssuedCert(ac *acmeContext, accountId string, serial string) (*acmeCertEntry, error) {
|
||||
path := acmeAccountPrefix + accountId + "/certs/" + normalizeSerial(serial)
|
||||
|
||||
entry, err := ac.sc.Storage.Get(ac.sc.Context, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading acme cert entry: %w", err)
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("no certificate with this serial was issued for this account")
|
||||
}
|
||||
|
||||
var cert acmeCertEntry
|
||||
err = entry.DecodeJSON(&cert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding acme cert entry: %w", err)
|
||||
}
|
||||
|
||||
cert.Serial = denormalizeSerial(serial)
|
||||
cert.Account = accountId
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
func getAuthorizationPath(accountId string, authId string) string {
|
||||
return acmeAccountPrefix + accountId + "/authorizations/" + authId
|
||||
}
|
||||
|
|
|
@ -243,6 +243,7 @@ func Backend(conf *logical.BackendConfig) *backend {
|
|||
acmePaths = append(acmePaths, pathAcmeFetchOrderCert(&b)...)
|
||||
acmePaths = append(acmePaths, pathAcmeChallenge(&b)...)
|
||||
acmePaths = append(acmePaths, pathAcmeAuthorization(&b)...)
|
||||
acmePaths = append(acmePaths, pathAcmeRevoke(&b)...)
|
||||
|
||||
for _, acmePath := range acmePaths {
|
||||
b.Backend.Paths = append(b.Backend.Paths, acmePath)
|
||||
|
|
|
@ -6816,6 +6816,7 @@ func TestProperAuthing(t *testing.T) {
|
|||
paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList
|
||||
paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList
|
||||
paths[acmePrefix+"acme/new-account"] = shouldBeUnauthedWriteOnly
|
||||
paths[acmePrefix+"acme/revoke-cert"] = shouldBeUnauthedWriteOnly
|
||||
paths[acmePrefix+"acme/new-order"] = shouldBeUnauthedWriteOnly
|
||||
paths[acmePrefix+"acme/orders"] = shouldBeUnauthedWriteOnly
|
||||
paths[acmePrefix+"acme/account/hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo="] = shouldBeUnauthedWriteOnly
|
||||
|
|
|
@ -268,6 +268,11 @@ func (b *backend) acmeFinalizeOrderHandler(ac *acmeContext, _ *logical.Request,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if err := b.acmeState.TrackIssuedCert(ac, order.AccountId, hyphenSerialNumber, order.OrderId); err != nil {
|
||||
b.Logger().Warn("orphaned generated ACME certificate due to error saving account->cert->order reference", "serial_number", hyphenSerialNumber, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
order.Status = ACMEOrderValid
|
||||
order.CertificateSerialNumber = hyphenSerialNumber
|
||||
order.CertificateExpiry = signedCertBundle.Certificate.NotAfter
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package pki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
func pathAcmeRevoke(b *backend) []*framework.Path {
|
||||
return buildAcmeFrameworkPaths(b, patternAcmeRevoke, "/revoke-cert")
|
||||
}
|
||||
|
||||
func patternAcmeRevoke(b *backend, pattern string) *framework.Path {
|
||||
fields := map[string]*framework.FieldSchema{}
|
||||
addFieldsForACMEPath(fields, pattern)
|
||||
addFieldsForACMERequest(fields)
|
||||
|
||||
return &framework.Path{
|
||||
Pattern: pattern,
|
||||
Fields: fields,
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.UpdateOperation: &framework.PathOperation{
|
||||
Callback: b.acmeParsedWrapper(b.acmeRevocationHandler),
|
||||
ForwardPerformanceSecondary: false,
|
||||
ForwardPerformanceStandby: true,
|
||||
},
|
||||
},
|
||||
|
||||
HelpSynopsis: "",
|
||||
HelpDescription: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backend) acmeRevocationHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}) (*logical.Response, error) {
|
||||
var cert *x509.Certificate
|
||||
|
||||
rawCertificate, present := data["certificate"]
|
||||
if present {
|
||||
certBase64, ok := rawCertificate.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid type (%T; expected string) for field 'certificate': %w", rawCertificate, ErrMalformed)
|
||||
}
|
||||
|
||||
certBytes, err := base64.RawURLEncoding.DecodeString(certBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to base64 decode certificate: %v: %w", err, ErrMalformed)
|
||||
}
|
||||
|
||||
cert, err = x509.ParseCertificate(certBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %v: %w", err, ErrMalformed)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("bad request was lacking required field 'certificate': %w", ErrMalformed)
|
||||
}
|
||||
|
||||
rawReason, present := data["reason"]
|
||||
if present {
|
||||
reason, ok := rawReason.(float64)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid type (%T; expected float64) for field 'reason': %w", rawReason, ErrMalformed)
|
||||
}
|
||||
|
||||
if int(reason) != 0 {
|
||||
return nil, fmt.Errorf("Vault does not support revocation reasons (got %v; expected omitted or 0/unspecified): %w", int(reason), ErrBadRevocationReason)
|
||||
}
|
||||
}
|
||||
|
||||
// If the certificate expired, there's no point in revoking it.
|
||||
if cert.NotAfter.Before(time.Now()) {
|
||||
return nil, fmt.Errorf("refusing to revoke expired certificate: %w", ErrMalformed)
|
||||
}
|
||||
|
||||
// Fetch the CRL config as we need it to ultimately do the
|
||||
// revocation. This should be cached and thus relatively fast.
|
||||
config, err := b.crlBuilder.getConfigWithUpdate(acmeCtx.sc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: failed reading revocation config: %v: %w", err, ErrServerInternal)
|
||||
}
|
||||
|
||||
// Load our certificate from storage to ensure it exists and matches
|
||||
// what was given to us.
|
||||
serial := serialFromCert(cert)
|
||||
certEntry, err := fetchCertBySerial(acmeCtx.sc, "certs/", serial)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: err reading global cert entry: %v: %w", err, ErrServerInternal)
|
||||
}
|
||||
if certEntry == nil {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: no global cert entry found: %w", ErrServerInternal)
|
||||
}
|
||||
|
||||
// Validate that the provided certificate matches the stored
|
||||
// certificate. This completes the chain of:
|
||||
//
|
||||
// provided_auth -> provided_cert == stored cert.
|
||||
//
|
||||
// Allowing revocation to be safe.
|
||||
//
|
||||
// We use the non-subtle unsafe bytes equality check here as we have
|
||||
// already fetched this certificate from storage, thus already leaking
|
||||
// timing information that this cert exists. The user could thus simply
|
||||
// fetch the cert from Vault matching this serial number via the unauthed
|
||||
// pki/certs/:serial API endpoint.
|
||||
if !bytes.Equal(certEntry.Value, cert.Raw) {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: supplied certificate does not match CA's stored value: %w", ErrMalformed)
|
||||
}
|
||||
|
||||
// Check if it was already revoked; in this case, we do not need to
|
||||
// revoke it again and want to respond with an appropriate error message.
|
||||
revEntry, err := fetchCertBySerial(acmeCtx.sc, "revoked/", serial)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: err reading revocation entry: %v: %w", err, ErrServerInternal)
|
||||
}
|
||||
if revEntry != nil {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: %w", ErrAlreadyRevoked)
|
||||
}
|
||||
|
||||
// Finally, do the relevant permissions/authorization check as
|
||||
// appropriate based on the type of revocation happening.
|
||||
if !userCtx.Existing {
|
||||
return b.acmeRevocationByPoP(acmeCtx, r, fields, userCtx, data, cert, config)
|
||||
}
|
||||
|
||||
return b.acmeRevocationByAccount(acmeCtx, r, fields, userCtx, data, cert, config)
|
||||
}
|
||||
|
||||
func (b *backend) acmeRevocationByPoP(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, cert *x509.Certificate, config *crlConfig) (*logical.Response, error) {
|
||||
// Since this account does not exist, ensure we've gotten a private key
|
||||
// matching the certificate's public key.
|
||||
signer, ok := userCtx.Key.Key.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: unable to parse JWS key of type (%T): %w", userCtx.Key.Key, ErrMalformed)
|
||||
}
|
||||
|
||||
// Ensure that our PoP is indeed valid.
|
||||
if err := validatePrivateKeyMatchesCert(signer, cert); err != nil {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: unable to verify proof of possession: %v: %w", err, ErrMalformed)
|
||||
}
|
||||
|
||||
// Now it is safe to revoke.
|
||||
b.revokeStorageLock.Lock()
|
||||
defer b.revokeStorageLock.Unlock()
|
||||
|
||||
return revokeCert(acmeCtx.sc, config, cert)
|
||||
}
|
||||
|
||||
func (b *backend) acmeRevocationByAccount(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, cert *x509.Certificate, config *crlConfig) (*logical.Response, error) {
|
||||
// Fetch the account; disallow revocations from non-valid-status
|
||||
// accounts.
|
||||
account, err := b.acmeState.LoadAccount(acmeCtx, userCtx.Kid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to lookup account: %w", err)
|
||||
}
|
||||
if account.Status != StatusValid {
|
||||
return nil, fmt.Errorf("account isn't presently valid: %w", ErrUnauthorized)
|
||||
}
|
||||
|
||||
// We only support certificates issued by this user, we don't support
|
||||
// cross-account revocations.
|
||||
serial := serialFromCert(cert)
|
||||
acmeEntry, err := b.acmeState.GetIssuedCert(acmeCtx, userCtx.Kid, serial)
|
||||
if err != nil || acmeEntry == nil {
|
||||
return nil, fmt.Errorf("unable to revoke certificate: %v: %w", err, ErrMalformed)
|
||||
}
|
||||
|
||||
// Now it is safe to revoke.
|
||||
b.revokeStorageLock.Lock()
|
||||
defer b.revokeStorageLock.Unlock()
|
||||
|
||||
return revokeCert(acmeCtx.sc, config, cert)
|
||||
}
|
|
@ -5,6 +5,7 @@ package pki
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
|
@ -446,6 +447,10 @@ func (b *backend) pathRevokeWriteHandleKey(req *logical.Request, certReference *
|
|||
return fmt.Errorf("failed to parse provided private key: %w", err)
|
||||
}
|
||||
|
||||
return validatePrivateKeyMatchesCert(signer, certReference)
|
||||
}
|
||||
|
||||
func validatePrivateKeyMatchesCert(signer crypto.Signer, certReference *x509.Certificate) error {
|
||||
// Finally, verify if the cert and key match. This code has been
|
||||
// cribbed from the Go TLS config code, with minor modifications.
|
||||
//
|
||||
|
@ -453,7 +458,6 @@ func (b *backend) pathRevokeWriteHandleKey(req *logical.Request, certReference *
|
|||
// components and ensure we validate exponent and curve information
|
||||
// as well.
|
||||
//
|
||||
//
|
||||
// See: https://github.com/golang/go/blob/c6a2dada0df8c2d75cf3ae599d7caed77d416fa2/src/crypto/tls/tls.go#L304-L331
|
||||
switch certPub := certReference.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
|
|
|
@ -65,19 +65,52 @@ func CheckCertBot(t *testing.T, vaultNetwork string, vaultNodeID string, directo
|
|||
"--agree-tos",
|
||||
"--no-verify-ssl",
|
||||
"--standalone",
|
||||
"--non-interactive",
|
||||
"--server", directory,
|
||||
"-d", hostname,
|
||||
}
|
||||
logCatCmd := []string{"cat", "/var/log/letsencrypt/letsencrypt.log"}
|
||||
|
||||
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotCmd)
|
||||
t.Logf("Certbot Command: %v\nstdout: %v\nstderr: %v\n", certbotCmd, string(stdout), string(stderr))
|
||||
t.Logf("Certbot Issue Command: %v\nstdout: %v\nstderr: %v\n", certbotCmd, string(stdout), string(stderr))
|
||||
if err != nil || retcode != 0 {
|
||||
logsStdout, logsStderr, _, _ := runner.RunCmdWithOutput(ctx, result.Container.ID, logCatCmd)
|
||||
t.Logf("Certbot logs\nstdout: %v\nstderr: %v\n", string(logsStdout), string(logsStderr))
|
||||
}
|
||||
require.NoError(t, err, "got error running command")
|
||||
require.Equal(t, 0, retcode, "expected zero retcode")
|
||||
require.NoError(t, err, "got error running issue command")
|
||||
require.Equal(t, 0, retcode, "expected zero retcode issue command result")
|
||||
|
||||
certbotRevokeCmd := []string{
|
||||
"certbot",
|
||||
"revoke",
|
||||
"--no-eff-email",
|
||||
"--email", "certbot.client@dadgarcorp.com",
|
||||
"--agree-tos",
|
||||
"--no-verify-ssl",
|
||||
"--non-interactive",
|
||||
"--no-delete-after-revoke",
|
||||
"--cert-name", hostname,
|
||||
}
|
||||
|
||||
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotRevokeCmd)
|
||||
t.Logf("Certbot Revoke Command: %v\nstdout: %v\nstderr: %v\n", certbotRevokeCmd, string(stdout), string(stderr))
|
||||
if err != nil || retcode != 0 {
|
||||
logsStdout, logsStderr, _, _ := runner.RunCmdWithOutput(ctx, result.Container.ID, logCatCmd)
|
||||
t.Logf("Certbot logs\nstdout: %v\nstderr: %v\n", string(logsStdout), string(logsStderr))
|
||||
}
|
||||
require.NoError(t, err, "got error running revoke command")
|
||||
require.Equal(t, 0, retcode, "expected zero retcode revoke command result")
|
||||
|
||||
// Revoking twice should fail.
|
||||
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotRevokeCmd)
|
||||
t.Logf("Certbot Double Revoke Command: %v\nstdout: %v\nstderr: %v\n", certbotRevokeCmd, string(stdout), string(stderr))
|
||||
if err != nil || retcode == 0 {
|
||||
logsStdout, logsStderr, _, _ := runner.RunCmdWithOutput(ctx, result.Container.ID, logCatCmd)
|
||||
t.Logf("Certbot logs\nstdout: %v\nstderr: %v\n", string(logsStdout), string(logsStderr))
|
||||
}
|
||||
|
||||
require.NoError(t, err, "got error running double revoke command")
|
||||
require.NotEqual(t, 0, retcode, "expected non-zero retcode double revoke command result")
|
||||
|
||||
runner.Stop(ctx, result.Container.ID)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue