diff --git a/builtin/logical/pki/acme_challenge_engine.go b/builtin/logical/pki/acme_challenge_engine.go index 08f33470ed..528f65038d 100644 --- a/builtin/logical/pki/acme_challenge_engine.go +++ b/builtin/logical/pki/acme_challenge_engine.go @@ -417,6 +417,22 @@ func (ace *ACMEChallengeEngine) _verifyChallenge(sc *storageContext, id string, err = fmt.Errorf("%w: error validating dns-01 challenge %v: %v; %v", ErrIncorrectResponse, id, err, ChallengeAttemptFailedMsg) 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) diff --git a/builtin/logical/pki/acme_challenges.go b/builtin/logical/pki/acme_challenges.go index e7aed6c796..855035729e 100644 --- a/builtin/logical/pki/acme_challenges.go +++ b/builtin/logical/pki/acme_challenges.go @@ -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 Application‑Layer 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 +} diff --git a/builtin/logical/pki/acme_challenges_test.go b/builtin/logical/pki/acme_challenges_test.go index 137ea29287..3bcdf88141 100644 --- a/builtin/logical/pki/acme_challenges_test.go +++ b/builtin/logical/pki/acme_challenges_test.go @@ -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) + } + } +} diff --git a/builtin/logical/pki/path_acme_order.go b/builtin/logical/pki/path_acme_order.go index 5d27d7695b..6c1918eec4 100644 --- a/builtin/logical/pki/path_acme_order.go +++ b/builtin/logical/pki/path_acme_order.go @@ -845,7 +845,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 { diff --git a/builtin/logical/pki/path_acme_test.go b/builtin/logical/pki/path_acme_test.go index a264f663d0..0ec822b210 100644 --- a/builtin/logical/pki/path_acme_test.go +++ b/builtin/logical/pki/path_acme_test.go @@ -185,7 +185,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) @@ -194,6 +194,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) @@ -202,7 +206,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) @@ -1255,7 +1259,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") diff --git a/changelog/20943.txt b/changelog/20943.txt new file mode 100644 index 0000000000..7cf186d184 --- /dev/null +++ b/changelog/20943.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/pki: Support TLS-ALPN-01 challenge type in ACME for DNS certificate identifiers. +``` diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx index 885c0e480c..ee7b78492f 100644 --- a/website/content/api-docs/secret/pki.mdx +++ b/website/content/api-docs/secret/pki.mdx @@ -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).