vault/builtin/logical/pkiext/pkiext_binary/acme_test.go
Matt Schultz 8cc7be234a
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>
2023-06-15 20:44:09 +00:00

1104 lines
42 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package pkiext_binary
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"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"
hDocker "github.com/hashicorp/vault/sdk/helper/docker"
"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.
func Test_ACME(t *testing.T) {
cluster := NewVaultPkiClusterWithDNS(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,
"acme wildcard": SubtestACMEWildcardDNS,
"acme prevents ica": SubtestACMEPreventsICADNS,
}
// Wrap the tests within an outer group, so that we run all tests
// in parallel, but still wait for all tests to finish before completing
// and running the cleanup of the Vault cluster.
t.Run("group", func(gt *testing.T) {
for testName := range tc {
// Trap the function to be embedded later in the run so it
// doesn't get clobbered on the next for iteration
testFunc := tc[testName]
gt.Run(testName, func(st *testing.T) {
st.Parallel()
testFunc(st, cluster)
})
}
})
// Do not run these tests in parallel.
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")
directory := "https://" + pki.GetActiveContainerIP() + ":8200/v1/pki/acme/directory"
vaultNetwork := pki.GetContainerNetworkName()
logConsumer, logStdout, logStderr := getDockerLog(t)
// Default to 45 second timeout, but bump to 120 when running locally or if nightly regression
// flag is provided.
sleepTimer := "45"
if testhelpers.IsLocalOrRegressionTests() {
sleepTimer = "120"
}
t.Logf("creating on network: %v", vaultNetwork)
runner, err := hDocker.NewServiceRunner(hDocker.RunOptions{
ImageRepo: "docker.mirror.hashicorp.services/certbot/certbot",
ImageTag: "latest",
ContainerName: "vault_pki_certbot_test",
NetworkName: vaultNetwork,
Entrypoint: []string{"sleep", sleepTimer},
LogConsumer: logConsumer,
LogStdout: logStdout,
LogStderr: logStderr,
})
require.NoError(t, err, "failed creating service runner")
ctx := context.Background()
result, err := runner.Start(ctx, true, false)
require.NoError(t, err, "could not start container")
require.NotNil(t, result, "could not start container")
defer runner.Stop(context.Background(), result.Container.ID)
networks, err := runner.GetNetworkAndAddresses(result.Container.ID)
require.NoError(t, err, "could not read container's IP address")
require.Contains(t, networks, vaultNetwork, "expected to contain vault network")
ipAddr := networks[vaultNetwork]
hostname := "certbot-acme-client.dadgarcorp.com"
err = pki.AddHostname(hostname, ipAddr)
require.NoError(t, err, "failed to update vault host files")
// Sinkhole a domain that's invalid just in case it's registered in the future.
cluster.Dns.AddDomain("armoncorp.com")
cluster.Dns.AddRecord("armoncorp.com", "A", "127.0.0.1")
certbotCmd := []string{
"certbot",
"certonly",
"--no-eff-email",
"--email", "certbot.client@dadgarcorp.com",
"--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 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 issue command")
require.Equal(t, 0, retcode, "expected zero retcode issue command result")
// N.B. We're using the `certonly` subcommand here because it seems as though the `renew` command
// attempts to install the cert for you. This ends up hanging and getting killed by docker, but is
// also not desired behavior. The certbot docs suggest using `certonly` to renew as seen here:
// https://eff-certbot.readthedocs.io/en/stable/using.html#renewing-certificates
certbotRenewCmd := []string{
"certbot",
"certonly",
"--no-eff-email",
"--email", "certbot.client@dadgarcorp.com",
"--agree-tos",
"--no-verify-ssl",
"--standalone",
"--non-interactive",
"--server", directory,
"-d", hostname,
"--cert-name", hostname,
"--force-renewal",
}
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotRenewCmd)
t.Logf("Certbot Renew Command: %v\nstdout: %v\nstderr: %v\n", certbotRenewCmd, 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 renew command")
require.Equal(t, 0, retcode, "expected zero retcode renew 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")
// Attempt to issue against a domain that doesn't match the challenge.
// N.B. This test only runs locally or when the nightly regression env var is provided to CI.
if testhelpers.IsLocalOrRegressionTests() {
certbotInvalidIssueCmd := []string{
"certbot",
"certonly",
"--no-eff-email",
"--email", "certbot.client@dadgarcorp.com",
"--agree-tos",
"--no-verify-ssl",
"--standalone",
"--non-interactive",
"--server", directory,
"-d", "armoncorp.com",
"--issuance-timeout", "10",
}
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotInvalidIssueCmd)
t.Logf("Certbot Invalid Issue Command: %v\nstdout: %v\nstderr: %v\n", certbotInvalidIssueCmd, 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 issue command")
require.NotEqual(t, 0, retcode, "expected non-zero retcode issue command result")
}
// Attempt to close out our ACME account
certbotUnregisterCmd := []string{
"certbot",
"unregister",
"--no-verify-ssl",
"--non-interactive",
"--server", directory,
}
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotUnregisterCmd)
t.Logf("Certbot Unregister Command: %v\nstdout: %v\nstderr: %v\n", certbotUnregisterCmd, 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 unregister command")
require.Equal(t, 0, retcode, "expected zero retcode unregister command result")
// Attempting to close out our ACME account twice should fail
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotUnregisterCmd)
t.Logf("Certbot double Unregister Command: %v\nstdout: %v\nstderr: %v\n", certbotUnregisterCmd, string(stdout), string(stderr))
if err != nil || retcode != 0 {
logsStdout, logsStderr, _, _ := runner.RunCmdWithOutput(ctx, result.Container.ID, logCatCmd)
t.Logf("Certbot double logs\nstdout: %v\nstderr: %v\n", string(logsStdout), string(logsStderr))
}
require.NoError(t, err, "got error running double unregister command")
require.Equal(t, 1, retcode, "expected non-zero retcode double unregister command result")
}
func SubtestACMECertbotEab(t *testing.T, cluster *VaultPkiCluster) {
mountName := "pki-certbot-eab"
pki, err := cluster.CreateAcmeMount(mountName)
require.NoError(t, err, "failed setting up acme mount")
err = pki.UpdateAcmeConfig(true, map[string]interface{}{
"eab_policy": "new-account-required",
})
require.NoError(t, err)
eabId, base64EabKey, err := pki.GetEabKey("acme/")
directory := "https://" + pki.GetActiveContainerIP() + ":8200/v1/" + mountName + "/acme/directory"
vaultNetwork := pki.GetContainerNetworkName()
logConsumer, logStdout, logStderr := getDockerLog(t)
t.Logf("creating on network: %v", vaultNetwork)
runner, err := hDocker.NewServiceRunner(hDocker.RunOptions{
ImageRepo: "docker.mirror.hashicorp.services/certbot/certbot",
ImageTag: "latest",
ContainerName: "vault_pki_certbot_eab_test",
NetworkName: vaultNetwork,
Entrypoint: []string{"sleep", "45"},
LogConsumer: logConsumer,
LogStdout: logStdout,
LogStderr: logStderr,
})
require.NoError(t, err, "failed creating service runner")
ctx := context.Background()
result, err := runner.Start(ctx, true, false)
require.NoError(t, err, "could not start container")
require.NotNil(t, result, "could not start container")
defer runner.Stop(context.Background(), result.Container.ID)
networks, err := runner.GetNetworkAndAddresses(result.Container.ID)
require.NoError(t, err, "could not read container's IP address")
require.Contains(t, networks, vaultNetwork, "expected to contain vault network")
ipAddr := networks[vaultNetwork]
hostname := "certbot-eab-acme-client.dadgarcorp.com"
err = pki.AddHostname(hostname, ipAddr)
require.NoError(t, err, "failed to update vault host files")
certbotCmd := []string{
"certbot",
"certonly",
"--no-eff-email",
"--email", "certbot.client@dadgarcorp.com",
"--eab-kid", eabId,
"--eab-hmac-key='" + base64EabKey + "'",
"--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 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 issue command")
require.Equal(t, 0, retcode, "expected zero retcode issue command result")
certbotRenewCmd := []string{
"certbot",
"certonly",
"--no-eff-email",
"--email", "certbot.client@dadgarcorp.com",
"--agree-tos",
"--no-verify-ssl",
"--standalone",
"--non-interactive",
"--server", directory,
"-d", hostname,
"--cert-name", hostname,
"--force-renewal",
}
stdout, stderr, retcode, err = runner.RunCmdWithOutput(ctx, result.Container.ID, certbotRenewCmd)
t.Logf("Certbot Renew Command: %v\nstdout: %v\nstderr: %v\n", certbotRenewCmd, 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 renew command")
require.Equal(t, 0, retcode, "expected zero retcode renew 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")
}
func SubtestACMEIPAndDNS(t *testing.T, cluster *VaultPkiCluster) {
pki, err := cluster.CreateAcmeMount("pki-ip-dns-sans")
require.NoError(t, err, "failed setting up acme mount")
// Since we interact with ACME from outside the container network the ACME
// configuration needs to be updated to use the host port and not the internal
// docker ip.
basePath, err := pki.UpdateClusterConfigLocalAddr()
require.NoError(t, err, "failed updating cluster config")
logConsumer, logStdout, logStderr := getDockerLog(t)
// Setup an nginx container that we can have respond the queries for ips
runner, err := hDocker.NewServiceRunner(hDocker.RunOptions{
ImageRepo: "docker.mirror.hashicorp.services/nginx",
ImageTag: "latest",
ContainerName: "vault_pki_ipsans_test",
NetworkName: pki.GetContainerNetworkName(),
LogConsumer: logConsumer,
LogStdout: logStdout,
LogStderr: logStderr,
})
require.NoError(t, err, "failed creating service runner")
ctx := context.Background()
result, err := runner.Start(ctx, true, false)
require.NoError(t, err, "could not start container")
require.NotNil(t, result, "could not start container")
nginxContainerId := result.Container.ID
defer runner.Stop(context.Background(), nginxContainerId)
networks, err := runner.GetNetworkAndAddresses(nginxContainerId)
challengeFolder := "/usr/share/nginx/html/.well-known/acme-challenge/"
createChallengeFolderCmd := []string{
"sh", "-c",
"mkdir -p '" + challengeFolder + "'",
}
stdout, stderr, retcode, err := runner.RunCmdWithOutput(ctx, nginxContainerId, createChallengeFolderCmd)
require.NoError(t, err, "failed to create folder in nginx container")
t.Logf("Update host file command: %v\nstdout: %v\nstderr: %v", createChallengeFolderCmd, string(stdout), string(stderr))
require.Equal(t, 0, retcode, "expected zero retcode from mkdir in nginx container")
ipAddr := networks[pki.GetContainerNetworkName()]
hostname := "go-lang-acme-client.dadgarcorp.com"
err = pki.AddHostname(hostname, ipAddr)
require.NoError(t, err, "failed to update vault host files")
// Perform an ACME lifecycle with an order that contains both an IP and a DNS name identifier
err = pki.UpdateRole("ip-dns-sans", map[string]interface{}{
"key_type": "any",
"allowed_domains": "dadgarcorp.com",
"allow_subdomains": true,
"allow_wildcard_certificates": false,
})
require.NoError(t, err, "failed creating role ip-dns-sans")
directoryUrl := basePath + "/roles/ip-dns-sans/acme/directory"
acmeOrderIdentifiers := []acme.AuthzID{
{Type: "ip", Value: ipAddr},
{Type: "dns", Value: hostname},
}
cr := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: hostname},
DNSNames: []string{hostname},
IPAddresses: []net.IP{net.ParseIP(ipAddr)},
}
provisioningFunc := func(acmeClient *acme.Client, auths []*acme.Authorization) []*acme.Challenge {
// For each http-01 challenge, generate the file to place underneath the nginx challenge folder
acmeCtx := hDocker.NewBuildContext()
var challengesToAccept []*acme.Challenge
for _, auth := range auths {
for _, challenge := range auth.Challenges {
if challenge.Status != acme.StatusPending {
t.Logf("ignoring challenge not in status pending: %v", challenge)
continue
}
if challenge.Type == "http-01" {
challengeBody, err := acmeClient.HTTP01ChallengeResponse(challenge.Token)
require.NoError(t, err, "failed generating challenge response")
challengePath := acmeClient.HTTP01ChallengePath(challenge.Token)
require.NoError(t, err, "failed generating challenge path")
challengeFile := path.Base(challengePath)
acmeCtx[challengeFile] = hDocker.PathContentsFromString(challengeBody)
challengesToAccept = append(challengesToAccept, challenge)
}
}
}
require.GreaterOrEqual(t, len(challengesToAccept), 1, "Need at least one challenge, got none")
// Copy all challenges within the nginx container
err = runner.CopyTo(nginxContainerId, challengeFolder, acmeCtx)
require.NoError(t, err, "failed copying challenges to container")
return challengesToAccept
}
acmeCert := doAcmeValidationWithGoLibrary(t, directoryUrl, acmeOrderIdentifiers, cr, provisioningFunc, "")
require.Len(t, acmeCert.IPAddresses, 1, "expected only a single ip address in cert")
require.Equal(t, ipAddr, acmeCert.IPAddresses[0].String())
require.Equal(t, []string{hostname}, acmeCert.DNSNames)
require.Equal(t, hostname, acmeCert.Subject.CommonName)
// Perform an ACME lifecycle with an order that contains just an IP identifier
err = pki.UpdateRole("ip-sans", map[string]interface{}{
"key_type": "any",
"use_csr_common_name": false,
"require_cn": false,
"client_flag": false,
})
require.NoError(t, err, "failed creating role ip-sans")
directoryUrl = basePath + "/roles/ip-sans/acme/directory"
acmeOrderIdentifiers = []acme.AuthzID{
{Type: "ip", Value: ipAddr},
}
cr = &x509.CertificateRequest{
IPAddresses: []net.IP{net.ParseIP(ipAddr)},
}
acmeCert = doAcmeValidationWithGoLibrary(t, directoryUrl, acmeOrderIdentifiers, cr, provisioningFunc, "")
require.Len(t, acmeCert.IPAddresses, 1, "expected only a single ip address in cert")
require.Equal(t, ipAddr, acmeCert.IPAddresses[0].String())
require.Empty(t, acmeCert.DNSNames, "acme cert dns name field should have been empty")
require.Equal(t, "", acmeCert.Subject.CommonName)
}
type acmeGoValidatorProvisionerFunc func(acmeClient *acme.Client, auths []*acme.Authorization) []*acme.Challenge
func doAcmeValidationWithGoLibrary(t *testing.T, directoryUrl string, acmeOrderIdentifiers []acme.AuthzID, cr *x509.CertificateRequest, provisioningFunc acmeGoValidatorProvisionerFunc, expectedFailure string) *x509.Certificate {
// Since we are contacting Vault through the host ip/port, the certificate will not validate properly
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
httpClient := &http.Client{Transport: tr}
accountKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "failed creating rsa account key")
t.Logf("Using the following url for the ACME directory: %s", directoryUrl)
acmeClient := &acme.Client{
Key: accountKey,
HTTPClient: httpClient,
DirectoryURL: directoryUrl,
}
testCtx, cancelFunc := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancelFunc()
// Create new account
_, err = acmeClient.Register(testCtx, &acme.Account{Contact: []string{"mailto:ipsans@dadgarcorp.com"}},
func(tosURL string) bool { return true })
require.NoError(t, err, "failed registering account")
// Create an ACME order
order, err := acmeClient.AuthorizeOrder(testCtx, acmeOrderIdentifiers)
require.NoError(t, err, "failed creating ACME order")
var auths []*acme.Authorization
for _, authUrl := range order.AuthzURLs {
authorization, err := acmeClient.GetAuthorization(testCtx, authUrl)
require.NoError(t, err, "failed to lookup authorization at url: %s", authUrl)
auths = append(auths, authorization)
}
// Handle the validation using the external validation mechanism.
challengesToAccept := provisioningFunc(acmeClient, auths)
require.NotEmpty(t, challengesToAccept, "provisioning function failed to return any challenges to accept")
// Tell the ACME server, that they can now validate those challenges.
for _, challenge := range challengesToAccept {
_, err = acmeClient.Accept(testCtx, challenge)
require.NoError(t, err, "failed to accept challenge: %v", challenge)
}
// Wait for the order/challenges to be validated.
_, err = acmeClient.WaitOrder(testCtx, order.URI)
require.NoError(t, err, "failed waiting for order to be ready")
// Create/sign the CSR and ask ACME server to sign it returning us the final certificate
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csr, err := x509.CreateCertificateRequest(rand.Reader, cr, csrKey)
require.NoError(t, err, "failed generating csr")
t.Logf("[TEST-LOG] Created CSR: %v", hex.EncodeToString(csr))
certs, _, err := acmeClient.CreateOrderCert(testCtx, order.FinalizeURL, csr, false)
if err != nil {
if expectedFailure != "" {
require.Contains(t, err.Error(), expectedFailure, "got a unexpected failure not matching expected value")
return nil
}
require.NoError(t, err, "failed to get a certificate back from ACME")
} else if expectedFailure != "" {
t.Fatalf("expected failure containing: %s got none", expectedFailure)
}
acmeCert, err := x509.ParseCertificate(certs[0])
require.NoError(t, err, "failed parsing acme cert bytes")
return acmeCert
}
func SubtestACMEWildcardDNS(t *testing.T, cluster *VaultPkiCluster) {
pki, err := cluster.CreateAcmeMount("pki-dns-wildcards")
require.NoError(t, err, "failed setting up acme mount")
// Since we interact with ACME from outside the container network the ACME
// configuration needs to be updated to use the host port and not the internal
// docker ip.
basePath, err := pki.UpdateClusterConfigLocalAddr()
require.NoError(t, err, "failed updating cluster config")
hostname := "go-lang-wildcard-client.dadgarcorp.com"
wildcard := "*." + hostname
// Do validation without a role first.
directoryUrl := basePath + "/acme/directory"
acmeOrderIdentifiers := []acme.AuthzID{
{Type: "dns", Value: hostname},
{Type: "dns", Value: wildcard},
}
cr := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: wildcard},
DNSNames: []string{hostname, wildcard},
}
provisioningFunc := func(acmeClient *acme.Client, auths []*acme.Authorization) []*acme.Challenge {
// For each dns-01 challenge, place the record in the associated DNS resolver.
var challengesToAccept []*acme.Challenge
for _, auth := range auths {
for _, challenge := range auth.Challenges {
if challenge.Status != acme.StatusPending {
t.Logf("ignoring challenge not in status pending: %v", challenge)
continue
}
if challenge.Type == "dns-01" {
challengeBody, err := acmeClient.DNS01ChallengeRecord(challenge.Token)
require.NoError(t, err, "failed generating challenge response")
err = pki.AddDNSRecord("_acme-challenge."+auth.Identifier.Value, "TXT", challengeBody)
require.NoError(t, err, "failed setting DNS record")
challengesToAccept = append(challengesToAccept, challenge)
}
}
}
require.GreaterOrEqual(t, len(challengesToAccept), 1, "Need at least one challenge, got none")
return challengesToAccept
}
acmeCert := doAcmeValidationWithGoLibrary(t, directoryUrl, acmeOrderIdentifiers, cr, provisioningFunc, "")
require.Contains(t, acmeCert.DNSNames, hostname)
require.Contains(t, acmeCert.DNSNames, wildcard)
require.Equal(t, wildcard, acmeCert.Subject.CommonName)
pki.RemoveDNSRecordsForDomain(hostname)
// Redo validation with a role this time.
err = pki.UpdateRole("wildcard", map[string]interface{}{
"key_type": "any",
"allowed_domains": "go-lang-wildcard-client.dadgarcorp.com",
"allow_subdomains": true,
"allow_bare_domains": true,
"allow_wildcard_certificates": true,
"client_flag": false,
})
require.NoError(t, err, "failed creating role wildcard")
directoryUrl = basePath + "/roles/wildcard/acme/directory"
acmeCert = doAcmeValidationWithGoLibrary(t, directoryUrl, acmeOrderIdentifiers, cr, provisioningFunc, "")
require.Contains(t, acmeCert.DNSNames, hostname)
require.Contains(t, acmeCert.DNSNames, wildcard)
require.Equal(t, wildcard, acmeCert.Subject.CommonName)
pki.RemoveDNSRecordsForDomain(hostname)
}
func SubtestACMEPreventsICADNS(t *testing.T, cluster *VaultPkiCluster) {
pki, err := cluster.CreateAcmeMount("pki-dns-ica")
require.NoError(t, err, "failed setting up acme mount")
// Since we interact with ACME from outside the container network the ACME
// configuration needs to be updated to use the host port and not the internal
// docker ip.
basePath, err := pki.UpdateClusterConfigLocalAddr()
require.NoError(t, err, "failed updating cluster config")
hostname := "go-lang-intermediate-ca-cert.dadgarcorp.com"
// Do validation without a role first.
directoryUrl := basePath + "/acme/directory"
acmeOrderIdentifiers := []acme.AuthzID{
{Type: "dns", Value: hostname},
}
cr := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: hostname},
DNSNames: []string{hostname},
ExtraExtensions: []pkix.Extension{
// Basic Constraint with IsCA asserted to true.
{
Id: certutil.ExtensionBasicConstraintsOID,
Critical: true,
Value: []byte{0x30, 0x03, 0x01, 0x01, 0xFF},
},
},
}
provisioningFunc := func(acmeClient *acme.Client, auths []*acme.Authorization) []*acme.Challenge {
// For each dns-01 challenge, place the record in the associated DNS resolver.
var challengesToAccept []*acme.Challenge
for _, auth := range auths {
for _, challenge := range auth.Challenges {
if challenge.Status != acme.StatusPending {
t.Logf("ignoring challenge not in status pending: %v", challenge)
continue
}
if challenge.Type == "dns-01" {
challengeBody, err := acmeClient.DNS01ChallengeRecord(challenge.Token)
require.NoError(t, err, "failed generating challenge response")
err = pki.AddDNSRecord("_acme-challenge."+auth.Identifier.Value, "TXT", challengeBody)
require.NoError(t, err, "failed setting DNS record")
challengesToAccept = append(challengesToAccept, challenge)
}
}
}
require.GreaterOrEqual(t, len(challengesToAccept), 1, "Need at least one challenge, got none")
return challengesToAccept
}
doAcmeValidationWithGoLibrary(t, directoryUrl, acmeOrderIdentifiers, cr, provisioningFunc, "refusing to accept CSR with Basic Constraints extension")
pki.RemoveDNSRecordsForDomain(hostname)
// Redo validation with a role this time.
err = pki.UpdateRole("ica", map[string]interface{}{
"key_type": "any",
"allowed_domains": "go-lang-intermediate-ca-cert.dadgarcorp.com",
"allow_subdomains": true,
"allow_bare_domains": true,
"allow_wildcard_certificates": true,
"client_flag": false,
})
require.NoError(t, err, "failed creating role wildcard")
directoryUrl = basePath + "/roles/ica/acme/directory"
doAcmeValidationWithGoLibrary(t, directoryUrl, acmeOrderIdentifiers, cr, provisioningFunc, "refusing to accept CSR with Basic Constraints extension")
pki.RemoveDNSRecordsForDomain(hostname)
}
// SubtestACMEStepDownNode Verify that we can properly run an ACME session through a
// secondary node, and midway through the challenge verification process, seal the
// active node and make sure we can complete the ACME session on the new active node.
func SubtestACMEStepDownNode(t *testing.T, cluster *VaultPkiCluster) {
pki, err := cluster.CreateAcmeMount("stepdown-test")
require.NoError(t, err)
// Since we interact with ACME from outside the container network the ACME
// configuration needs to be updated to use the host port and not the internal
// docker ip. We also grab the non-active node here on purpose to verify
// ACME related APIs are properly forwarded across standby hosts.
nonActiveNodes := pki.GetNonActiveNodes()
require.GreaterOrEqual(t, len(nonActiveNodes), 1, "Need at least one non-active node")
nonActiveNode := nonActiveNodes[0]
basePath := fmt.Sprintf("https://%s/v1/%s", nonActiveNode.HostPort, pki.mount)
err = pki.UpdateClusterConfig(map[string]interface{}{
"path": basePath,
})
hostname := "go-lang-stepdown-client.dadgarcorp.com"
acmeOrderIdentifiers := []acme.AuthzID{
{Type: "dns", Value: hostname},
}
cr := &x509.CertificateRequest{
DNSNames: []string{hostname, hostname},
}
accountKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "failed creating rsa account key")
acmeClient := &acme.Client{
Key: accountKey,
HTTPClient: &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}},
DirectoryURL: basePath + "/acme/directory",
}
testCtx, cancelFunc := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancelFunc()
// Create new account
_, err = acmeClient.Register(testCtx, &acme.Account{Contact: []string{"mailto:ipsans@dadgarcorp.com"}},
func(tosURL string) bool { return true })
require.NoError(t, err, "failed registering account")
// Create an ACME order
order, err := acmeClient.AuthorizeOrder(testCtx, acmeOrderIdentifiers)
require.NoError(t, err, "failed creating ACME order")
require.Len(t, order.AuthzURLs, 1, "expected a single authz url")
authUrl := order.AuthzURLs[0]
authorization, err := acmeClient.GetAuthorization(testCtx, authUrl)
require.NoError(t, err, "failed to lookup authorization at url: %s", authUrl)
dnsTxtRecordsToAdd := map[string]string{}
var challengesToAccept []*acme.Challenge
for _, challenge := range authorization.Challenges {
if challenge.Status != acme.StatusPending {
t.Logf("ignoring challenge not in status pending: %v", challenge)
continue
}
if challenge.Type == "dns-01" {
challengeBody, err := acmeClient.DNS01ChallengeRecord(challenge.Token)
require.NoError(t, err, "failed generating challenge response")
// Collect the challenges for us to add the DNS records after step-down
dnsTxtRecordsToAdd["_acme-challenge."+authorization.Identifier.Value] = challengeBody
challengesToAccept = append(challengesToAccept, challenge)
}
}
// Tell the ACME server, that they can now validate those challenges, this will cause challenge
// verification failures on the main node as the DNS records do not exist.
for _, challenge := range challengesToAccept {
_, err = acmeClient.Accept(testCtx, challenge)
require.NoError(t, err, "failed to accept challenge: %v", challenge)
}
// Now wait till we start seeing the challenge engine start failing the lookups.
testhelpers.RetryUntil(t, 10*time.Second, func() error {
myAuth, err := acmeClient.GetAuthorization(testCtx, authUrl)
require.NoError(t, err, "failed to lookup authorization at url: %s", authUrl)
for _, challenge := range myAuth.Challenges {
if challenge.Error != nil {
// The engine failed on one of the challenges, we are done waiting
return nil
}
}
return fmt.Errorf("no challenges for auth %v contained any errors", myAuth.Identifier)
})
// Seal the active node now and wait for the next node to appear
previousActiveNode := pki.GetActiveClusterNode()
t.Logf("Stepping down node id: %s", previousActiveNode.NodeID)
haStatus, _ := previousActiveNode.APIClient().Sys().HAStatus()
t.Logf("Node: %v HaStatus: %v\n", previousActiveNode.NodeID, haStatus)
testhelpers.RetryUntil(t, 2*time.Minute, func() error {
state, err := previousActiveNode.APIClient().Sys().RaftAutopilotState()
if err != nil {
return err
}
t.Logf("Node: %v Raft AutoPilotState: %v\n", previousActiveNode.NodeID, state)
if !state.Healthy {
return fmt.Errorf("raft auto pilot state is not healthy")
}
// Make sure that we have at least one node that can take over prior to sealing the current active node.
if state.FailureTolerance < 1 {
msg := fmt.Sprintf("there is no fault tolerance within raft state yet: %d", state.FailureTolerance)
t.Log(msg)
return errors.New(msg)
}
return nil
})
t.Logf("Sealing active node")
err = previousActiveNode.APIClient().Sys().Seal()
require.NoError(t, err, "failed stepping down node")
// Add our DNS records now
t.Logf("Adding DNS records")
for dnsHost, dnsValue := range dnsTxtRecordsToAdd {
err = pki.AddDNSRecord(dnsHost, "TXT", dnsValue)
require.NoError(t, err, "failed adding DNS record: %s:%s", dnsHost, dnsValue)
}
// Wait for our new active node to come up
testhelpers.RetryUntil(t, 2*time.Minute, func() error {
newNode := pki.GetActiveClusterNode()
if newNode.NodeID == previousActiveNode.NodeID {
return fmt.Errorf("existing node is still the leader after stepdown: %s", newNode.NodeID)
}
t.Logf("New active node has node id: %v", newNode.NodeID)
return nil
})
// Wait for the order/challenges to be validated.
_, err = acmeClient.WaitOrder(testCtx, order.URI)
if err != nil {
// We failed waiting for the order to become ready, lets print out current challenge statuses to help debugging
myAuth, authErr := acmeClient.GetAuthorization(testCtx, authUrl)
require.NoError(t, authErr, "failed to lookup authorization at url: %s and wait order failed with: %v", authUrl, err)
t.Logf("Authorization Status: %s", myAuth.Status)
for _, challenge := range myAuth.Challenges {
// The engine failed on one of the challenges, we are done waiting
t.Logf("challenge: %v state: %v Error: %v", challenge.Type, challenge.Status, challenge.Error)
}
require.NoError(t, err, "failed waiting for order to be ready")
}
// Create/sign the CSR and ask ACME server to sign it returning us the final certificate
csrKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csr, err := x509.CreateCertificateRequest(rand.Reader, cr, csrKey)
require.NoError(t, err, "failed generating csr")
certs, _, err := acmeClient.CreateOrderCert(testCtx, order.FinalizeURL, csr, false)
require.NoError(t, err, "failed to get a certificate back from ACME")
_, err = x509.ParseCertificate(certs[0])
require.NoError(t, err, "failed parsing acme cert bytes")
}
func getDockerLog(t *testing.T) (func(s string), *pkiext.LogConsumerWriter, *pkiext.LogConsumerWriter) {
logConsumer := func(s string) {
t.Logf(s)
}
logStdout := &pkiext.LogConsumerWriter{logConsumer}
logStderr := &pkiext.LogConsumerWriter{logConsumer}
return logConsumer, logStdout, logStderr
}