e42fd09b47
* Handle caching of ACME config Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add DNS resolvers to ACME configuration Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add custom DNS resolver to challenge verification This required plumbing through the config, reloading it when necessary, and creating a custom net.Resolver instance. Not immediately clear is how we'd go about building a custom DNS validation mechanism that supported multiple resolvers. Likely we'd need to rely on meikg/dns and handle the resolution separately for each container and use a custom Dialer that assumes the address is already pre-resolved. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Improvements to Docker harness - Expose additional service information, allowing callers to figure out both the local address and the network-specific address of the service container, and - Allow modifying permissions on uploaded container files. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Add infrastructure to run Bind9 in a container for tests Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Validate DNS-01 challenge works Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> --------- Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
191 lines
5.9 KiB
Go
191 lines
5.9 KiB
Go
package pki
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const DNSChallengePrefix = "_acme-challenge."
|
|
|
|
// 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
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to build resolver: %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,
|
|
|
|
// 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,
|
|
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))
|
|
}
|