524 lines
16 KiB
Go
524 lines
16 KiB
Go
package va
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jmhodges/clock"
|
|
"github.com/letsencrypt/challtestsrv"
|
|
"github.com/letsencrypt/pebble/acme"
|
|
"github.com/letsencrypt/pebble/core"
|
|
)
|
|
|
|
const (
|
|
whitespaceCutset = "\n\r\t"
|
|
userAgentBase = "LetsEncrypt-Pebble-VA"
|
|
|
|
// How long do valid authorizations last before expiring?
|
|
validAuthzExpire = time.Hour
|
|
|
|
// How many vaTasks can be in the channel before the WFE blocks on adding
|
|
// another?
|
|
taskQueueSize = 6
|
|
|
|
// How many concurrent validations are performed?
|
|
concurrentValidations = 3
|
|
|
|
// noSleepEnvVar defines the environment variable name used to signal that the
|
|
// VA should *not* sleep between validation attempts. Set this to 1 when you
|
|
// invoke Pebble if you wish validation to be done at full speed, e.g.:
|
|
// PEBBLE_VA_NOSLEEP=1 pebble
|
|
noSleepEnvVar = "PEBBLE_VA_NOSLEEP"
|
|
|
|
// sleepTimeEnvVar defines the environment variable name used to set the time
|
|
// the VA should sleep between validation attempts (if not disabled). Set this
|
|
// e.g. to 5 when you invoke Pebble if you wish the delays to be between 0
|
|
// and 5 seconds (instead between 0 and 15 seconds):
|
|
// PEBBLE_VA_SLEEPTIME=5 pebble
|
|
sleepTimeEnvVar = "PEBBLE_VA_SLEEPTIME"
|
|
|
|
// defaultSleepTime defines the default sleep time (in seconds) between
|
|
// validation attempts. Can be disabled or modified by the environment
|
|
// variables PEBBLE_VA_NOSLEEP resp. PEBBLE_VA_SLEEPTIME (see above).
|
|
defaultSleepTime = 5
|
|
|
|
// validationTimeout defines the timeout for validation attempts.
|
|
validationTimeout = 15 * time.Second
|
|
|
|
// noValidateEnvVar defines the environment variable name used to signal that
|
|
// the VA should *not* actually validate challenges. Set this to 1 when you
|
|
// invoke Pebble if you wish validation to always succeed without actually
|
|
// making any challenge requests, e.g.:
|
|
// PEBBLE_VA_ALWAYS_VALID=1 pebble"
|
|
noValidateEnvVar = "PEBBLE_VA_ALWAYS_VALID"
|
|
)
|
|
|
|
func userAgent() string {
|
|
return fmt.Sprintf(
|
|
"%s (%s; %s)",
|
|
userAgentBase, runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
|
|
// certNames collects up all of a certificate's subject names (Subject CN and
|
|
// Subject Alternate Names) and reduces them to a comma joined string.
|
|
func certNames(cert *x509.Certificate) string {
|
|
var names []string
|
|
if cert.Subject.CommonName != "" {
|
|
names = append(names, cert.Subject.CommonName)
|
|
}
|
|
names = append(names, cert.DNSNames...)
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
type vaTask struct {
|
|
Identifier string
|
|
Challenge *core.Challenge
|
|
Account *core.Account
|
|
}
|
|
|
|
type VAImpl struct {
|
|
log *log.Logger
|
|
clk clock.Clock
|
|
httpPort int
|
|
tlsPort int
|
|
tasks chan *vaTask
|
|
sleep bool
|
|
sleepTime int
|
|
alwaysValid bool
|
|
strict bool
|
|
}
|
|
|
|
func New(
|
|
log *log.Logger,
|
|
clk clock.Clock,
|
|
httpPort, tlsPort int,
|
|
strict bool) *VAImpl {
|
|
va := &VAImpl{
|
|
log: log,
|
|
clk: clk,
|
|
httpPort: httpPort,
|
|
tlsPort: tlsPort,
|
|
tasks: make(chan *vaTask, taskQueueSize),
|
|
sleep: true,
|
|
sleepTime: defaultSleepTime,
|
|
strict: strict,
|
|
}
|
|
|
|
// Read the PEBBLE_VA_NOSLEEP environment variable string
|
|
noSleep := os.Getenv(noSleepEnvVar)
|
|
// If it is set to something true-like, then the VA shouldn't sleep
|
|
switch noSleep {
|
|
case "1", "true", "True", "TRUE":
|
|
va.sleep = false
|
|
va.log.Printf("Disabling random VA sleeps")
|
|
}
|
|
|
|
sleepTime := os.Getenv(sleepTimeEnvVar)
|
|
sleepTimeInt, err := strconv.Atoi(sleepTime)
|
|
if err == nil && va.sleep && sleepTimeInt >= 1 {
|
|
va.sleepTime = sleepTimeInt
|
|
va.log.Printf("Setting maximum random VA sleep time to %d seconds", va.sleepTime)
|
|
}
|
|
|
|
noValidate := os.Getenv(noValidateEnvVar)
|
|
switch noValidate {
|
|
case "1", "true", "True", "TRUE":
|
|
va.alwaysValid = true
|
|
va.log.Printf("Disabling VA challenge requests. VA always returns valid")
|
|
}
|
|
|
|
go va.processTasks()
|
|
return va
|
|
}
|
|
|
|
func (va VAImpl) ValidateChallenge(ident string, chal *core.Challenge, acct *core.Account) {
|
|
task := &vaTask{
|
|
Identifier: ident,
|
|
Challenge: chal,
|
|
Account: acct,
|
|
}
|
|
// Submit the task for validation
|
|
va.tasks <- task
|
|
}
|
|
|
|
func (va VAImpl) processTasks() {
|
|
for task := range va.tasks {
|
|
go va.process(task)
|
|
}
|
|
}
|
|
|
|
func (va VAImpl) firstError(results chan *core.ValidationRecord) *acme.ProblemDetails {
|
|
for i := 0; i < concurrentValidations; i++ {
|
|
result := <-results
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setAuthzValid updates an authorization and an associated challenge to be
|
|
// status valid. The authorization expiry is updated to now plus the configured
|
|
// `validAuthzExpire` duration.
|
|
func (va VAImpl) setAuthzValid(authz *core.Authorization, chal *core.Challenge) {
|
|
authz.Lock()
|
|
defer authz.Unlock()
|
|
// Update the authz expiry for the new validity period
|
|
now := va.clk.Now().UTC()
|
|
authz.ExpiresDate = now.Add(validAuthzExpire)
|
|
authz.Expires = authz.ExpiresDate.Format(time.RFC3339)
|
|
// Update the authz status
|
|
authz.Status = acme.StatusValid
|
|
|
|
chal.Lock()
|
|
defer chal.Unlock()
|
|
// Update the challenge status
|
|
chal.Status = acme.StatusValid
|
|
}
|
|
|
|
// setOrderError updates an order with an error from an authorization
|
|
// validation.
|
|
func (va VAImpl) setOrderError(order *core.Order, err *acme.ProblemDetails) {
|
|
order.Lock()
|
|
defer order.Unlock()
|
|
order.Error = err
|
|
}
|
|
|
|
// setAuthzInvalid updates an authorization and an associated challenge to be
|
|
// status invalid. The challenge's error is set to the provided problem and both
|
|
// the challenge and the authorization have their status updated to invalid.
|
|
func (va VAImpl) setAuthzInvalid(
|
|
authz *core.Authorization,
|
|
chal *core.Challenge,
|
|
err *acme.ProblemDetails) {
|
|
authz.Lock()
|
|
defer authz.Unlock()
|
|
// Update the authz status
|
|
authz.Status = acme.StatusInvalid
|
|
|
|
// Lock the challenge for update
|
|
chal.Lock()
|
|
defer chal.Unlock()
|
|
// Update the challenge error field
|
|
chal.Error = err
|
|
// Update the challenge status
|
|
chal.Status = acme.StatusInvalid
|
|
}
|
|
|
|
func (va VAImpl) process(task *vaTask) {
|
|
va.log.Printf("Pulled a task from the Tasks queue: %#v", task)
|
|
va.log.Printf("Starting %d validations.", concurrentValidations)
|
|
|
|
chal := task.Challenge
|
|
chal.Lock()
|
|
// Update the validated date for the challenge
|
|
now := va.clk.Now().UTC()
|
|
chal.ValidatedDate = now
|
|
chal.Validated = chal.ValidatedDate.Format(time.RFC3339)
|
|
authz := chal.Authz
|
|
chal.Unlock()
|
|
|
|
results := make(chan *core.ValidationRecord, concurrentValidations)
|
|
|
|
// Start a number of go routines to perform concurrent validations
|
|
for i := 0; i < concurrentValidations; i++ {
|
|
go va.performValidation(task, results)
|
|
}
|
|
|
|
err := va.firstError(results)
|
|
// If one of the results was an error, the challenge fails
|
|
if err != nil {
|
|
va.setAuthzInvalid(authz, chal, err)
|
|
va.log.Printf("authz %s set INVALID by completed challenge %s", authz.ID, chal.ID)
|
|
va.setOrderError(authz.Order, err)
|
|
va.log.Printf("order %s set INVALID by invalid authz %s", authz.Order.ID, authz.ID)
|
|
return
|
|
}
|
|
|
|
// If there was no error, then the challenge succeeded and the authz is valid
|
|
va.setAuthzValid(authz, chal)
|
|
va.log.Printf("authz %s set VALID by completed challenge %s", authz.ID, chal.ID)
|
|
}
|
|
|
|
func (va VAImpl) performValidation(task *vaTask, results chan<- *core.ValidationRecord) {
|
|
if va.sleep {
|
|
// Sleep for a random amount of time between 0 and va.sleepTime seconds
|
|
len := time.Duration(rand.Intn(va.sleepTime))
|
|
va.log.Printf("Sleeping for %s seconds before validating", time.Second*len)
|
|
va.clk.Sleep(time.Second * len)
|
|
}
|
|
|
|
// If `alwaysValid` is true then return a validation record immediately
|
|
// without actually making any validation requests.
|
|
if va.alwaysValid {
|
|
va.log.Printf("%s is enabled. Skipping real validation of challenge %s",
|
|
noValidateEnvVar, task.Challenge.ID)
|
|
// NOTE(@cpu): The validation record's URL will not match the value it would
|
|
// have received in a real validation request. For simplicity when faking
|
|
// validation we always set it to the task identifier regardless of challenge
|
|
// type. For example comparison, a real DNS-01 validation would set
|
|
// the URL to the `_acme-challenge` subdomain.
|
|
results <- &core.ValidationRecord{
|
|
URL: task.Identifier,
|
|
ValidatedAt: va.clk.Now(),
|
|
}
|
|
return
|
|
}
|
|
|
|
switch task.Challenge.Type {
|
|
case acme.ChallengeHTTP01:
|
|
results <- va.validateHTTP01(task)
|
|
case acme.ChallengeTLSALPN01:
|
|
results <- va.validateTLSALPN01(task)
|
|
case acme.ChallengeDNS01:
|
|
results <- va.validateDNS01(task)
|
|
default:
|
|
va.log.Printf("Error: performValidation(): Invalid challenge type: %q", task.Challenge.Type)
|
|
}
|
|
}
|
|
|
|
func (va VAImpl) validateDNS01(task *vaTask) *core.ValidationRecord {
|
|
const dns01Prefix = "_acme-challenge"
|
|
challengeSubdomain := fmt.Sprintf("%s.%s", dns01Prefix, task.Identifier)
|
|
|
|
result := &core.ValidationRecord{
|
|
URL: challengeSubdomain,
|
|
ValidatedAt: va.clk.Now(),
|
|
}
|
|
|
|
ctx, cancelfunc := context.WithTimeout(context.Background(), validationTimeout)
|
|
defer cancelfunc()
|
|
|
|
txts, err := net.DefaultResolver.LookupTXT(ctx, challengeSubdomain)
|
|
if err != nil {
|
|
result.Error = acme.UnauthorizedProblem("Error retrieving TXT records for DNS challenge")
|
|
return result
|
|
}
|
|
|
|
if len(txts) == 0 {
|
|
msg := fmt.Sprintf("No TXT records found for DNS challenge")
|
|
result.Error = acme.UnauthorizedProblem(msg)
|
|
return result
|
|
}
|
|
|
|
task.Challenge.RLock()
|
|
expectedKeyAuthorization := task.Challenge.ExpectedKeyAuthorization(task.Account.Key)
|
|
h := sha256.Sum256([]byte(expectedKeyAuthorization))
|
|
task.Challenge.RUnlock()
|
|
authorizedKeysDigest := base64.RawURLEncoding.EncodeToString(h[:])
|
|
|
|
for _, element := range txts {
|
|
if subtle.ConstantTimeCompare([]byte(element), []byte(authorizedKeysDigest)) == 1 {
|
|
return result
|
|
}
|
|
}
|
|
|
|
msg := fmt.Sprintf("Correct value not found for DNS challenge")
|
|
result.Error = acme.UnauthorizedProblem(msg)
|
|
return result
|
|
}
|
|
|
|
func (va VAImpl) validateTLSALPN01(task *vaTask) *core.ValidationRecord {
|
|
portString := strconv.Itoa(va.tlsPort)
|
|
hostPort := net.JoinHostPort(task.Identifier, portString)
|
|
|
|
result := &core.ValidationRecord{
|
|
URL: hostPort,
|
|
ValidatedAt: va.clk.Now(),
|
|
}
|
|
|
|
cs, problem := va.fetchConnectionState(hostPort, &tls.Config{
|
|
ServerName: task.Identifier,
|
|
NextProtos: []string{acme.ACMETLS1Protocol},
|
|
InsecureSkipVerify: true,
|
|
})
|
|
if problem != nil {
|
|
result.Error = problem
|
|
return result
|
|
}
|
|
|
|
if !cs.NegotiatedProtocolIsMutual || cs.NegotiatedProtocol != acme.ACMETLS1Protocol {
|
|
result.Error = acme.UnauthorizedProblem(fmt.Sprintf(
|
|
"Cannot negotiate ALPN protocol %q for %s challenge",
|
|
acme.ACMETLS1Protocol,
|
|
acme.ChallengeTLSALPN01,
|
|
))
|
|
return result
|
|
}
|
|
|
|
certs := cs.PeerCertificates
|
|
if len(certs) == 0 {
|
|
result.Error = acme.UnauthorizedProblem(fmt.Sprintf("No certs presented for %s challenge", acme.ChallengeTLSALPN01))
|
|
return result
|
|
}
|
|
leafCert := certs[0]
|
|
|
|
// Verify SNI - certificate returned must be issued only for the domain we are verifying.
|
|
if len(leafCert.DNSNames) != 1 || !strings.EqualFold(leafCert.DNSNames[0], task.Identifier) {
|
|
names := certNames(leafCert)
|
|
errText := fmt.Sprintf(
|
|
"Incorrect validation certificate for %s challenge. "+
|
|
"Requested %s from %s. Received %d certificate(s), "+
|
|
"first certificate had names %q",
|
|
acme.ChallengeTLSALPN01, task.Identifier, hostPort, len(certs), names)
|
|
result.Error = acme.UnauthorizedProblem(errText)
|
|
return result
|
|
}
|
|
|
|
// Verify key authorization in acmeValidation extension
|
|
expectedKeyAuthorization := task.Challenge.ExpectedKeyAuthorization(task.Account.Key)
|
|
h := sha256.Sum256([]byte(expectedKeyAuthorization))
|
|
for _, ext := range leafCert.Extensions {
|
|
if ext.Critical {
|
|
hasAcmeIdentifier := challtestsrv.IdPeAcmeIdentifier.Equal(ext.Id)
|
|
if hasAcmeIdentifier {
|
|
var extValue []byte
|
|
if _, err := asn1.Unmarshal(ext.Value, &extValue); err != nil {
|
|
errText := fmt.Sprintf("Incorrect validation certificate for %s challenge. "+
|
|
"Malformed acmeValidation extension value.", acme.ChallengeTLSALPN01)
|
|
result.Error = acme.UnauthorizedProblem(errText)
|
|
return result
|
|
}
|
|
if subtle.ConstantTimeCompare(h[:], extValue) == 1 {
|
|
return result
|
|
}
|
|
errText := fmt.Sprintf("Incorrect validation certificate for %s challenge. "+
|
|
"Invalid acmeValidation extension value.", acme.ChallengeTLSALPN01)
|
|
result.Error = acme.UnauthorizedProblem(errText)
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
errText := fmt.Sprintf(
|
|
"Incorrect validation certificate for %s challenge. "+
|
|
"Missing acmeValidationV1 extension.",
|
|
acme.ChallengeTLSALPN01)
|
|
result.Error = acme.UnauthorizedProblem(errText)
|
|
return result
|
|
}
|
|
|
|
func (va VAImpl) fetchConnectionState(hostPort string, config *tls.Config) (*tls.ConnectionState, *acme.ProblemDetails) {
|
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: validationTimeout}, "tcp", hostPort, config)
|
|
|
|
if err != nil {
|
|
// TODO(@cpu): Return better err - see parseHTTPConnError from boulder
|
|
return nil, acme.UnauthorizedProblem(
|
|
fmt.Sprintf("Failed to connect to %s for the %s challenge", hostPort, acme.ChallengeTLSALPN01))
|
|
}
|
|
|
|
// close errors are not important here
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
cs := conn.ConnectionState()
|
|
return &cs, nil
|
|
}
|
|
|
|
func (va VAImpl) validateHTTP01(task *vaTask) *core.ValidationRecord {
|
|
body, url, err := va.fetchHTTP(task.Identifier, task.Challenge.Token)
|
|
|
|
result := &core.ValidationRecord{
|
|
URL: url,
|
|
ValidatedAt: va.clk.Now(),
|
|
Error: err,
|
|
}
|
|
if result.Error != nil {
|
|
return result
|
|
}
|
|
|
|
expectedKeyAuthorization := task.Challenge.ExpectedKeyAuthorization(task.Account.Key)
|
|
// The server SHOULD ignore whitespace characters at the end of the body
|
|
payload := strings.TrimRight(string(body), whitespaceCutset)
|
|
if payload != expectedKeyAuthorization {
|
|
result.Error = acme.UnauthorizedProblem(
|
|
fmt.Sprintf("The key authorization file from the server did not match this challenge %q != %q",
|
|
expectedKeyAuthorization, payload))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// NOTE(@cpu): fetchHTTP only fetches the ACME HTTP-01 challenge path for
|
|
// a given challenge & identifier domain. It is not a challenge agnostic general
|
|
// purpose HTTP function
|
|
func (va VAImpl) fetchHTTP(identifier string, token string) ([]byte, string, *acme.ProblemDetails) {
|
|
path := fmt.Sprintf("%s%s", acme.HTTP01BaseURL, token)
|
|
|
|
url := &url.URL{
|
|
Scheme: "http",
|
|
Host: fmt.Sprintf("%s:%d", identifier, va.httpPort),
|
|
Path: path,
|
|
}
|
|
|
|
va.log.Printf("Attempting to validate w/ HTTP: %s\n", url)
|
|
httpRequest, err := http.NewRequest("GET", url.String(), nil)
|
|
if err != nil {
|
|
return nil, url.String(), acme.MalformedProblem(
|
|
fmt.Sprintf("Invalid URL %q\n", url.String()))
|
|
}
|
|
httpRequest.Header.Set("User-Agent", userAgent())
|
|
httpRequest.Header.Set("Accept", "*/*")
|
|
|
|
transport := &http.Transport{
|
|
// We don't expect to make multiple requests to a client, so close
|
|
// connection immediately.
|
|
DisableKeepAlives: true,
|
|
|
|
// We always ask for a challenge on HTTP, but
|
|
// we should ignore certificate errors if we get redirected
|
|
// to an HTTPS host.
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: validationTimeout,
|
|
}
|
|
|
|
resp, err := client.Do(httpRequest)
|
|
if err != nil {
|
|
return nil, url.String(), acme.ConnectionProblem(err.Error())
|
|
}
|
|
|
|
// NOTE: This is *not* using a `io.LimitedReader` and isn't suitable for
|
|
// production because a very large response will bog down the server. Don't
|
|
// use Pebble anywhere that isn't a testing rig!!!
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, url.String(), acme.InternalErrorProblem(err.Error())
|
|
}
|
|
err = resp.Body.Close()
|
|
if err != nil {
|
|
return nil, url.String(), acme.InternalErrorProblem(err.Error())
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, url.String(), acme.UnauthorizedProblem(
|
|
fmt.Sprintf("Non-200 status code from HTTP: %s returned %d",
|
|
url.String(), resp.StatusCode))
|
|
}
|
|
|
|
return body, url.String(), nil
|
|
}
|