boulder/va/validation-authority.go

515 lines
15 KiB
Go

// Copyright 2014 ISRG. All rights reserved
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package va
import (
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/letsencrypt/boulder/Godeps/_workspace/src/github.com/miekg/dns"
"github.com/letsencrypt/boulder/core"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/policy"
)
// ValidationAuthorityImpl represents a VA
type ValidationAuthorityImpl struct {
RA core.RegistrationAuthority
log *blog.AuditLogger
DNSResolver core.DNSResolver
IssuerDomain string
TestMode bool
UserAgent string
}
// NewValidationAuthorityImpl constructs a new VA, and may place it
// into Test Mode (tm)
func NewValidationAuthorityImpl(tm bool) ValidationAuthorityImpl {
logger := blog.GetAuditLogger()
logger.Notice("Validation Authority Starting")
return ValidationAuthorityImpl{log: logger, TestMode: tm}
}
// Used for audit logging
type verificationRequestEvent struct {
ID string `json:",omitempty"`
Requester int64 `json:",omitempty"`
Challenge core.Challenge `json:",omitempty"`
RequestTime time.Time `json:",omitempty"`
ResponseTime time.Time `json:",omitempty"`
Error string `json:",omitempty"`
}
// Validation methods
// setChallengeErrorFromDNSError checks the error returned from Lookup...
// methods and tests if the error was an underlying net.OpError or an error
// caused by resolver returning SERVFAIL or other invalid Rcodes and sets
// the challenge.Error field accordingly.
func setChallengeErrorFromDNSError(err error, challenge *core.Challenge) {
challenge.Error = &core.ProblemDetails{Type: core.ConnectionProblem}
if netErr, ok := err.(*net.OpError); ok {
if netErr.Timeout() {
challenge.Error.Detail = "DNS query timed out"
} else if netErr.Temporary() {
challenge.Error.Detail = "Temporary network connectivity error"
}
} else {
challenge.Error.Detail = "Server failure at resolver"
}
}
func (va ValidationAuthorityImpl) validateSimpleHTTP(identifier core.AcmeIdentifier, input core.Challenge) (core.Challenge, error) {
challenge := input
if len(challenge.Path) == 0 {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: core.MalformedProblem,
Detail: "No path provided for SimpleHTTP challenge.",
}
va.log.Debug(fmt.Sprintf("SimpleHTTP [%s] path empty: %v", identifier, challenge))
return challenge, challenge.Error
}
if identifier.Type != core.IdentifierDNS {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: core.MalformedProblem,
Detail: "Identifier type for SimpleHTTP was not DNS",
}
va.log.Debug(fmt.Sprintf("SimpleHTTP [%s] Identifier failure", identifier))
return challenge, challenge.Error
}
hostName := identifier.Value
var scheme string
if input.TLS == nil || (input.TLS != nil && *input.TLS) {
scheme = "https"
} else {
scheme = "http"
}
if va.TestMode {
hostName = "localhost:5001"
}
url := fmt.Sprintf("%s://%s/.well-known/acme-challenge/%s", scheme, hostName, challenge.Path)
// AUDIT[ Certificate Requests ] 11917fa4-10ef-4e0d-9105-bacbe7836a3c
va.log.Audit(fmt.Sprintf("Attempting to validate Simple%s for %s", strings.ToUpper(scheme), url))
httpRequest, err := http.NewRequest("GET", url, nil)
if err != nil {
challenge.Error = &core.ProblemDetails{
Type: core.MalformedProblem,
Detail: "URL provided for SimpleHTTP was invalid",
}
va.log.Debug(fmt.Sprintf("SimpleHTTP [%s] HTTP failure: %s", identifier, err))
challenge.Status = core.StatusInvalid
return challenge, err
}
if va.UserAgent != "" {
httpRequest.Header["User-Agent"] = []string{va.UserAgent}
}
httpRequest.Host = hostName
tr := &http.Transport{
// We are talking to a client that does not yet have a certificate,
// so we accept a temporary, invalid one.
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
// We don't expect to make multiple requests to a client, so close
// connection immediately.
DisableKeepAlives: true,
}
logRedirect := func(req *http.Request, via []*http.Request) error {
va.log.Info(fmt.Sprintf("validateSimpleHTTP [%s] redirect from %q to %q", identifier, via[len(via)-1].URL.String(), req.URL.String()))
return nil
}
client := http.Client{
Transport: tr,
CheckRedirect: logRedirect,
Timeout: 5 * time.Second,
}
httpResponse, err := client.Do(httpRequest)
if err == nil && httpResponse.StatusCode == 200 {
// Read body & test
body, readErr := ioutil.ReadAll(httpResponse.Body)
if readErr != nil {
challenge.Error = &core.ProblemDetails{
Type: core.ServerInternalProblem,
}
va.log.Debug(fmt.Sprintf("SimpleHTTP [%s] Read failure: %s", identifier, readErr))
challenge.Status = core.StatusInvalid
return challenge, readErr
}
if subtle.ConstantTimeCompare(body, []byte(challenge.Token)) == 1 {
challenge.Status = core.StatusValid
} else {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: core.UnauthorizedProblem,
Detail: fmt.Sprintf("Incorrect token validating Simple%s for %s",
strings.ToUpper(scheme), url),
}
err = challenge.Error
}
} else if err != nil {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: parseHTTPConnError(err),
Detail: fmt.Sprintf("Could not connect to %s", url),
}
va.log.Debug(strings.Join([]string{challenge.Error.Error(), err.Error()}, ": "))
} else {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: core.UnauthorizedProblem,
Detail: fmt.Sprintf("Invalid response from %s: %d",
url, httpResponse.StatusCode),
}
err = challenge.Error
}
return challenge, err
}
func (va ValidationAuthorityImpl) validateDvsni(identifier core.AcmeIdentifier, input core.Challenge) (core.Challenge, error) {
challenge := input
if identifier.Type != "dns" {
challenge.Error = &core.ProblemDetails{
Type: core.MalformedProblem,
Detail: "Identifier type for DVSNI was not DNS",
}
challenge.Status = core.StatusInvalid
va.log.Debug(fmt.Sprintf("DVSNI [%s] Identifier failure", identifier))
return challenge, challenge.Error
}
const DVSNIsuffix = ".acme.invalid"
nonceName := challenge.Nonce + DVSNIsuffix
R, err := core.B64dec(challenge.R)
if err != nil {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: core.MalformedProblem,
Detail: "Failed to decode R value from DVSNI challenge",
}
va.log.Debug(fmt.Sprintf("DVSNI [%s] R Decode failure: %s", identifier, err))
return challenge, err
}
S, err := core.B64dec(challenge.S)
if err != nil {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: core.MalformedProblem,
Detail: "Failed to decode S value from DVSNI challenge",
}
va.log.Debug(fmt.Sprintf("DVSNI [%s] S Decode failure: %s", identifier, err))
return challenge, err
}
RS := append(R, S...)
z := sha256.Sum256(RS)
zName := fmt.Sprintf("%064x.acme.invalid", z)
// Make a connection with SNI = nonceName
hostPort := identifier.Value + ":443"
if va.TestMode {
hostPort = "localhost:5001"
}
va.log.Notice(fmt.Sprintf("DVSNI [%s] Attempting to validate DVSNI for %s %s",
identifier, hostPort, zName))
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", hostPort, &tls.Config{
ServerName: nonceName,
InsecureSkipVerify: true,
})
if err != nil {
challenge.Status = core.StatusInvalid
challenge.Error = &core.ProblemDetails{
Type: parseHTTPConnError(err),
Detail: "Failed to connect to host for DVSNI challenge",
}
va.log.Debug(fmt.Sprintf("DVSNI [%s] TLS Connection failure: %s", identifier, err))
return challenge, err
}
defer conn.Close()
// Check that zName is a dNSName SAN in the server's certificate
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
challenge.Error = &core.ProblemDetails{
Type: core.UnauthorizedProblem,
Detail: "No certs presented for DVSNI challenge",
}
challenge.Status = core.StatusInvalid
return challenge, challenge.Error
}
for _, name := range certs[0].DNSNames {
if subtle.ConstantTimeCompare([]byte(name), []byte(zName)) == 1 {
challenge.Status = core.StatusValid
return challenge, nil
}
}
challenge.Error = &core.ProblemDetails{
Type: core.UnauthorizedProblem,
Detail: "Correct zName not found for DVSNI challenge",
}
challenge.Status = core.StatusInvalid
return challenge, challenge.Error
}
// parseHTTPConnError returns the ACME ProblemType corresponding to an error
// that occurred during domain validation.
func parseHTTPConnError(err error) core.ProblemType {
if urlErr, ok := err.(*url.Error); ok {
err = urlErr.Err
}
// XXX: On all of the resolvers I tested that validate DNSSEC, there is
// no differentation between a DNSSEC failure and an unknown host. If we
// do not verify DNSSEC ourselves, this function should be modified.
if netErr, ok := err.(*net.OpError); ok {
dnsErr, ok := netErr.Err.(*net.DNSError)
if ok && !dnsErr.Timeout() && !dnsErr.Temporary() {
return core.UnknownHostProblem
} else if fmt.Sprintf("%T", netErr.Err) == "tls.alert" {
return core.TLSProblem
}
}
return core.ConnectionProblem
}
func (va ValidationAuthorityImpl) validateDNS(identifier core.AcmeIdentifier, input core.Challenge) (core.Challenge, error) {
challenge := input
if identifier.Type != core.IdentifierDNS {
challenge.Error = &core.ProblemDetails{
Type: core.MalformedProblem,
Detail: "Identifier type for DNS was not itself DNS",
}
va.log.Debug(fmt.Sprintf("DNS [%s] Identifier failure", identifier))
challenge.Status = core.StatusInvalid
return challenge, challenge.Error
}
const DNSPrefix = "_acme-challenge"
challengeSubdomain := fmt.Sprintf("%s.%s", DNSPrefix, identifier.Value)
txts, _, err := va.DNSResolver.LookupTXT(challengeSubdomain)
if err != nil {
challenge.Status = core.StatusInvalid
setChallengeErrorFromDNSError(err, &challenge)
va.log.Debug(fmt.Sprintf("%s [%s] DNS failure: %s", challenge.Type, identifier, err))
return challenge, challenge.Error
}
byteToken := []byte(challenge.Token)
for _, element := range txts {
if subtle.ConstantTimeCompare([]byte(element), byteToken) == 1 {
challenge.Status = core.StatusValid
return challenge, nil
}
}
challenge.Error = &core.ProblemDetails{
Type: core.UnauthorizedProblem,
Detail: "Correct value not found for DNS challenge",
}
challenge.Status = core.StatusInvalid
return challenge, challenge.Error
}
// Overall validation process
func (va ValidationAuthorityImpl) validate(authz core.Authorization, challengeIndex int) {
// Select the first supported validation method
// XXX: Remove the "break" lines to process all supported validations
logEvent := verificationRequestEvent{
ID: authz.ID,
Requester: authz.RegistrationID,
RequestTime: time.Now(),
}
if !authz.Challenges[challengeIndex].IsSane(true) {
chall := &authz.Challenges[challengeIndex]
chall.Status = core.StatusInvalid
chall.Error = &core.ProblemDetails{Type: core.MalformedProblem,
Detail: fmt.Sprintf("Challenge failed sanity check.")}
logEvent.Challenge = *chall
logEvent.Error = chall.Error.Detail
} else {
var err error
switch authz.Challenges[challengeIndex].Type {
case core.ChallengeTypeSimpleHTTP:
authz.Challenges[challengeIndex], err = va.validateSimpleHTTP(authz.Identifier, authz.Challenges[challengeIndex])
break
case core.ChallengeTypeDVSNI:
authz.Challenges[challengeIndex], err = va.validateDvsni(authz.Identifier, authz.Challenges[challengeIndex])
break
case core.ChallengeTypeDNS:
authz.Challenges[challengeIndex], err = va.validateDNS(authz.Identifier, authz.Challenges[challengeIndex])
break
}
logEvent.Challenge = authz.Challenges[challengeIndex]
if err != nil {
logEvent.Error = err.Error()
}
}
// AUDIT[ Certificate Requests ] 11917fa4-10ef-4e0d-9105-bacbe7836a3c
va.log.AuditObject("Validation result", logEvent)
va.log.Notice(fmt.Sprintf("Validations: %+v", authz))
va.RA.OnValidationUpdate(authz)
}
// UpdateValidations runs the validate() method asynchronously using goroutines.
func (va ValidationAuthorityImpl) UpdateValidations(authz core.Authorization, challengeIndex int) error {
go va.validate(authz, challengeIndex)
return nil
}
// CAASet consists of filtered CAA records
type CAASet struct {
Issue []*dns.CAA
Issuewild []*dns.CAA
Iodef []*dns.CAA
Unknown []*dns.CAA
}
// returns true if any CAA records have unknown tag properties and are flagged critical.
func (caaSet CAASet) criticalUnknown() bool {
if len(caaSet.Unknown) > 0 {
for _, caaRecord := range caaSet.Unknown {
// Critical flag is 1, but according to RFC 6844 any flag other than
// 0 should currently be interpreted as critical.
if caaRecord.Flag > 0 {
return true
}
}
}
return false
}
// Filter CAA records by property
func newCAASet(CAAs []*dns.CAA) *CAASet {
var filtered CAASet
for _, caaRecord := range CAAs {
switch caaRecord.Tag {
case "issue":
filtered.Issue = append(filtered.Issue, caaRecord)
case "issuewild":
filtered.Issuewild = append(filtered.Issuewild, caaRecord)
case "iodef":
filtered.Iodef = append(filtered.Iodef, caaRecord)
default:
filtered.Unknown = append(filtered.Unknown, caaRecord)
}
}
return &filtered
}
func (va *ValidationAuthorityImpl) getCAASet(hostname string) (*CAASet, error) {
hostname = strings.TrimRight(hostname, ".")
splitDomain := strings.Split(hostname, ".")
// RFC 6844 CAA set query sequence, 'x.y.z.com' => ['x.y.z.com', 'y.z.com', 'z.com']
for i := range splitDomain {
queryDomain := strings.Join(splitDomain[i:], ".")
// Don't query a public suffix
if _, present := policy.PublicSuffixList[queryDomain]; present {
break
}
// Query CAA records for domain and its alias if it has a CNAME
for _, alias := range []bool{false, true} {
if alias {
target, _, err := va.DNSResolver.LookupCNAME(queryDomain)
if err != nil {
return nil, err
}
queryDomain = target
}
CAAs, _, err := va.DNSResolver.LookupCAA(queryDomain)
if err != nil {
return nil, err
}
if len(CAAs) > 0 {
return newCAASet(CAAs), nil
}
}
}
// no CAA records found
return nil, nil
}
// CheckCAARecords verifies that, if the indicated subscriber domain has any CAA
// records, they authorize the configured CA domain to issue a certificate
func (va *ValidationAuthorityImpl) CheckCAARecords(identifier core.AcmeIdentifier) (present, valid bool, err error) {
hostname := strings.ToLower(identifier.Value)
caaSet, err := va.getCAASet(hostname)
if err != nil {
return
}
if caaSet == nil {
// No CAA records found, can issue
present = false
valid = true
return
} else if caaSet.criticalUnknown() {
present = true
valid = false
return
} else if len(caaSet.Issue) > 0 || len(caaSet.Issuewild) > 0 {
present = true
var checkSet []*dns.CAA
if strings.SplitN(hostname, ".", 2)[0] == "*" {
checkSet = caaSet.Issuewild
} else {
checkSet = caaSet.Issue
}
for _, caa := range checkSet {
if caa.Value == va.IssuerDomain {
valid = true
return
} else if caa.Flag > 0 {
valid = false
return
}
}
valid = false
return
}
return
}