770 lines
27 KiB
Go
770 lines
27 KiB
Go
package va
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jmhodges/clock"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"github.com/letsencrypt/boulder/bdns"
|
|
"github.com/letsencrypt/boulder/core"
|
|
corepb "github.com/letsencrypt/boulder/core/proto"
|
|
berrors "github.com/letsencrypt/boulder/errors"
|
|
"github.com/letsencrypt/boulder/features"
|
|
bgrpc "github.com/letsencrypt/boulder/grpc"
|
|
"github.com/letsencrypt/boulder/identifier"
|
|
blog "github.com/letsencrypt/boulder/log"
|
|
"github.com/letsencrypt/boulder/metrics"
|
|
"github.com/letsencrypt/boulder/probs"
|
|
vapb "github.com/letsencrypt/boulder/va/proto"
|
|
)
|
|
|
|
const (
|
|
PrimaryPerspective = "Primary"
|
|
allPerspectives = "all"
|
|
|
|
opDCVAndCAA = "dcv+caa"
|
|
opDCV = "dcv"
|
|
opCAA = "caa"
|
|
|
|
pass = "pass"
|
|
fail = "fail"
|
|
)
|
|
|
|
var (
|
|
// badTLSHeader contains the string 'HTTP /' which is returned when
|
|
// we try to talk TLS to a server that only talks HTTP
|
|
badTLSHeader = []byte{0x48, 0x54, 0x54, 0x50, 0x2f}
|
|
// h2SettingsFrameErrRegex is a regex against a net/http error indicating
|
|
// a malformed HTTP response that matches the initial SETTINGS frame of an
|
|
// HTTP/2 connection. This happens when a server configures HTTP/2 on port
|
|
// :80, failing HTTP-01 challenges.
|
|
//
|
|
// The regex first matches the error string prefix and then matches the raw
|
|
// bytes of an arbitrarily sized HTTP/2 SETTINGS frame:
|
|
// 0x00 0x00 0x?? 0x04 0x00 0x00 0x00 0x00
|
|
//
|
|
// The third byte is variable and indicates the frame size. Typically
|
|
// this will be 0x12.
|
|
// The 0x04 in the fourth byte indicates that the frame is SETTINGS type.
|
|
//
|
|
// See:
|
|
// * https://tools.ietf.org/html/rfc7540#section-4.1
|
|
// * https://tools.ietf.org/html/rfc7540#section-6.5
|
|
//
|
|
// NOTE(@cpu): Using a regex is a hack but unfortunately for this case
|
|
// http.Client.Do() will return a url.Error err that wraps
|
|
// a errors.ErrorString instance. There isn't much else to do with one of
|
|
// those except match the encoded byte string with a regex. :-X
|
|
//
|
|
// NOTE(@cpu): The first component of this regex is optional to avoid an
|
|
// integration test flake. In some (fairly rare) conditions the malformed
|
|
// response error will be returned simply as a http.badStringError without
|
|
// the broken transport prefix. Most of the time the error is returned with
|
|
// a transport connection error prefix.
|
|
h2SettingsFrameErrRegex = regexp.MustCompile(`(?:net\/http\: HTTP\/1\.x transport connection broken: )?malformed HTTP response \"\\x00\\x00\\x[a-f0-9]{2}\\x04\\x00\\x00\\x00\\x00\\x00.*"`)
|
|
)
|
|
|
|
// RemoteClients wraps the vapb.VAClient and vapb.CAAClient interfaces to aid in
|
|
// mocking remote VAs for testing.
|
|
type RemoteClients struct {
|
|
vapb.VAClient
|
|
vapb.CAAClient
|
|
}
|
|
|
|
// RemoteVA embeds RemoteClients and adds a field containing the address of the
|
|
// remote gRPC server since the underlying gRPC client doesn't provide a way to
|
|
// extract this metadata which is useful for debugging gRPC connection issues.
|
|
type RemoteVA struct {
|
|
RemoteClients
|
|
Address string
|
|
Perspective string
|
|
RIR string
|
|
}
|
|
|
|
type vaMetrics struct {
|
|
// validationLatency is a histogram of the latency to perform validations
|
|
// from the primary and remote VA perspectives. It's labelled by:
|
|
// - operation: VA.DoDCV or VA.DoCAA as [dcv|caa|dcv+caa]
|
|
// - perspective: ValidationAuthorityImpl.perspective
|
|
// - challenge_type: core.Challenge.Type
|
|
// - problem_type: probs.ProblemType
|
|
// - result: the result of the validation as [pass|fail]
|
|
validationLatency *prometheus.HistogramVec
|
|
prospectiveRemoteCAACheckFailures prometheus.Counter
|
|
tlsALPNOIDCounter *prometheus.CounterVec
|
|
http01Fallbacks prometheus.Counter
|
|
http01Redirects prometheus.Counter
|
|
caaCounter *prometheus.CounterVec
|
|
ipv4FallbackCounter prometheus.Counter
|
|
}
|
|
|
|
func initMetrics(stats prometheus.Registerer) *vaMetrics {
|
|
validationLatency := prometheus.NewHistogramVec(
|
|
prometheus.HistogramOpts{
|
|
Name: "validation_latency",
|
|
Help: "Histogram of the latency to perform validations from the primary and remote VA perspectives",
|
|
Buckets: metrics.InternetFacingBuckets,
|
|
},
|
|
[]string{"operation", "perspective", "challenge_type", "problem_type", "result"},
|
|
)
|
|
stats.MustRegister(validationLatency)
|
|
prospectiveRemoteCAACheckFailures := prometheus.NewCounter(
|
|
prometheus.CounterOpts{
|
|
Name: "prospective_remote_caa_check_failures",
|
|
Help: "Number of CAA rechecks that would have failed due to remote VAs returning failure if consesus were enforced",
|
|
})
|
|
stats.MustRegister(prospectiveRemoteCAACheckFailures)
|
|
tlsALPNOIDCounter := prometheus.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Name: "tls_alpn_oid_usage",
|
|
Help: "Number of TLS ALPN validations using either of the two OIDs",
|
|
},
|
|
[]string{"oid"},
|
|
)
|
|
stats.MustRegister(tlsALPNOIDCounter)
|
|
http01Fallbacks := prometheus.NewCounter(
|
|
prometheus.CounterOpts{
|
|
Name: "http01_fallbacks",
|
|
Help: "Number of IPv6 to IPv4 HTTP-01 fallback requests made",
|
|
})
|
|
stats.MustRegister(http01Fallbacks)
|
|
http01Redirects := prometheus.NewCounter(
|
|
prometheus.CounterOpts{
|
|
Name: "http01_redirects",
|
|
Help: "Number of HTTP-01 redirects followed",
|
|
})
|
|
stats.MustRegister(http01Redirects)
|
|
caaCounter := prometheus.NewCounterVec(prometheus.CounterOpts{
|
|
Name: "caa_sets_processed",
|
|
Help: "A counter of CAA sets processed labelled by result",
|
|
}, []string{"result"})
|
|
stats.MustRegister(caaCounter)
|
|
ipv4FallbackCounter := prometheus.NewCounter(prometheus.CounterOpts{
|
|
Name: "tls_alpn_ipv4_fallback",
|
|
Help: "A counter of IPv4 fallbacks during TLS ALPN validation",
|
|
})
|
|
stats.MustRegister(ipv4FallbackCounter)
|
|
|
|
return &vaMetrics{
|
|
validationLatency: validationLatency,
|
|
prospectiveRemoteCAACheckFailures: prospectiveRemoteCAACheckFailures,
|
|
tlsALPNOIDCounter: tlsALPNOIDCounter,
|
|
http01Fallbacks: http01Fallbacks,
|
|
http01Redirects: http01Redirects,
|
|
caaCounter: caaCounter,
|
|
ipv4FallbackCounter: ipv4FallbackCounter,
|
|
}
|
|
}
|
|
|
|
// PortConfig specifies what ports the VA should call to on the remote
|
|
// host when performing its checks.
|
|
type portConfig struct {
|
|
HTTPPort int
|
|
HTTPSPort int
|
|
TLSPort int
|
|
}
|
|
|
|
// newDefaultPortConfig is a constructor which returns a portConfig with default
|
|
// settings.
|
|
//
|
|
// CABF BRs section 1.6.1: Authorized Ports: One of the following ports: 80
|
|
// (http), 443 (https), 25 (smtp), 22 (ssh).
|
|
//
|
|
// RFC 8555 section 8.3: Dereference the URL using an HTTP GET request. This
|
|
// request MUST be sent to TCP port 80 on the HTTP server.
|
|
//
|
|
// RFC 8737 section 3: The ACME server initiates a TLS connection to the chosen
|
|
// IP address. This connection MUST use TCP port 443.
|
|
func newDefaultPortConfig() *portConfig {
|
|
return &portConfig{
|
|
HTTPPort: 80,
|
|
HTTPSPort: 443,
|
|
TLSPort: 443,
|
|
}
|
|
}
|
|
|
|
// ValidationAuthorityImpl represents a VA
|
|
type ValidationAuthorityImpl struct {
|
|
vapb.UnsafeVAServer
|
|
vapb.UnsafeCAAServer
|
|
log blog.Logger
|
|
dnsClient bdns.Client
|
|
issuerDomain string
|
|
httpPort int
|
|
httpsPort int
|
|
tlsPort int
|
|
userAgent string
|
|
clk clock.Clock
|
|
remoteVAs []RemoteVA
|
|
maxRemoteFailures int
|
|
accountURIPrefixes []string
|
|
singleDialTimeout time.Duration
|
|
perspective string
|
|
rir string
|
|
isReservedIPFunc func(ip net.IP) bool
|
|
|
|
metrics *vaMetrics
|
|
}
|
|
|
|
var _ vapb.VAServer = (*ValidationAuthorityImpl)(nil)
|
|
var _ vapb.CAAServer = (*ValidationAuthorityImpl)(nil)
|
|
|
|
// NewValidationAuthorityImpl constructs a new VA
|
|
func NewValidationAuthorityImpl(
|
|
resolver bdns.Client,
|
|
remoteVAs []RemoteVA,
|
|
userAgent string,
|
|
issuerDomain string,
|
|
stats prometheus.Registerer,
|
|
clk clock.Clock,
|
|
logger blog.Logger,
|
|
accountURIPrefixes []string,
|
|
perspective string,
|
|
rir string,
|
|
reservedIPChecker func(ip net.IP) bool,
|
|
) (*ValidationAuthorityImpl, error) {
|
|
|
|
if len(accountURIPrefixes) == 0 {
|
|
return nil, errors.New("no account URI prefixes configured")
|
|
}
|
|
|
|
for i, va1 := range remoteVAs {
|
|
for j, va2 := range remoteVAs {
|
|
if i != j && va1.Perspective == va2.Perspective {
|
|
return nil, fmt.Errorf("duplicate remote VA perspective %q", va1.Perspective)
|
|
}
|
|
}
|
|
}
|
|
|
|
pc := newDefaultPortConfig()
|
|
|
|
va := &ValidationAuthorityImpl{
|
|
log: logger,
|
|
dnsClient: resolver,
|
|
issuerDomain: issuerDomain,
|
|
httpPort: pc.HTTPPort,
|
|
httpsPort: pc.HTTPSPort,
|
|
tlsPort: pc.TLSPort,
|
|
userAgent: userAgent,
|
|
clk: clk,
|
|
metrics: initMetrics(stats),
|
|
remoteVAs: remoteVAs,
|
|
maxRemoteFailures: maxAllowedFailures(len(remoteVAs)),
|
|
accountURIPrefixes: accountURIPrefixes,
|
|
// singleDialTimeout specifies how long an individual `DialContext` operation may take
|
|
// before timing out. This timeout ignores the base RPC timeout and is strictly
|
|
// used for the DialContext operations that take place during an
|
|
// HTTP-01 challenge validation.
|
|
singleDialTimeout: 10 * time.Second,
|
|
perspective: perspective,
|
|
rir: rir,
|
|
isReservedIPFunc: reservedIPChecker,
|
|
}
|
|
|
|
return va, nil
|
|
}
|
|
|
|
// maxAllowedFailures returns the maximum number of allowed failures
|
|
// for a given number of remote perspectives, according to the "Quorum
|
|
// Requirements" table in BRs Section 3.2.2.9, as follows:
|
|
//
|
|
// | # of Distinct Remote Network Perspectives Used | # of Allowed non-Corroborations |
|
|
// | --- | --- |
|
|
// | 2-5 | 1 |
|
|
// | 6+ | 2 |
|
|
func maxAllowedFailures(perspectiveCount int) int {
|
|
if perspectiveCount < 2 {
|
|
return 0
|
|
}
|
|
if perspectiveCount < 6 {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// ipError is an error type used to pass though the IP address of the remote
|
|
// host when an error occurs during HTTP-01 and TLS-ALPN domain validation.
|
|
type ipError struct {
|
|
ip net.IP
|
|
err error
|
|
}
|
|
|
|
// newIPError wraps an error and the IP of the remote host in an ipError so we
|
|
// can display the IP in the problem details returned to the client.
|
|
func newIPError(ip net.IP, err error) error {
|
|
return ipError{ip: ip, err: err}
|
|
}
|
|
|
|
// Unwrap returns the underlying error.
|
|
func (i ipError) Unwrap() error {
|
|
return i.err
|
|
}
|
|
|
|
// Error returns a string representation of the error.
|
|
func (i ipError) Error() string {
|
|
return fmt.Sprintf("%s: %s", i.ip, i.err)
|
|
}
|
|
|
|
// detailedError returns a ProblemDetails corresponding to an error
|
|
// that occurred during HTTP-01 or TLS-ALPN domain validation. Specifically it
|
|
// tries to unwrap known Go error types and present something a little more
|
|
// meaningful. It additionally handles `berrors.ConnectionFailure` errors by
|
|
// passing through the detailed message.
|
|
func detailedError(err error) *probs.ProblemDetails {
|
|
var ipErr ipError
|
|
if errors.As(err, &ipErr) {
|
|
detailedErr := detailedError(ipErr.err)
|
|
if ipErr.ip == nil {
|
|
// This should never happen.
|
|
return detailedErr
|
|
}
|
|
// Prefix the error message with the IP address of the remote host.
|
|
detailedErr.Detail = fmt.Sprintf("%s: %s", ipErr.ip, detailedErr.Detail)
|
|
return detailedErr
|
|
}
|
|
// net/http wraps net.OpError in a url.Error. Unwrap them.
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) {
|
|
prob := detailedError(urlErr.Err)
|
|
prob.Detail = fmt.Sprintf("Fetching %s: %s", urlErr.URL, prob.Detail)
|
|
return prob
|
|
}
|
|
|
|
var tlsErr tls.RecordHeaderError
|
|
if errors.As(err, &tlsErr) && bytes.Equal(tlsErr.RecordHeader[:], badTLSHeader) {
|
|
return probs.Malformed("Server only speaks HTTP, not TLS")
|
|
}
|
|
|
|
var netOpErr *net.OpError
|
|
if errors.As(err, &netOpErr) {
|
|
if fmt.Sprintf("%T", netOpErr.Err) == "tls.alert" {
|
|
// All the tls.alert error strings are reasonable to hand back to a
|
|
// user. Confirmed against Go 1.8.
|
|
return probs.TLS(netOpErr.Error())
|
|
} else if netOpErr.Timeout() && netOpErr.Op == "dial" {
|
|
return probs.Connection("Timeout during connect (likely firewall problem)")
|
|
} else if netOpErr.Timeout() {
|
|
return probs.Connection(fmt.Sprintf("Timeout during %s (your server may be slow or overloaded)", netOpErr.Op))
|
|
}
|
|
}
|
|
var syscallErr *os.SyscallError
|
|
if errors.As(err, &syscallErr) {
|
|
switch syscallErr.Err {
|
|
case syscall.ECONNREFUSED:
|
|
return probs.Connection("Connection refused")
|
|
case syscall.ENETUNREACH:
|
|
return probs.Connection("Network unreachable")
|
|
case syscall.ECONNRESET:
|
|
return probs.Connection("Connection reset by peer")
|
|
}
|
|
}
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
return probs.Connection("Timeout after connect (your server may be slow or overloaded)")
|
|
}
|
|
if errors.Is(err, berrors.ConnectionFailure) {
|
|
return probs.Connection(err.Error())
|
|
}
|
|
if errors.Is(err, berrors.Unauthorized) {
|
|
return probs.Unauthorized(err.Error())
|
|
}
|
|
if errors.Is(err, berrors.DNS) {
|
|
return probs.DNS(err.Error())
|
|
}
|
|
if errors.Is(err, berrors.Malformed) {
|
|
return probs.Malformed(err.Error())
|
|
}
|
|
if errors.Is(err, berrors.CAA) {
|
|
return probs.CAA(err.Error())
|
|
}
|
|
|
|
if h2SettingsFrameErrRegex.MatchString(err.Error()) {
|
|
return probs.Connection("Server is speaking HTTP/2 over HTTP")
|
|
}
|
|
return probs.Connection("Error getting validation data")
|
|
}
|
|
|
|
// isPrimaryVA returns true if the VA is the primary validation perspective.
|
|
func (va *ValidationAuthorityImpl) isPrimaryVA() bool {
|
|
return va.perspective == PrimaryPerspective
|
|
}
|
|
|
|
// validateChallenge simply passes through to the appropriate validation method
|
|
// depending on the challenge type.
|
|
func (va *ValidationAuthorityImpl) validateChallenge(
|
|
ctx context.Context,
|
|
ident identifier.ACMEIdentifier,
|
|
kind core.AcmeChallenge,
|
|
token string,
|
|
keyAuthorization string,
|
|
) ([]core.ValidationRecord, error) {
|
|
switch kind {
|
|
case core.ChallengeTypeHTTP01:
|
|
return va.validateHTTP01(ctx, ident, token, keyAuthorization)
|
|
case core.ChallengeTypeDNS01:
|
|
// Strip a (potential) leading wildcard token from the identifier.
|
|
ident.Value = strings.TrimPrefix(ident.Value, "*.")
|
|
return va.validateDNS01(ctx, ident, keyAuthorization)
|
|
case core.ChallengeTypeTLSALPN01:
|
|
return va.validateTLSALPN01(ctx, ident, keyAuthorization)
|
|
}
|
|
return nil, berrors.MalformedError("invalid challenge type %s", kind)
|
|
}
|
|
|
|
// observeLatency records entries in the validationLatency histogram of the
|
|
// latency to perform validations from the primary and remote VA perspectives.
|
|
// The labels are:
|
|
// - operation: VA.DoDCV or VA.DoCAA as [dcv|caa]
|
|
// - perspective: [ValidationAuthorityImpl.perspective|all]
|
|
// - challenge_type: core.Challenge.Type
|
|
// - problem_type: probs.ProblemType
|
|
// - result: the result of the validation as [pass|fail]
|
|
func (va *ValidationAuthorityImpl) observeLatency(op, perspective, challType, probType, result string, latency time.Duration) {
|
|
labels := prometheus.Labels{
|
|
"operation": op,
|
|
"perspective": perspective,
|
|
"challenge_type": challType,
|
|
"problem_type": probType,
|
|
"result": result,
|
|
}
|
|
va.metrics.validationLatency.With(labels).Observe(latency.Seconds())
|
|
}
|
|
|
|
// remoteOperation is a func type that encapsulates the operation and request
|
|
// passed to va.performRemoteOperation. The operation must be a method on
|
|
// vapb.VAClient or vapb.CAAClient, and the request must be the corresponding
|
|
// proto.Message passed to that method.
|
|
type remoteOperation = func(context.Context, RemoteVA, proto.Message) (remoteResult, error)
|
|
|
|
// remoteResult is an interface that must be implemented by the results of a
|
|
// remoteOperation, such as *vapb.ValidationResult and *vapb.IsCAAValidResponse.
|
|
// It provides methods to access problem details, the associated perspective,
|
|
// and the RIR.
|
|
type remoteResult interface {
|
|
proto.Message
|
|
GetProblem() *corepb.ProblemDetails
|
|
GetPerspective() string
|
|
GetRir() string
|
|
}
|
|
|
|
const (
|
|
// requiredRIRs is the minimum number of distinct Regional Internet
|
|
// Registries required for MPIC-compliant validation. Per BRs Section
|
|
// 3.2.2.9, starting March 15, 2026, the required number is 2.
|
|
requiredRIRs = 2
|
|
)
|
|
|
|
// mpicSummary is returned by doRemoteOperation and contains a summary of the
|
|
// validation results for logging purposes. To ensure that the JSON output does
|
|
// not contain nil slices, and to ensure deterministic output use the
|
|
// summarizeMPIC function to prepare an mpicSummary.
|
|
type mpicSummary struct {
|
|
// Passed are the perspectives that passed validation.
|
|
Passed []string `json:"passedPerspectives"`
|
|
|
|
// Failed are the perspectives that failed validation.
|
|
Failed []string `json:"failedPerspectives"`
|
|
|
|
// PassedRIRs are the Regional Internet Registries that the passing
|
|
// perspectives reside in.
|
|
PassedRIRs []string `json:"passedRIRs"`
|
|
|
|
// QuorumResult is the Multi-Perspective Issuance Corroboration quorum
|
|
// result, per BRs Section 5.4.1, Requirement 2.7 (i.e., "3/4" which should
|
|
// be interpreted as "Three (3) out of four (4) attempted Network
|
|
// Perspectives corroborated the determinations made by the Primary Network
|
|
// Perspective".
|
|
QuorumResult string `json:"quorumResult"`
|
|
}
|
|
|
|
// summarizeMPIC prepares an *mpicSummary for logging, ensuring there are no nil
|
|
// slices and output is deterministic.
|
|
func summarizeMPIC(passed, failed []string, passedRIRSet map[string]struct{}) *mpicSummary {
|
|
if passed == nil {
|
|
passed = []string{}
|
|
}
|
|
slices.Sort(passed)
|
|
if failed == nil {
|
|
failed = []string{}
|
|
}
|
|
slices.Sort(failed)
|
|
|
|
passedRIRs := []string{}
|
|
if passedRIRSet != nil {
|
|
for rir := range maps.Keys(passedRIRSet) {
|
|
passedRIRs = append(passedRIRs, rir)
|
|
}
|
|
}
|
|
slices.Sort(passedRIRs)
|
|
|
|
return &mpicSummary{
|
|
Passed: passed,
|
|
Failed: failed,
|
|
PassedRIRs: passedRIRs,
|
|
QuorumResult: fmt.Sprintf("%d/%d", len(passed), len(passed)+len(failed)),
|
|
}
|
|
}
|
|
|
|
// doRemoteOperation concurrently calls the provided operation with `req` and a
|
|
// RemoteVA once for each configured RemoteVA. It cancels remaining operations
|
|
// and returns early if either the required number of successful results is
|
|
// obtained or the number of failures exceeds va.maxRemoteFailures.
|
|
//
|
|
// Internal logic errors are logged. If the number of operation failures exceeds
|
|
// va.maxRemoteFailures, the first encountered problem is returned as a
|
|
// *probs.ProblemDetails.
|
|
func (va *ValidationAuthorityImpl) doRemoteOperation(ctx context.Context, op remoteOperation, req proto.Message) (*mpicSummary, *probs.ProblemDetails) {
|
|
remoteVACount := len(va.remoteVAs)
|
|
// - Mar 15, 2026: MUST implement using at least 3 perspectives
|
|
// - Jun 15, 2026: MUST implement using at least 4 perspectives
|
|
// - Dec 15, 2026: MUST implement using at least 5 perspectives
|
|
// See "Phased Implementation Timeline" in
|
|
// https://github.com/cabforum/servercert/blob/main/docs/BR.md#3229-multi-perspective-issuance-corroboration
|
|
if remoteVACount < 3 {
|
|
return nil, probs.ServerInternal("Insufficient remote perspectives: need at least 3")
|
|
}
|
|
|
|
type response struct {
|
|
addr string
|
|
perspective string
|
|
rir string
|
|
result remoteResult
|
|
err error
|
|
}
|
|
|
|
subCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
responses := make(chan *response, remoteVACount)
|
|
for _, i := range rand.Perm(remoteVACount) {
|
|
go func(rva RemoteVA) {
|
|
res, err := op(subCtx, rva, req)
|
|
if err != nil {
|
|
responses <- &response{rva.Address, rva.Perspective, rva.RIR, res, err}
|
|
return
|
|
}
|
|
if res.GetPerspective() != rva.Perspective || res.GetRir() != rva.RIR {
|
|
err = fmt.Errorf(
|
|
"Expected perspective %q (%q) but got reply from %q (%q) - misconfiguration likely", rva.Perspective, rva.RIR, res.GetPerspective(), res.GetRir(),
|
|
)
|
|
responses <- &response{rva.Address, rva.Perspective, rva.RIR, res, err}
|
|
return
|
|
}
|
|
responses <- &response{rva.Address, rva.Perspective, rva.RIR, res, err}
|
|
}(va.remoteVAs[i])
|
|
}
|
|
|
|
required := remoteVACount - va.maxRemoteFailures
|
|
var passed []string
|
|
var failed []string
|
|
var passedRIRs = map[string]struct{}{}
|
|
var firstProb *probs.ProblemDetails
|
|
|
|
for resp := range responses {
|
|
var currProb *probs.ProblemDetails
|
|
|
|
if resp.err != nil {
|
|
// Failed to communicate with the remote VA.
|
|
failed = append(failed, resp.perspective)
|
|
|
|
if core.IsCanceled(resp.err) {
|
|
currProb = probs.ServerInternal("Secondary validation RPC canceled")
|
|
} else {
|
|
va.log.Errf("Operation on remote VA (%s) failed: %s", resp.addr, resp.err)
|
|
currProb = probs.ServerInternal("Secondary validation RPC failed")
|
|
}
|
|
} else if resp.result.GetProblem() != nil {
|
|
// The remote VA returned a problem.
|
|
failed = append(failed, resp.perspective)
|
|
|
|
var err error
|
|
currProb, err = bgrpc.PBToProblemDetails(resp.result.GetProblem())
|
|
if err != nil {
|
|
va.log.Errf("Operation on Remote VA (%s) returned malformed problem: %s", resp.addr, err)
|
|
currProb = probs.ServerInternal("Secondary validation RPC returned malformed result")
|
|
}
|
|
} else {
|
|
// The remote VA returned a successful result.
|
|
passed = append(passed, resp.perspective)
|
|
passedRIRs[resp.rir] = struct{}{}
|
|
}
|
|
|
|
if firstProb == nil && currProb != nil {
|
|
// A problem was encountered for the first time.
|
|
firstProb = currProb
|
|
}
|
|
|
|
if !features.Get().MPICFullResults {
|
|
// To respond faster, if we get enough successes or too many failures, we cancel remaining RPCs.
|
|
// Finish the loop to collect remaining responses into `failed` so we can rely on having a response
|
|
// for every request we made.
|
|
if len(passed) >= required && len(passedRIRs) >= requiredRIRs {
|
|
cancel()
|
|
}
|
|
if len(failed) > va.maxRemoteFailures {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// Once all the VAs have returned a result, break the loop.
|
|
if len(passed)+len(failed) >= remoteVACount {
|
|
break
|
|
}
|
|
}
|
|
if len(passed) >= required && len(passedRIRs) >= requiredRIRs {
|
|
return summarizeMPIC(passed, failed, passedRIRs), nil
|
|
}
|
|
if firstProb == nil {
|
|
// This should never happen. If we didn't meet the thresholds above we
|
|
// should have seen at least one error.
|
|
return summarizeMPIC(passed, failed, passedRIRs), probs.ServerInternal(
|
|
"During secondary validation: validation failed but the problem is unavailable")
|
|
}
|
|
firstProb.Detail = fmt.Sprintf("During secondary validation: %s", firstProb.Detail)
|
|
return summarizeMPIC(passed, failed, passedRIRs), firstProb
|
|
}
|
|
|
|
// validationLogEvent is a struct that contains the information needed to log
|
|
// the results of DoCAA and DoDCV.
|
|
type validationLogEvent struct {
|
|
AuthzID string
|
|
Requester int64
|
|
Identifier identifier.ACMEIdentifier
|
|
Challenge core.Challenge
|
|
Error string `json:",omitempty"`
|
|
InternalError string `json:",omitempty"`
|
|
Latency float64
|
|
Summary *mpicSummary `json:",omitempty"`
|
|
}
|
|
|
|
// DoDCV conducts a local Domain Control Validation (DCV) for the specified
|
|
// challenge. When invoked on the primary Validation Authority (VA) and the
|
|
// local validation succeeds, it also performs DCV validations using the
|
|
// configured remote VAs. Failed validations are indicated by a non-nil Problems
|
|
// in the returned ValidationResult. DoDCV returns error only for internal logic
|
|
// errors (and the client may receive errors from gRPC in the event of a
|
|
// communication problem). ValidationResult always includes a list of
|
|
// ValidationRecords, even when it also contains Problems. This method
|
|
// implements the DCV portion of Multi-Perspective Issuance Corroboration as
|
|
// defined in BRs Sections 3.2.2.9 and 5.4.1.
|
|
func (va *ValidationAuthorityImpl) DoDCV(ctx context.Context, req *vapb.PerformValidationRequest) (*vapb.ValidationResult, error) {
|
|
// TODO(#8023): Once DnsNames are no longer used in RPCs, use req.Identifier
|
|
// directly instead of setting ident.
|
|
if req.Identifier != nil && req.DnsName != "" {
|
|
return nil, errors.New("both Identifier and DNSName are set")
|
|
}
|
|
ident := identifier.FromProtoWithDefault(req)
|
|
|
|
if core.IsAnyNilOrZero(req, ident, req.Challenge, req.Authz, req.ExpectedKeyAuthorization) {
|
|
return nil, berrors.InternalServerError("Incomplete validation request")
|
|
}
|
|
|
|
chall, err := bgrpc.PBToChallenge(req.Challenge)
|
|
if err != nil {
|
|
return nil, errors.New("challenge failed to deserialize")
|
|
}
|
|
|
|
err = chall.CheckPending()
|
|
if err != nil {
|
|
return nil, berrors.MalformedError("challenge failed consistency check: %s", err)
|
|
}
|
|
|
|
// Initialize variables and a deferred function to handle validation latency
|
|
// metrics, log validation errors, and log an MPIC summary. Avoid using :=
|
|
// to redeclare `prob`, `localLatency`, or `summary` below this point.
|
|
var prob *probs.ProblemDetails
|
|
var summary *mpicSummary
|
|
var localLatency time.Duration
|
|
start := va.clk.Now()
|
|
logEvent := validationLogEvent{
|
|
AuthzID: req.Authz.Id,
|
|
Requester: req.Authz.RegID,
|
|
Identifier: ident,
|
|
Challenge: chall,
|
|
}
|
|
defer func() {
|
|
probType := ""
|
|
outcome := fail
|
|
if prob != nil {
|
|
probType = string(prob.Type)
|
|
logEvent.Error = prob.String()
|
|
logEvent.Challenge.Error = prob
|
|
logEvent.Challenge.Status = core.StatusInvalid
|
|
} else {
|
|
logEvent.Challenge.Status = core.StatusValid
|
|
outcome = pass
|
|
}
|
|
// Observe local validation latency (primary|remote).
|
|
va.observeLatency(opDCV, va.perspective, string(chall.Type), probType, outcome, localLatency)
|
|
if va.isPrimaryVA() {
|
|
// Observe total validation latency (primary+remote).
|
|
va.observeLatency(opDCV, allPerspectives, string(chall.Type), probType, outcome, va.clk.Since(start))
|
|
logEvent.Summary = summary
|
|
}
|
|
|
|
// Log the total validation latency.
|
|
logEvent.Latency = va.clk.Since(start).Round(time.Millisecond).Seconds()
|
|
va.log.AuditObject("Validation result", logEvent)
|
|
}()
|
|
|
|
// Do local validation. Note that we process the result in a couple ways
|
|
// *before* checking whether it returned an error. These few checks are
|
|
// carefully written to ensure that they work whether the local validation
|
|
// was successful or not, and cannot themselves fail.
|
|
records, err := va.validateChallenge(
|
|
ctx,
|
|
ident,
|
|
chall.Type,
|
|
chall.Token,
|
|
req.ExpectedKeyAuthorization,
|
|
)
|
|
|
|
// Stop the clock for local validation latency.
|
|
localLatency = va.clk.Since(start)
|
|
|
|
// Check for malformed ValidationRecords
|
|
logEvent.Challenge.ValidationRecord = records
|
|
if err == nil && !logEvent.Challenge.RecordsSane() {
|
|
err = errors.New("records from local validation failed sanity check")
|
|
}
|
|
|
|
if err != nil {
|
|
logEvent.InternalError = err.Error()
|
|
prob = detailedError(err)
|
|
return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob), va.perspective, va.rir)
|
|
}
|
|
|
|
if va.isPrimaryVA() {
|
|
// Do remote validation. We do this after local validation is complete
|
|
// to avoid wasting work when validation will fail anyway. This only
|
|
// returns a singular problem, because the remote VAs have already
|
|
// logged their own validationLogEvent, and it's not helpful to present
|
|
// multiple large errors to the end user.
|
|
op := func(ctx context.Context, remoteva RemoteVA, req proto.Message) (remoteResult, error) {
|
|
validationRequest, ok := req.(*vapb.PerformValidationRequest)
|
|
if !ok {
|
|
return nil, fmt.Errorf("got type %T, want *vapb.PerformValidationRequest", req)
|
|
}
|
|
return remoteva.DoDCV(ctx, validationRequest)
|
|
}
|
|
summary, prob = va.doRemoteOperation(ctx, op, req)
|
|
}
|
|
return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob), va.perspective, va.rir)
|
|
}
|