backport of commit f079b7b0a4de28f1230a270fc35ea5a787ad96d2 (#21060)

Co-authored-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
hc-github-team-secure-vault-core 2023-06-07 17:32:58 -04:00 committed by GitHub
parent 1f8c665eb3
commit d8979b449c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 799 additions and 14 deletions

View File

@ -415,6 +415,22 @@ func (ace *ACMEChallengeEngine) _verifyChallenge(sc *storageContext, id string,
err = fmt.Errorf("%w: error validating dns-01 challenge %v: %s", ErrIncorrectResponse, id, err.Error())
return ace._verifyChallengeRetry(sc, cv, authzPath, authz, challenge, err, id)
}
case ACMEALPNChallenge:
if authz.Identifier.Type != ACMEDNSIdentifier {
err = fmt.Errorf("unsupported identifier type for authorization %v/%v in challenge %v: %v", cv.Account, cv.Authorization, id, authz.Identifier.Type)
return ace._verifyChallengeCleanup(sc, err, id)
}
if authz.Wildcard {
err = fmt.Errorf("unable to validate wildcard authorization %v/%v in challenge %v via tls-alpn-01 challenge", cv.Account, cv.Authorization, id)
return ace._verifyChallengeCleanup(sc, err, id)
}
valid, err = ValidateTLSALPN01Challenge(authz.Identifier.Value, cv.Token, cv.Thumbprint, config)
if err != nil {
err = fmt.Errorf("%w: error validating tls-alpn-01 challenge %v: %s", ErrIncorrectResponse, id, err.Error())
return ace._verifyChallengeRetry(sc, cv, authzPath, authz, challenge, err, id)
}
default:
err = fmt.Errorf("unsupported ACME challenge type %v for challenge %v", cv.ChallengeType, id)
return ace._verifyChallengeCleanup(sc, err, id)

View File

@ -1,8 +1,12 @@
package pki
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"fmt"
"io"
@ -12,7 +16,21 @@ import (
"time"
)
const DNSChallengePrefix = "_acme-challenge."
const (
DNSChallengePrefix = "_acme-challenge."
ALPNProtocol = "acme-tls/1"
)
// While this should be a constant, there's no way to do a low-level test of
// ValidateTLSALPN01Challenge without spinning up a complicated Docker
// instance to build a custom responder. Because we already have a local
// toolchain, it is far easier to drive this through Go tests with a custom
// (high) port, rather than requiring permission to bind to port 443 (root-run
// tests are even worse).
var ALPNPort = "443"
// OID of the acmeIdentifier X.509 Certificate Extension.
var OIDACMEIdentifier = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 31}
// ValidateKeyAuthorization validates that the given keyAuthz from a challenge
// matches our expectation, returning (true, nil) if so, or (false, err) if
@ -67,15 +85,28 @@ func buildResolver(config *acmeConfigEntry) (*net.Resolver, error) {
}, nil
}
func buildDialerConfig(config *acmeConfigEntry) (*net.Dialer, error) {
resolver, err := buildResolver(config)
if err != nil {
return nil, fmt.Errorf("failed to build resolver: %w", err)
}
return &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: -1 * time.Second,
Resolver: resolver,
}, nil
}
// Validates a given ACME http-01 challenge against the specified domain,
// per RFC 8555.
//
// We attempt to be defensive here against timeouts, extra redirects, &c.
func ValidateHTTP01Challenge(domain string, token string, thumbprint string, config *acmeConfigEntry) (bool, error) {
path := "http://" + domain + "/.well-known/acme-challenge/" + token
resolver, err := buildResolver(config)
dialer, err := buildDialerConfig(config)
if err != nil {
return false, fmt.Errorf("failed to build resolver: %w", err)
return false, fmt.Errorf("failed to build dialer: %w", err)
}
transport := &http.Transport{
@ -90,11 +121,7 @@ func ValidateHTTP01Challenge(domain string, token string, thumbprint string, con
// We'd rather timeout and re-attempt validation later than hang
// too many validators waiting for slow hosts.
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: -1 * time.Second,
Resolver: resolver,
}).DialContext,
DialContext: dialer.DialContext,
ResponseHeaderTimeout: 10 * time.Second,
}
@ -188,3 +215,255 @@ func ValidateDNS01Challenge(domain string, token string, thumbprint string, conf
return false, fmt.Errorf("dns-01: challenge failed against %v records", len(results))
}
func ValidateTLSALPN01Challenge(domain string, token string, thumbprint string, config *acmeConfigEntry) (bool, error) {
// This RFC is defined in RFC 8737 Automated Certificate Management
// Environment (ACME) TLS ApplicationLayer Protocol Negotiation
// (ALPN) Challenge Extension.
//
// This is conceptually similar to ValidateHTTP01Challenge, but
// uses a TLS connection on port 443 with the specified ALPN
// protocol.
cfg := &tls.Config{
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge, the name of the negotiated
// protocol is "acme-tls/1".
NextProtos: []string{ALPNProtocol},
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > ... and an SNI extension containing only the domain name
// > being validated during the TLS handshake.
//
// According to the Go docs, setting this option (even though
// InsecureSkipVerify=true is also specified), allows us to
// set the SNI extension to this value.
ServerName: domain,
VerifyConnection: func(connState tls.ConnectionState) error {
// We initiated a fresh connection with no session tickets;
// even if we did have a session ticket, we do not wish to
// use it. Verify that the server has not inadvertently
// reused connections between validation attempts or something.
if connState.DidResume {
return fmt.Errorf("server under test incorrectly reported that handshake was resumed when no session cache was provided; refusing to continue")
}
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > The ACME server verifies that during the TLS handshake the
// > application-layer protocol "acme-tls/1" was successfully
// > negotiated (and that the ALPN extension contained only the
// > value "acme-tls/1").
if connState.NegotiatedProtocol != ALPNProtocol {
return fmt.Errorf("server under test negotiated unexpected ALPN protocol %v", connState.NegotiatedProtocol)
}
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > and that the certificate returned
//
// Because this certificate MUST be self-signed (per earlier
// statement in RFC 8737 Section 3), there is no point in sending
// more than one certificate, and so we will err early here if
// we got more than one.
if len(connState.PeerCertificates) > 1 {
return fmt.Errorf("server under test returned multiple (%v) certificates when we expected only one", len(connState.PeerCertificates))
}
cert := connState.PeerCertificates[0]
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > The client prepares for validation by constructing a
// > self-signed certificate that MUST contain an acmeIdentifier
// > extension and a subjectAlternativeName extension [RFC5280].
//
// 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)
}
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())
}
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > The subjectAlternativeName extension MUST contain a single
// > dNSName entry where the value is the domain name being
// > validated.
//
// TODO: this does not validate that there are not other SANs
// with unknown (to Go) OIDs.
if len(cert.DNSNames) != 1 || len(cert.EmailAddresses) > 0 || len(cert.IPAddresses) > 0 || len(cert.URIs) > 0 {
return fmt.Errorf("server under test returned a certificate with incorrect SANs")
}
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > The comparison of dNSNames MUST be case insensitive
// > [RFC4343]. Note that as ACME doesn't support Unicode
// > identifiers, all dNSNames MUST be encoded using the rules
// > of [RFC3492].
if !strings.EqualFold(cert.DNSNames[0], domain) {
return fmt.Errorf("server under test returned a certificate with unexpected identifier: %v", cert.DNSNames[0])
}
// Per above, verify that the acmeIdentifier extension is present
// exactly once and has the correct value.
var foundACMEId bool
for _, ext := range cert.Extensions {
if !ext.Id.Equal(OIDACMEIdentifier) {
continue
}
// There must be only a single ACME extension.
if foundACMEId {
return fmt.Errorf("server under test returned a certificate with multiple acmeIdentifier extensions")
}
foundACMEId = true
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > a critical acmeIdentifier extension
if !ext.Critical {
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)
if !ok || err != nil {
return fmt.Errorf("server under test returned a certificate with an invalid key authorization (%w)", err)
}
}
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > The ACME server verifies that ... the certificate returned
// > contains: ... a critical acmeIdentifier extension containing
// > the expected SHA-256 digest computed in step 1.
if !foundACMEId {
return fmt.Errorf("server under test returned a certificate without the required acmeIdentifier extension")
}
// Remove the handled critical extension and validate that we
// have no additional critical extensions left unhandled.
var index int = -1
for oidIndex, oid := range cert.UnhandledCriticalExtensions {
if oid.Equal(OIDACMEIdentifier) {
index = oidIndex
break
}
}
if index != -1 {
// Unlike the foundACMEId case, this is not a failure; if Go
// updates to "understand" this critical extension, we do not
// wish to fail.
cert.UnhandledCriticalExtensions = append(cert.UnhandledCriticalExtensions[0:index], cert.UnhandledCriticalExtensions[index+1:]...)
}
if len(cert.UnhandledCriticalExtensions) > 0 {
return fmt.Errorf("server under test returned a certificate with additional unknown critical extensions (%v)", cert.UnhandledCriticalExtensions)
}
// All good!
return nil
},
// We never want to resume a connection; do not provide session
// cache storage.
ClientSessionCache: nil,
// Do not trust any system trusted certificates; we're going to be
// manually validating the chain, so specifying a non-empty pool
// here could only cause additional, unnecessary work.
RootCAs: x509.NewCertPool(),
// Do not bother validating the client's chain; we know it should be
// self-signed. This also disables hostname verification, but we do
// this verification as part of VerifyConnection(...) ourselves.
//
// Per Go docs, this option is only safe in conjunction with
// VerifyConnection which we define above.
InsecureSkipVerify: true,
// RFC 8737 Section 4. acme-tls/1 Protocol Definition:
//
// > ACME servers that implement "acme-tls/1" MUST only negotiate
// > TLS 1.2 [RFC5246] or higher when connecting to clients for
// > validation.
MinVersion: tls.VersionTLS12,
// While RFC 8737 does not place restrictions around allowed cipher
// suites, we wish to restrict ourselves to secure defaults. Specify
// the Intermediate guideline from Mozilla's TLS config generator to
// disable obviously weak ciphers.
//
// See also: https://ssl-config.mozilla.org/#server=go&version=1.14.4&config=intermediate&guideline=5.7
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
}
// Build a dialer using our custom DNS resolver, to ensure domains get
// resolved according to configuration.
dialer, err := buildDialerConfig(config)
if err != nil {
return false, fmt.Errorf("failed to build dialer: %w", err)
}
// Per RFC 8737 Section 3. TLS with Application-Layer Protocol
// Negotiation (TLS ALPN) Challenge:
//
// > 2. The ACME server resolves the domain name being validated and
// > chooses one of the IP addresses returned for validation (the
// > server MAY validate against multiple addresses if more than
// > one is returned).
// > 3. The ACME server initiates a TLS connection to the chosen IP
// > address. This connection MUST use TCP port 443.
address := fmt.Sprintf("%v:"+ALPNPort, domain)
conn, err := dialer.Dial("tcp", address)
if err != nil {
return false, fmt.Errorf("tls-alpn-01: failed to dial host: %w", err)
}
// Initiate the connection to the remote peer.
client := tls.Client(conn, cfg)
// We intentionally swallow this error as it isn't useful to the
// underlying protocol we perform here. Notably, per RFC 8737
// Section 4. acme-tls/1 Protocol Definition:
//
// > Once the handshake is completed, the client MUST NOT exchange
// > any further data with the server and MUST immediately close the
// > connection. ... Because of this, an ACME server MAY choose to
// > withhold authorization if either the certificate signature is
// > invalid or the handshake doesn't fully complete.
defer client.Close()
// We wish to put time bounds on the total time the handshake can
// stall for, so build a connection context here.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// See note above about why we can allow Handshake to complete
// successfully.
if err := client.HandshakeContext(ctx); err != nil {
return false, fmt.Errorf("tls-alpn-01: failed to perform handshake: %w", err)
}
return true, nil
}

View File

@ -1,8 +1,18 @@
package pki
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"strings"
@ -10,6 +20,8 @@ import (
"time"
"github.com/hashicorp/vault/builtin/logical/pki/dnstest"
"github.com/stretchr/testify/require"
)
type keyAuthorizationTestCase struct {
@ -190,7 +202,7 @@ func TestAcmeValidateHTTP01Challenge(t *testing.T) {
func TestAcmeValidateDNS01Challenge(t *testing.T) {
t.Parallel()
host := "alsdkjfasldkj.com"
host := "dadgarcorp.com"
resolver := dnstest.SetupResolver(t, host)
defer resolver.Cleanup()
@ -219,3 +231,473 @@ func TestAcmeValidateDNS01Challenge(t *testing.T) {
resolver.RemoveAllRecords()
}
}
func TestAcmeValidateTLSALPN01Challenge(t *testing.T) {
// This test is not parallel because we modify ALPNPort to use a custom
// non-standard port _just for testing purposes_.
host := "localhost"
config := &acmeConfigEntry{}
returnedProtocols := []string{ALPNProtocol}
var certificates []*x509.Certificate
var privateKey crypto.PrivateKey
tlsCfg := &tls.Config{}
tlsCfg.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) {
var retCfg tls.Config = *tlsCfg
retCfg.NextProtos = returnedProtocols
t.Logf("[alpn-server] returned protocol: %v", returnedProtocols)
return &retCfg, nil
}
tlsCfg.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
var ret tls.Certificate
for index, cert := range certificates {
ret.Certificate = append(ret.Certificate, cert.Raw)
if index == 0 {
ret.Leaf = cert
}
}
ret.PrivateKey = privateKey
t.Logf("[alpn-server] returned certificates: %v", ret)
return &ret, nil
}
ln, err := tls.Listen("tcp", host+":0", tlsCfg)
require.NoError(t, err, "failed to listen with TLS config")
doOneAccept := func() {
t.Logf("[alpn-server] starting accept...")
connRaw, err := ln.Accept()
require.NoError(t, err, "failed to accept TLS connection")
t.Logf("[alpn-server] got connection...")
conn := tls.Server(connRaw.(*tls.Conn), tlsCfg)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer func() {
t.Logf("[alpn-server] defer context cancel executing")
cancel()
}()
t.Logf("[alpn-server] starting handshake...")
if err := conn.HandshakeContext(ctx); err != nil {
t.Logf("[alpn-server] got non-fatal error while handshaking connection: %v", err)
}
t.Logf("[alpn-server] closing connection...")
if err := conn.Close(); err != nil {
t.Logf("[alpn-server] got non-fatal error while closing connection: %v", err)
}
}
ALPNPort = strings.Split(ln.Addr().String(), ":")[1]
type alpnTestCase struct {
name string
certificates []*x509.Certificate
privateKey crypto.PrivateKey
protocols []string
token string
thumbprint string
shouldFail bool
}
var alpnTestCases []alpnTestCase
// Add all of our keyAuthorizationTestCases into alpnTestCases
for index, tc := range keyAuthorizationTestCases {
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[:])
// Build a self-signed certificate.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{
CommonName: host,
},
Issuer: pkix.Name{
CommonName: host,
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: key.Public(),
SerialNumber: big.NewInt(1),
DNSNames: []string{host},
ExtraExtensions: []pkix.Extension{
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
},
},
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create certificate")
cert, err := x509.ParseCertificate(certBytes)
require.NoError(t, err, "failed to parse newly generated certificate")
newTc := alpnTestCase{
name: fmt.Sprintf("keyAuthorizationTestCase[%d]", index),
certificates: []*x509.Certificate{cert},
privateKey: key,
protocols: []string{ALPNProtocol},
token: tc.token,
thumbprint: tc.thumbprint,
shouldFail: tc.shouldFail,
}
alpnTestCases = append(alpnTestCases, newTc)
}
{
// Test case: Longer chain
// Build a self-signed certificate.
rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating root private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{
CommonName: "Root CA",
},
Issuer: pkix.Name{
CommonName: "Root CA",
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: rootKey.Public(),
SerialNumber: big.NewInt(1),
BasicConstraintsValid: true,
IsCA: true,
}
rootCertBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, rootKey.Public(), rootKey)
require.NoError(t, err, "failed to create root certificate")
rootCert, err := x509.ParseCertificate(rootCertBytes)
require.NoError(t, err, "failed to parse newly generated root certificate")
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
// Build a leaf certificate which _could_ pass validation
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating leaf private key")
tmpl = &x509.Certificate{
Subject: pkix.Name{
CommonName: host,
},
Issuer: pkix.Name{
CommonName: "Root CA",
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: key.Public(),
SerialNumber: big.NewInt(2),
DNSNames: []string{host},
ExtraExtensions: []pkix.Extension{
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
},
},
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, rootCert, key.Public(), rootKey)
require.NoError(t, err, "failed to create leaf certificate")
cert, err := x509.ParseCertificate(certBytes)
require.NoError(t, err, "failed to parse newly generated leaf certificate")
newTc := alpnTestCase{
name: "longer chain with valid leaf",
certificates: []*x509.Certificate{cert, rootCert},
privateKey: key,
protocols: []string{ALPNProtocol},
token: "valid",
thumbprint: "valid",
shouldFail: true,
}
alpnTestCases = append(alpnTestCases, newTc)
}
{
// Test case: cert without DNSSan
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
// Build a leaf certificate without a DNSSan
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating leaf private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{
CommonName: host,
},
Issuer: pkix.Name{
CommonName: host,
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: key.Public(),
SerialNumber: big.NewInt(2),
// NO DNSNames
ExtraExtensions: []pkix.Extension{
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
},
},
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
cert, err := x509.ParseCertificate(certBytes)
require.NoError(t, err, "failed to parse newly generated leaf certificate")
newTc := alpnTestCase{
name: "valid keyauthz without valid dnsname",
certificates: []*x509.Certificate{cert},
privateKey: key,
protocols: []string{ALPNProtocol},
token: "valid",
thumbprint: "valid",
shouldFail: true,
}
alpnTestCases = append(alpnTestCases, newTc)
}
{
// Test case: cert without matching DNSSan
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
// Build a leaf certificate which fails validation due to bad DNSName
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating leaf private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{
CommonName: host,
},
Issuer: pkix.Name{
CommonName: host,
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: key.Public(),
SerialNumber: big.NewInt(2),
DNSNames: []string{host + ".dadgarcorp.com" /* not matching host! */},
ExtraExtensions: []pkix.Extension{
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
},
},
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
cert, err := x509.ParseCertificate(certBytes)
require.NoError(t, err, "failed to parse newly generated leaf certificate")
newTc := alpnTestCase{
name: "valid keyauthz without matching dnsname",
certificates: []*x509.Certificate{cert},
privateKey: key,
protocols: []string{ALPNProtocol},
token: "valid",
thumbprint: "valid",
shouldFail: true,
}
alpnTestCases = append(alpnTestCases, newTc)
}
{
// Test case: cert with additional SAN
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
// Build a leaf certificate which has an invalid additional SAN
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating leaf private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{
CommonName: host,
},
Issuer: pkix.Name{
CommonName: host,
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: key.Public(),
SerialNumber: big.NewInt(2),
DNSNames: []string{host},
EmailAddresses: []string{"webmaster@" + host}, /* unexpected */
ExtraExtensions: []pkix.Extension{
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
},
},
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
cert, err := x509.ParseCertificate(certBytes)
require.NoError(t, err, "failed to parse newly generated leaf certificate")
newTc := alpnTestCase{
name: "valid keyauthz with additional email SANs",
certificates: []*x509.Certificate{cert},
privateKey: key,
protocols: []string{ALPNProtocol},
token: "valid",
thumbprint: "valid",
shouldFail: true,
}
alpnTestCases = append(alpnTestCases, newTc)
}
{
// Test case: cert without CN
// Compute our authorization.
checksum := sha256.Sum256([]byte("valid.valid"))
authz := base64.RawURLEncoding.EncodeToString(checksum[:])
// Build a leaf certificate which should pass validation
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating leaf private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{},
Issuer: pkix.Name{},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: key.Public(),
SerialNumber: big.NewInt(2),
DNSNames: []string{host},
ExtraExtensions: []pkix.Extension{
{
Id: OIDACMEIdentifier,
Critical: true,
Value: []byte(authz),
},
},
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
cert, err := x509.ParseCertificate(certBytes)
require.NoError(t, err, "failed to parse newly generated leaf certificate")
newTc := alpnTestCase{
name: "valid certificate; no Subject/Issuer (missing CN)",
certificates: []*x509.Certificate{cert},
privateKey: key,
protocols: []string{ALPNProtocol},
token: "valid",
thumbprint: "valid",
shouldFail: false,
}
alpnTestCases = append(alpnTestCases, newTc)
}
{
// Test case: cert without the extension
// Build a leaf certificate which should fail validation
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating leaf private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{},
Issuer: pkix.Name{},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: key.Public(),
SerialNumber: big.NewInt(1),
DNSNames: []string{host},
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
require.NoError(t, err, "failed to create leaf certificate")
cert, err := x509.ParseCertificate(certBytes)
require.NoError(t, err, "failed to parse newly generated leaf certificate")
newTc := alpnTestCase{
name: "missing required acmeIdentifier extension",
certificates: []*x509.Certificate{cert},
privateKey: key,
protocols: []string{ALPNProtocol},
token: "valid",
thumbprint: "valid",
shouldFail: true,
}
alpnTestCases = append(alpnTestCases, newTc)
}
{
// Test case: root without a leaf
// Build a self-signed certificate.
rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed generating root private key")
tmpl := &x509.Certificate{
Subject: pkix.Name{
CommonName: "Root CA",
},
Issuer: pkix.Name{
CommonName: "Root CA",
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
PublicKey: rootKey.Public(),
SerialNumber: big.NewInt(1),
BasicConstraintsValid: true,
IsCA: true,
}
rootCertBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, rootKey.Public(), rootKey)
require.NoError(t, err, "failed to create root certificate")
rootCert, err := x509.ParseCertificate(rootCertBytes)
require.NoError(t, err, "failed to parse newly generated root certificate")
newTc := alpnTestCase{
name: "root without leaf",
certificates: []*x509.Certificate{rootCert},
privateKey: rootKey,
protocols: []string{ALPNProtocol},
token: "valid",
thumbprint: "valid",
shouldFail: true,
}
alpnTestCases = append(alpnTestCases, newTc)
}
for index, tc := range alpnTestCases {
t.Logf("\n\n[tc=%d/name=%s] starting validation", index, tc.name)
certificates = tc.certificates
privateKey = tc.privateKey
returnedProtocols = tc.protocols
// Attempt to validate the challenge.
go doOneAccept()
isValid, err := ValidateTLSALPN01Challenge(host, tc.token, tc.thumbprint, config)
if !isValid && err == nil {
t.Fatalf("[tc=%d/name=%s] expected failure to give reason via err (%v / %v)", index, tc.name, isValid, err)
}
expectedValid := !tc.shouldFail
if expectedValid != isValid {
t.Fatalf("[tc=%d/name=%s] got ret=%v (err=%v), expected ret=%v (shouldFail=%v)", index, tc.name, isValid, err, expectedValid, tc.shouldFail)
} else if err != nil {
t.Logf("[tc=%d/name=%s] got expected failure: err=%v", index, tc.name, err)
}
}
}

View File

@ -799,7 +799,7 @@ func generateAuthorization(acct *acmeAccount, identifier *ACMEIdentifier) (*ACME
// Certain challenges have certain restrictions: DNS challenges cannot
// be used to validate IP addresses, and only DNS challenges can be used
// to validate wildcards.
allowedChallenges := []ACMEChallengeType{ACMEHTTPChallenge, ACMEDNSChallenge}
allowedChallenges := []ACMEChallengeType{ACMEHTTPChallenge, ACMEDNSChallenge, ACMEALPNChallenge}
if identifier.Type == ACMEIPIdentifier {
allowedChallenges = []ACMEChallengeType{ACMEHTTPChallenge}
} else if identifier.IsWildcard {

View File

@ -184,7 +184,7 @@ func TestAcmeBasicWorkflow(t *testing.T) {
require.False(t, domainAuth.Wildcard, "should not be a wildcard")
require.True(t, domainAuth.Expires.IsZero(), "authorization should only have expiry set on valid status")
require.Len(t, domainAuth.Challenges, 2, "expected two challenges")
require.Len(t, domainAuth.Challenges, 3, "expected three challenges")
require.Equal(t, acme.StatusPending, domainAuth.Challenges[0].Status)
require.True(t, domainAuth.Challenges[0].Validated.IsZero(), "validated time should be 0 on challenge")
require.Equal(t, "http-01", domainAuth.Challenges[0].Type)
@ -193,6 +193,10 @@ func TestAcmeBasicWorkflow(t *testing.T) {
require.True(t, domainAuth.Challenges[1].Validated.IsZero(), "validated time should be 0 on challenge")
require.Equal(t, "dns-01", domainAuth.Challenges[1].Type)
require.NotEmpty(t, domainAuth.Challenges[1].Token, "missing challenge token")
require.Equal(t, acme.StatusPending, domainAuth.Challenges[2].Status)
require.True(t, domainAuth.Challenges[2].Validated.IsZero(), "validated time should be 0 on challenge")
require.Equal(t, "tls-alpn-01", domainAuth.Challenges[2].Type)
require.NotEmpty(t, domainAuth.Challenges[2].Token, "missing challenge token")
// Test the values for the wildcard authentication
require.Equal(t, acme.StatusPending, wildcardAuth.Status)
@ -201,7 +205,7 @@ func TestAcmeBasicWorkflow(t *testing.T) {
require.True(t, wildcardAuth.Wildcard, "should be a wildcard")
require.True(t, wildcardAuth.Expires.IsZero(), "authorization should only have expiry set on valid status")
require.Len(t, wildcardAuth.Challenges, 1, "expected two challenges")
require.Len(t, wildcardAuth.Challenges, 1, "expected one challenge")
require.Equal(t, acme.StatusPending, domainAuth.Challenges[0].Status)
require.True(t, wildcardAuth.Challenges[0].Validated.IsZero(), "validated time should be 0 on challenge")
require.Equal(t, "dns-01", wildcardAuth.Challenges[0].Type)
@ -1080,7 +1084,7 @@ func TestAcmeValidationError(t *testing.T) {
authorizations = append(authorizations, auth)
}
require.Len(t, authorizations, 1, "expected a certain number of authorizations")
require.Len(t, authorizations[0].Challenges, 2, "expected a certain number of challenges associated with authorization")
require.Len(t, authorizations[0].Challenges, 3, "expected a certain number of challenges associated with authorization")
acceptedAuth, err := acmeClient.Accept(testCtx, authorizations[0].Challenges[0])
require.NoError(t, err, "Should have been allowed to accept challenge 1")

3
changelog/20943.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
secrets/pki: Support TLS-ALPN-01 challenge type in ACME for DNS certificate identifiers.
```

View File

@ -165,8 +165,9 @@ identifiers.
Vault supports the following ACME challenge types presently:
- `http-01`, supporting both `dns` and `ip` identifiers,
- `http-01`, supporting both `dns` and `ip` identifiers.
- `dns-01`, supporting `dns` identifiers including wildcards.
- `tls-alpn-01`, supporting only non-wildcard `dns` identifiers.
A custom DNS resolver used by the server for looking up DNS names for use
with both mechanisms can be added via the [ACME configuration](#set-acme-configuration).