Adds automated ACME tests using Caddy. (#21277)

* Adds automated ACME tests using Caddy.

* Do not use CheckSignatureFrom method to validate TLS-ALPN-01 challenges

* Uncomment TLS-ALPN test.

* Fix validation of tls-alpn-01 keyAuthz

Surprisingly, this failure was not caught by our earlier, but unmerged
acme.sh tests:

> 2023-06-07T19:35:27.6963070Z [32mPASS[0m builtin/logical/pkiext/pkiext_binary.Test_ACME/group/acme.sh_tls-alpn (33.06s)

from https://github.com/hashicorp/vault/pull/20987.

Notably, we had two failures:

 1. The extension's raw value is not used, but is instead an OCTET
    STRING encoded version:

    > The extension has the following ASN.1 [X.680] format :
    >
    > Authorization ::= OCTET STRING (SIZE (32))
    >
    > The extnValue of the id-pe-acmeIdentifier extension is the ASN.1
    > DER encoding [X.690] of the Authorization structure, which
    > contains the SHA-256 digest of the key authorization for the
    > challenge.
 2. Unlike DNS, the SHA-256 is directly embedded in the authorization,
    as evidenced by the `SIZE (32)` annotation in the quote above: we
    were instead expecting this to be url base-64 encoded, which would
    have a different size.

This failure was caught by Matt, testing with Caddy. :-)

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Quick gofmt run.

* Fix challenge encoding in TLS-ALPN-01 challenge tests

* Rename a PKI test helper that retrieves the Vault cluster listener's cert to distinguish it from the method that retrieves the PKI mount's CA cert. Combine a couple of Docker file copy commands into one.

---------

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
Co-authored-by: Steve Clark <steven.clark@hashicorp.com>
Co-authored-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
Matt Schultz
2023-06-15 15:44:09 -05:00
committed by GitHub
parent f5b2f8744f
commit 8cc7be234a
8 changed files with 442 additions and 23 deletions

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/asn1"
@@ -55,7 +56,7 @@ func ValidateKeyAuthorization(keyAuthz string, token string, thumbprint string)
// challenge matches our expectation, returning (true, nil) if so, or
// (false, err) if not.
//
// This is for use with DNS challenges, which require
// This is for use with DNS challenges, which require base64 encoding.
func ValidateSHA256KeyAuthorization(keyAuthz string, token string, thumbprint string) (bool, error) {
authzContents := token + "." + thumbprint
checksum := sha256.Sum256([]byte(authzContents))
@@ -68,6 +69,22 @@ func ValidateSHA256KeyAuthorization(keyAuthz string, token string, thumbprint st
return true, nil
}
// ValidateRawSHA256KeyAuthorization validates that the given keyAuthz from a
// challenge matches our expectation, returning (true, nil) if so, or
// (false, err) if not.
//
// This is for use with TLS challenges, which require the raw hash output.
func ValidateRawSHA256KeyAuthorization(keyAuthz []byte, token string, thumbprint string) (bool, error) {
authzContents := token + "." + thumbprint
expectedAuthz := sha256.Sum256([]byte(authzContents))
if len(keyAuthz) != len(expectedAuthz) || subtle.ConstantTimeCompare(expectedAuthz[:], keyAuthz) != 1 {
return false, fmt.Errorf("sha256 key authorization was invalid")
}
return true, nil
}
func buildResolver(config *acmeConfigEntry) (*net.Resolver, error) {
if len(config.DNSResolver) == 0 {
return net.DefaultResolver, nil
@@ -286,9 +303,13 @@ func ValidateTLSALPN01Challenge(domain string, token string, thumbprint string,
// Verify that this is a self-signed certificate that isn't signed
// by another certificate (i.e., with the same key material but
// different issuer).
if err := cert.CheckSignatureFrom(cert); err != nil {
return fmt.Errorf("server under test returned a non-self-signed certificate: %w", err)
// NOTE: Do not use cert.CheckSignatureFrom(cert) as we need to bypass the
// checks for the parent certificate having the IsCA basic constraint set.
err := cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature)
if err != nil {
return fmt.Errorf("server under test returned a non-self-signed certificate: %v", err)
}
if !bytes.Equal(cert.RawSubject, cert.RawIssuer) {
return fmt.Errorf("server under test returned a non-self-signed certificate: invalid subject (%v) <-> issuer (%v) match", cert.Subject.String(), cert.Issuer.String())
}
@@ -339,8 +360,16 @@ func ValidateTLSALPN01Challenge(domain string, token string, thumbprint string,
return fmt.Errorf("server under test returned a certificate with an acmeIdentifier extension marked non-Critical")
}
keyAuthz := string(ext.Value)
ok, err := ValidateSHA256KeyAuthorization(keyAuthz, token, thumbprint)
var keyAuthz []byte
remainder, err := asn1.Unmarshal(ext.Value, &keyAuthz)
if err != nil {
return fmt.Errorf("server under test returned a certificate with invalid acmeIdentifier extension value: %w", err)
}
if len(remainder) > 0 {
return fmt.Errorf("server under test returned a certificate with invalid acmeIdentifier extension value with additional trailing data")
}
ok, err := ValidateRawSHA256KeyAuthorization(keyAuthz, token, thumbprint)
if !ok || err != nil {
return fmt.Errorf("server under test returned a certificate with an invalid key authorization (%w)", err)
}

View File

@@ -10,6 +10,7 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"fmt"
"math/big"
@@ -308,7 +309,8 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
t.Logf("using keyAuthorizationTestCase [tc=%d] as alpnTestCase [tc=%d]...", index, len(alpnTestCases))
// Properly encode the authorization.
checksum := sha256.Sum256([]byte(tc.keyAuthz))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
authz, err := asn1.Marshal(checksum[:])
require.NoError(t, err, "failed asn.1 marshalling authz")
// Build a self-signed certificate.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -329,11 +331,11 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
Value: authz,
},
},
BasicConstraintsValid: true,
IsCA: true,
IsCA: false,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create certificate")
@@ -378,7 +380,8 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
authz, err := asn1.Marshal(checksum[:])
require.NoError(t, err, "failed to marshal authz with asn.1 ")
// Build a leaf certificate which _could_ pass validation
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -399,11 +402,11 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
Value: authz,
},
},
BasicConstraintsValid: true,
IsCA: true,
IsCA: false,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, rootCert, key.Public(), rootKey)
require.NoError(t, err, "failed to create leaf certificate")
@@ -426,7 +429,8 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
// Test case: cert without DNSSan
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
authz, err := asn1.Marshal(checksum[:])
require.NoError(t, err, "failed to marshal authz with asn.1 ")
// Build a leaf certificate without a DNSSan
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -447,11 +451,11 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
Value: authz,
},
},
BasicConstraintsValid: true,
IsCA: true,
IsCA: false,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
@@ -474,7 +478,8 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
// Test case: cert without matching DNSSan
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
authz, err := asn1.Marshal(checksum[:])
require.NoError(t, err, "failed to marshal authz with asn.1 ")
// Build a leaf certificate which fails validation due to bad DNSName
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -495,11 +500,11 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
Value: authz,
},
},
BasicConstraintsValid: true,
IsCA: true,
IsCA: false,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
@@ -522,7 +527,8 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
// Test case: cert with additional SAN
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
authz, err := asn1.Marshal(checksum[:])
require.NoError(t, err, "failed to marshal authz with asn.1 ")
// Build a leaf certificate which has an invalid additional SAN
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -544,11 +550,11 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
Value: authz,
},
},
BasicConstraintsValid: true,
IsCA: true,
IsCA: false,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
@@ -571,7 +577,8 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
// Test case: cert without CN
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
authz, err := asn1.Marshal(checksum[:])
require.NoError(t, err, "failed to marshal authz with asn.1 ")
// Build a leaf certificate which should pass validation
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -588,11 +595,11 @@ func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
Value: authz,
},
},
BasicConstraintsValid: true,
IsCA: true,
IsCA: false,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")

View File

@@ -12,17 +12,21 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"path"
"strings"
"testing"
"time"
"golang.org/x/crypto/acme"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/vault/builtin/logical/pkiext"
"github.com/hashicorp/vault/helper/testhelpers"
"github.com/hashicorp/vault/sdk/helper/certutil"
@@ -30,6 +34,15 @@ import (
"github.com/stretchr/testify/require"
)
//go:embed testdata/caddy_http.json
var caddyConfigTemplateHTTP string
//go:embed testdata/caddy_http_eab.json
var caddyConfigTemplateHTTPEAB string
//go:embed testdata/caddy_tls_alpn.json
var caddyConfigTemplateTLSALPN string
// Test_ACME will start a Vault cluster using the docker based binary, and execute
// a bunch of sub-tests against that cluster. It is up to each sub-test to run/configure
// a new pki mount within the cluster to not interfere with each other.
@@ -38,6 +51,9 @@ func Test_ACME(t *testing.T) {
defer cluster.Cleanup()
tc := map[string]func(t *testing.T, cluster *VaultPkiCluster){
"caddy http": SubtestACMECaddy(caddyConfigTemplateHTTP, false),
"caddy http eab": SubtestACMECaddy(caddyConfigTemplateHTTPEAB, true),
"caddy tls-alpn": SubtestACMECaddy(caddyConfigTemplateTLSALPN, false),
"certbot": SubtestACMECertbot,
"certbot eab": SubtestACMECertbotEab,
"acme ip sans": SubtestACMEIPAndDNS,
@@ -65,6 +81,156 @@ func Test_ACME(t *testing.T) {
t.Run("step down", func(gt *testing.T) { SubtestACMEStepDownNode(gt, cluster) })
}
// caddyConfig contains information used to render a Caddy configuration file from a template.
type caddyConfig struct {
Hostname string
Directory string
CACert string
EABID string
EABKey string
}
// SubtestACMECaddy returns an ACME test for Caddy using the provided template.
func SubtestACMECaddy(configTemplate string, enableEAB bool) func(*testing.T, *VaultPkiCluster) {
return func(t *testing.T, cluster *VaultPkiCluster) {
ctx := context.Background()
// Roll a random run ID for mount and hostname uniqueness.
runID, err := uuid.GenerateUUID()
require.NoError(t, err, "failed to generate a unique ID for test run")
runID = strings.Split(runID, "-")[0]
// Create the PKI mount with ACME enabled
pki, err := cluster.CreateAcmeMount(runID)
require.NoError(t, err, "failed to set up ACME mount")
// Conditionally enable EAB and retrieve the key.
var eabID, eabKey string
if enableEAB {
err = pki.UpdateAcmeConfig(true, map[string]interface{}{
"eab_policy": "new-account-required",
})
require.NoError(t, err, "failed to configure EAB policy in PKI mount")
eabID, eabKey, err = pki.GetEabKey("acme/")
require.NoError(t, err, "failed to retrieve EAB key from PKI mount")
}
directory := fmt.Sprintf("https://%s:8200/v1/%s/acme/directory", pki.GetActiveContainerIP(), runID)
vaultNetwork := pki.GetContainerNetworkName()
t.Logf("dir: %s", directory)
logConsumer, logStdout, logStderr := getDockerLog(t)
sleepTimer := "45"
// Kick off Caddy container.
t.Logf("creating on network: %v", vaultNetwork)
caddyRunner, err := hDocker.NewServiceRunner(hDocker.RunOptions{
// TODO: Replace with pull-through cache. - schultz
ImageRepo: "library/caddy",
ImageTag: "latest",
ContainerName: fmt.Sprintf("caddy_test_%s", runID),
NetworkName: vaultNetwork,
Ports: []string{"80/tcp", "443/tcp", "443/udp"},
Entrypoint: []string{"sleep", sleepTimer},
LogConsumer: logConsumer,
LogStdout: logStdout,
LogStderr: logStderr,
})
require.NoError(t, err, "failed creating caddy service runner")
caddyResult, err := caddyRunner.Start(ctx, true, false)
require.NoError(t, err, "could not start Caddy container")
require.NotNil(t, caddyResult, "could not start Caddy container")
defer caddyRunner.Stop(ctx, caddyResult.Container.ID)
networks, err := caddyRunner.GetNetworkAndAddresses(caddyResult.Container.ID)
require.NoError(t, err, "could not read caddy container's IP address")
require.Contains(t, networks, vaultNetwork, "expected to contain vault network")
ipAddr := networks[vaultNetwork]
hostname := fmt.Sprintf("%s.dadgarcorp.com", runID)
err = pki.AddHostname(hostname, ipAddr)
require.NoError(t, err, "failed to update vault host files")
// Render the Caddy configuration from the specified template.
tmpl, err := template.New("config").Parse(configTemplate)
require.NoError(t, err, "failed to parse Caddy config template")
var b strings.Builder
err = tmpl.Execute(
&b,
caddyConfig{
Hostname: hostname,
Directory: directory,
CACert: "/tmp/vault_ca_cert.crt",
EABID: eabID,
EABKey: eabKey,
},
)
require.NoError(t, err, "failed to render Caddy config template")
// Push the Caddy config and the cluster listener's CA certificate over to the docker container.
cpCtx := hDocker.NewBuildContext()
cpCtx["caddy_config.json"] = hDocker.PathContentsFromString(b.String())
cpCtx["vault_ca_cert.crt"] = hDocker.PathContentsFromString(string(cluster.GetListenerCACertPEM()))
err = caddyRunner.CopyTo(caddyResult.Container.ID, "/tmp/", cpCtx)
require.NoError(t, err, "failed to copy Caddy config and Vault listener CA certificate to container")
// Start the Caddy server.
caddyCmd := []string{
"caddy",
"start",
"--config", "/tmp/caddy_config.json",
}
stdout, stderr, retcode, err := caddyRunner.RunCmdWithOutput(ctx, caddyResult.Container.ID, caddyCmd)
t.Logf("Caddy Start Command: %v\nstdout: %v\nstderr: %v\n", caddyCmd, string(stdout), string(stderr))
require.NoError(t, err, "got error running Caddy start command")
require.Equal(t, 0, retcode, "expected zero retcode Caddy start command result")
// Start a cURL container.
curlRunner, err := hDocker.NewServiceRunner(hDocker.RunOptions{
ImageRepo: "docker.mirror.hashicorp.services/curlimages/curl",
ImageTag: "latest",
ContainerName: fmt.Sprintf("curl_test_%s", runID),
NetworkName: vaultNetwork,
Entrypoint: []string{"sleep", sleepTimer},
LogConsumer: logConsumer,
LogStdout: logStdout,
LogStderr: logStderr,
})
require.NoError(t, err, "failed creating cURL service runner")
curlResult, err := curlRunner.Start(ctx, true, false)
require.NoError(t, err, "could not start cURL container")
require.NotNil(t, curlResult, "could not start cURL container")
// Retrieve the PKI mount CA cert and copy it over to the cURL container.
mountCACert, err := pki.GetCACertPEM()
require.NoError(t, err, "failed to retrieve PKI mount CA certificate")
mountCACertCtx := hDocker.NewBuildContext()
mountCACertCtx["ca_cert.crt"] = hDocker.PathContentsFromString(mountCACert)
err = curlRunner.CopyTo(curlResult.Container.ID, "/tmp/", mountCACertCtx)
require.NoError(t, err, "failed to copy PKI mount CA certificate to cURL container")
// Use cURL to hit the Caddy server and validate that a certificate was retrieved successfully.
curlCmd := []string{
"curl",
"-L",
"--cacert", "/tmp/ca_cert.crt",
"--resolve", hostname + ":443:" + ipAddr,
"https://" + hostname + "/",
}
stdout, stderr, retcode, err = curlRunner.RunCmdWithOutput(ctx, curlResult.Container.ID, curlCmd)
t.Logf("cURL Command: %v\nstdout: %v\nstderr: %v\n", curlCmd, string(stdout), string(stderr))
require.NoError(t, err, "got error running cURL command")
require.Equal(t, 0, retcode, "expected zero retcode cURL command result")
}
}
func SubtestACMECertbot(t *testing.T, cluster *VaultPkiCluster) {
pki, err := cluster.CreateAcmeMount("pki")
require.NoError(t, err, "failed setting up acme mount")

View File

@@ -109,6 +109,11 @@ func (vpc *VaultPkiCluster) GetActiveNode() *api.Client {
return vpc.GetActiveClusterNode().APIClient()
}
// GetListenerCACertPEM returns the Vault cluster's PEM-encoded CA certificate.
func (vpc *VaultPkiCluster) GetListenerCACertPEM() []byte {
return vpc.cluster.CACertPEM
}
func (vpc *VaultPkiCluster) AddHostname(hostname, ip string) error {
if vpc.Dns != nil {
vpc.Dns.AddRecord(hostname, "A", ip)

View File

@@ -133,6 +133,16 @@ func (vpm *VaultPkiMount) GetEabKey(acmeDirectory string) (string, string, error
return eabId, base64EabKey, nil
}
// GetCACertPEM retrieves the PKI mount's PEM-encoded CA certificate.
func (vpm *VaultPkiMount) GetCACertPEM() (string, error) {
caCertPath := path.Join(vpm.mount, "/cert/ca")
resp, err := vpm.GetActiveNode().Logical().ReadWithContext(context.Background(), caCertPath)
if err != nil {
return "", err
}
return resp.Data["certificate"].(string), nil
}
func mergeWithDefaults(config map[string]interface{}, defaults map[string]interface{}) map[string]interface{} {
myConfig := config
if myConfig == nil {

View File

@@ -0,0 +1,66 @@
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80",
":443"
],
"routes": [
{
"match": [
{
"host": [
"{{.Hostname}}"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Hello!",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"{{.Hostname}}"
],
"issuers": [
{
"ca": "{{.Directory}}",
"module": "acme",
"challenges": {
"tls-alpn": {
"disabled": true
}
},
"trusted_roots_pem_files": [
"{{.CACert}}"
]
}
]
}
]
}
}
}
}

View File

@@ -0,0 +1,70 @@
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80",
":443"
],
"routes": [
{
"match": [
{
"host": [
"{{.Hostname}}"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Hello!",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"{{.Hostname}}"
],
"issuers": [
{
"ca": "{{.Directory}}",
"module": "acme",
"external_account": {
"key_id": "{{.EABID}}",
"mac_key": "{{.EABKey}}"
},
"challenges": {
"tls-alpn": {
"disabled": true
}
},
"trusted_roots_pem_files": [
"{{.CACert}}"
]
}
]
}
]
}
}
}
}

View File

@@ -0,0 +1,66 @@
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80",
":443"
],
"routes": [
{
"match": [
{
"host": [
"{{.Hostname}}"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Hello!",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": [
"{{.Hostname}}"
],
"issuers": [
{
"ca": "{{.Directory}}",
"module": "acme",
"challenges": {
"http": {
"disabled": true
}
},
"trusted_roots_pem_files": [
"{{.CACert}}"
]
}
]
}
]
}
}
}
}