open-vault/builtin/logical/pki/acme_challenges.go
hc-github-team-secure-vault-core 2d3e52fa92
backport of commit 28e3507680d78dbf80b3edc78bc16119088d4ba2 (#24142)
Co-authored-by: Robert Hanzlík <robi@junyks.cz>
2023-11-15 16:33:02 +00:00

503 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package pki
import (
"bytes"
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
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
// not.
func ValidateKeyAuthorization(keyAuthz string, token string, thumbprint string) (bool, error) {
parts := strings.Split(keyAuthz, ".")
if len(parts) != 2 {
return false, fmt.Errorf("invalid authorization: got %v parts, expected 2", len(parts))
}
tokenPart := parts[0]
thumbprintPart := parts[1]
if token != tokenPart || thumbprint != thumbprintPart {
return false, fmt.Errorf("key authorization was invalid")
}
return true, nil
}
// ValidateSHA256KeyAuthorization validates that the given keyAuthz from a
// challenge matches our expectation, returning (true, nil) if so, or
// (false, err) if not.
//
// This is for use with DNS challenges, which require base64 encoding.
func ValidateSHA256KeyAuthorization(keyAuthz string, token string, thumbprint string) (bool, error) {
authzContents := token + "." + thumbprint
checksum := sha256.Sum256([]byte(authzContents))
expectedAuthz := base64.RawURLEncoding.EncodeToString(checksum[:])
if keyAuthz != expectedAuthz {
return false, fmt.Errorf("sha256 key authorization was invalid")
}
return true, nil
}
// ValidateRawSHA256KeyAuthorization validates that the given keyAuthz from a
// challenge matches our expectation, returning (true, nil) if so, or
// (false, err) if not.
//
// This is for use with TLS challenges, which require the raw hash output.
func ValidateRawSHA256KeyAuthorization(keyAuthz []byte, token string, thumbprint string) (bool, error) {
authzContents := token + "." + thumbprint
expectedAuthz := sha256.Sum256([]byte(authzContents))
if len(keyAuthz) != len(expectedAuthz) || subtle.ConstantTimeCompare(expectedAuthz[:], keyAuthz) != 1 {
return false, fmt.Errorf("sha256 key authorization was invalid")
}
return true, nil
}
func buildResolver(config *acmeConfigEntry) (*net.Resolver, error) {
if len(config.DNSResolver) == 0 {
return net.DefaultResolver, nil
}
return &net.Resolver{
PreferGo: true,
StrictErrors: false,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: 10 * time.Second,
}
return d.DialContext(ctx, network, config.DNSResolver)
},
}, 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
dialer, err := buildDialerConfig(config)
if err != nil {
return false, fmt.Errorf("failed to build dialer: %w", err)
}
transport := &http.Transport{
// Only a single request is sent to this server as we do not do any
// batching of validation attempts. There is no need to do an HTTP
// KeepAlive as a result.
DisableKeepAlives: true,
MaxIdleConns: 1,
MaxIdleConnsPerHost: 1,
MaxConnsPerHost: 1,
IdleConnTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
// We'd rather timeout and re-attempt validation later than hang
// too many validators waiting for slow hosts.
DialContext: dialer.DialContext,
ResponseHeaderTimeout: 10 * time.Second,
}
maxRedirects := 10
urlLength := 2000
client := &http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via)+1 >= maxRedirects {
return fmt.Errorf("http-01: too many redirects: %v", len(via)+1)
}
reqUrlLen := len(req.URL.String())
if reqUrlLen > urlLength {
return fmt.Errorf("http-01: redirect url length too long: %v", reqUrlLen)
}
return nil
},
}
resp, err := client.Get(path)
if err != nil {
return false, fmt.Errorf("http-01: failed to fetch path %v: %w", path, err)
}
// We provision a buffer which allows for a variable size challenge, some
// whitespace, and a detection gap for too long of a message.
minExpected := len(token) + 1 + len(thumbprint)
maxExpected := 512
defer resp.Body.Close()
// Attempt to read the body, but don't do so infinitely.
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxExpected+1)))
if err != nil {
return false, fmt.Errorf("http-01: unexpected error while reading body: %w", err)
}
if len(body) > maxExpected {
return false, fmt.Errorf("http-01: response too large: received %v > %v bytes", len(body), maxExpected)
}
if len(body) < minExpected {
return false, fmt.Errorf("http-01: response too small: received %v < %v bytes", len(body), minExpected)
}
// Per RFC 8555 Section 8.3. HTTP Challenge:
//
// > The server SHOULD ignore whitespace characters at the end of the body.
keyAuthz := string(body)
keyAuthz = strings.TrimSpace(keyAuthz)
// If we got here, we got no non-EOF error while reading. Try to validate
// the token because we're bounded by a reasonable amount of length.
return ValidateKeyAuthorization(keyAuthz, token, thumbprint)
}
func ValidateDNS01Challenge(domain string, token string, thumbprint string, config *acmeConfigEntry) (bool, error) {
// Here, domain is the value from the post-wildcard-processed identifier.
// Per RFC 8555, no difference in validation occurs if a wildcard entry
// is requested or if a non-wildcard entry is requested.
//
// XXX: In this case the DNS server is operator controlled and is assumed
// to be less malicious so the default resolver is used. In the future,
// we'll want to use net.Resolver for two reasons:
//
// 1. To control the actual resolver via ACME configuration,
// 2. To use a context to set stricter timeout limits.
resolver, err := buildResolver(config)
if err != nil {
return false, fmt.Errorf("failed to build resolver: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
name := DNSChallengePrefix + domain
results, err := resolver.LookupTXT(ctx, name)
if err != nil {
return false, fmt.Errorf("dns-01: failed to lookup TXT records for domain (%v) via resolver %v: %w", name, config.DNSResolver, err)
}
for _, keyAuthz := range results {
ok, _ := ValidateSHA256KeyAuthorization(keyAuthz, token, thumbprint)
if ok {
return true, nil
}
}
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).
// NOTE: Do not use cert.CheckSignatureFrom(cert) as we need to bypass the
// checks for the parent certificate having the IsCA basic constraint set.
err := cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature)
if err != nil {
return fmt.Errorf("server under test returned a non-self-signed certificate: %v", err)
}
if !bytes.Equal(cert.RawSubject, cert.RawIssuer) {
return fmt.Errorf("server under test returned a non-self-signed certificate: invalid subject (%v) <-> issuer (%v) match", cert.Subject.String(), cert.Issuer.String())
}
// 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")
}
var keyAuthz []byte
remainder, err := asn1.Unmarshal(ext.Value, &keyAuthz)
if err != nil {
return fmt.Errorf("server under test returned a certificate with invalid acmeIdentifier extension value: %w", err)
}
if len(remainder) > 0 {
return fmt.Errorf("server under test returned a certificate with invalid acmeIdentifier extension value with additional trailing data")
}
ok, err := ValidateRawSHA256KeyAuthorization(keyAuthz, token, thumbprint)
if !ok || err != nil {
return fmt.Errorf("server under test returned a certificate with an invalid key authorization (%w)", err)
}
}
// 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
}