dfb29f3b96
* Add DNS challenge validation to ACME This allows us to validate against wildcard domain names, as the HTTP-01 challenge does not support wildcard validation. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> * Properly delay challenge retry attempts Previously we'd essentially semi-busy wait for a challenge to become retry-able, as the queue itself had no knowledge of the retry after value of the validation attempt. Now, we plumb through this value into the queue itself, to aide selection of validations to attempt. Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com> --------- Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
158 lines
4.9 KiB
Go
158 lines
4.9 KiB
Go
package pki
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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) (bool, error) {
|
|
path := "http://" + domain + "/.well-known/acme-challenge/" + token
|
|
|
|
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,
|
|
}).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) (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.
|
|
name := "_acme-challenge." + domain
|
|
results, err := net.LookupTXT(name)
|
|
if err != nil {
|
|
return false, fmt.Errorf("dns-01: failed to lookup TXT records for domain (%v): %w", name, 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))
|
|
}
|