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()) 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) 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: default:
err = fmt.Errorf("unsupported ACME challenge type %v for challenge %v", cv.ChallengeType, id) err = fmt.Errorf("unsupported ACME challenge type %v for challenge %v", cv.ChallengeType, id)
return ace._verifyChallengeCleanup(sc, err, id) return ace._verifyChallengeCleanup(sc, err, id)

View file

@ -1,8 +1,12 @@
package pki package pki
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/asn1"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
@ -12,7 +16,21 @@ import (
"time" "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 // ValidateKeyAuthorization validates that the given keyAuthz from a challenge
// matches our expectation, returning (true, nil) if so, or (false, err) if // matches our expectation, returning (true, nil) if so, or (false, err) if
@ -67,15 +85,28 @@ func buildResolver(config *acmeConfigEntry) (*net.Resolver, error) {
}, nil }, 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, // Validates a given ACME http-01 challenge against the specified domain,
// per RFC 8555. // per RFC 8555.
// //
// We attempt to be defensive here against timeouts, extra redirects, &c. // We attempt to be defensive here against timeouts, extra redirects, &c.
func ValidateHTTP01Challenge(domain string, token string, thumbprint string, config *acmeConfigEntry) (bool, error) { func ValidateHTTP01Challenge(domain string, token string, thumbprint string, config *acmeConfigEntry) (bool, error) {
path := "http://" + domain + "/.well-known/acme-challenge/" + token path := "http://" + domain + "/.well-known/acme-challenge/" + token
resolver, err := buildResolver(config) dialer, err := buildDialerConfig(config)
if err != nil { 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{ 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 // We'd rather timeout and re-attempt validation later than hang
// too many validators waiting for slow hosts. // too many validators waiting for slow hosts.
DialContext: (&net.Dialer{ DialContext: dialer.DialContext,
Timeout: 10 * time.Second,
KeepAlive: -1 * time.Second,
Resolver: resolver,
}).DialContext,
ResponseHeaderTimeout: 10 * time.Second, 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)) 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 package pki
import ( import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64" "encoding/base64"
"fmt"
"math/big"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@ -10,6 +20,8 @@ import (
"time" "time"
"github.com/hashicorp/vault/builtin/logical/pki/dnstest" "github.com/hashicorp/vault/builtin/logical/pki/dnstest"
"github.com/stretchr/testify/require"
) )
type keyAuthorizationTestCase struct { type keyAuthorizationTestCase struct {
@ -190,7 +202,7 @@ func TestAcmeValidateHTTP01Challenge(t *testing.T) {
func TestAcmeValidateDNS01Challenge(t *testing.T) { func TestAcmeValidateDNS01Challenge(t *testing.T) {
t.Parallel() t.Parallel()
host := "alsdkjfasldkj.com" host := "dadgarcorp.com"
resolver := dnstest.SetupResolver(t, host) resolver := dnstest.SetupResolver(t, host)
defer resolver.Cleanup() defer resolver.Cleanup()
@ -219,3 +231,473 @@ func TestAcmeValidateDNS01Challenge(t *testing.T) {
resolver.RemoveAllRecords() 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 // Certain challenges have certain restrictions: DNS challenges cannot
// be used to validate IP addresses, and only DNS challenges can be used // be used to validate IP addresses, and only DNS challenges can be used
// to validate wildcards. // to validate wildcards.
allowedChallenges := []ACMEChallengeType{ACMEHTTPChallenge, ACMEDNSChallenge} allowedChallenges := []ACMEChallengeType{ACMEHTTPChallenge, ACMEDNSChallenge, ACMEALPNChallenge}
if identifier.Type == ACMEIPIdentifier { if identifier.Type == ACMEIPIdentifier {
allowedChallenges = []ACMEChallengeType{ACMEHTTPChallenge} allowedChallenges = []ACMEChallengeType{ACMEHTTPChallenge}
} else if identifier.IsWildcard { } 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.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.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.Equal(t, acme.StatusPending, domainAuth.Challenges[0].Status)
require.True(t, domainAuth.Challenges[0].Validated.IsZero(), "validated time should be 0 on challenge") require.True(t, domainAuth.Challenges[0].Validated.IsZero(), "validated time should be 0 on challenge")
require.Equal(t, "http-01", domainAuth.Challenges[0].Type) 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.True(t, domainAuth.Challenges[1].Validated.IsZero(), "validated time should be 0 on challenge")
require.Equal(t, "dns-01", domainAuth.Challenges[1].Type) require.Equal(t, "dns-01", domainAuth.Challenges[1].Type)
require.NotEmpty(t, domainAuth.Challenges[1].Token, "missing challenge token") 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 // Test the values for the wildcard authentication
require.Equal(t, acme.StatusPending, wildcardAuth.Status) 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.Wildcard, "should be a wildcard")
require.True(t, wildcardAuth.Expires.IsZero(), "authorization should only have expiry set on valid status") 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.Equal(t, acme.StatusPending, domainAuth.Challenges[0].Status)
require.True(t, wildcardAuth.Challenges[0].Validated.IsZero(), "validated time should be 0 on challenge") require.True(t, wildcardAuth.Challenges[0].Validated.IsZero(), "validated time should be 0 on challenge")
require.Equal(t, "dns-01", wildcardAuth.Challenges[0].Type) require.Equal(t, "dns-01", wildcardAuth.Challenges[0].Type)
@ -1080,7 +1084,7 @@ func TestAcmeValidationError(t *testing.T) {
authorizations = append(authorizations, auth) authorizations = append(authorizations, auth)
} }
require.Len(t, authorizations, 1, "expected a certain number of authorizations") 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]) acceptedAuth, err := acmeClient.Accept(testCtx, authorizations[0].Challenges[0])
require.NoError(t, err, "Should have been allowed to accept challenge 1") 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: 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. - `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 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). with both mechanisms can be added via the [ACME configuration](#set-acme-configuration).