diff --git a/core/objects.go b/core/objects.go index 6732d623104..bf97c99a577 100644 --- a/core/objects.go +++ b/core/objects.go @@ -150,24 +150,6 @@ type ValidationRecord struct { // lookup for AddressUsed. During recursive A and AAAA lookups, a record may // instead look like A:host:port or AAAA:host:port ResolverAddrs []string `json:"resolverAddrs,omitempty"` - - // Perspective uniquely identifies the Network Perspective used to perform - // the validation, as specified in BRs Section 5.4.1, Requirement 2.7 - // ("Multi-Perspective Issuance Corroboration attempts from each Network - // Perspective"). It should uniquely identify either the Primary Perspective - // (VA) or a group of RVAs deployed in the same datacenter. - Perspective string `json:"perspective,omitempty"` - - // RIR indicates the Regional Internet Registry where this RVA is located. - // This field is used to identify the RIR region from which a given - // validation was performed, as specified in the "Phased Implementation - // Timeline" in BRs Section 3.2.2.9. It must be one of the following values: - // - ARIN - // - RIPE - // - APNIC - // - LACNIC - // - AfriNIC - RIR string `json:"rir,omitempty"` } // Challenge is an aggregate of all data needed for any challenges. diff --git a/go.mod b/go.mod index 839579d61fe..2795da5dedf 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/letsencrypt/boulder -go 1.22.0 +go 1.23.0 require ( github.com/aws/aws-sdk-go-v2 v1.32.2 diff --git a/grpc/pb-marshalling.go b/grpc/pb-marshalling.go index 417e5cc6191..c05ec9ecd2a 100644 --- a/grpc/pb-marshalling.go +++ b/grpc/pb-marshalling.go @@ -179,7 +179,7 @@ func PBToValidationRecord(in *corepb.ValidationRecord) (record core.ValidationRe }, nil } -func ValidationResultToPB(records []core.ValidationRecord, prob *probs.ProblemDetails) (*vapb.ValidationResult, error) { +func ValidationResultToPB(records []core.ValidationRecord, prob *probs.ProblemDetails, perspective, rir string) (*vapb.ValidationResult, error) { recordAry := make([]*corepb.ValidationRecord, len(records)) var err error for i, v := range records { @@ -193,8 +193,10 @@ func ValidationResultToPB(records []core.ValidationRecord, prob *probs.ProblemDe return nil, err } return &vapb.ValidationResult{ - Records: recordAry, - Problems: marshalledProbs, + Records: recordAry, + Problems: marshalledProbs, + Perspective: perspective, + Rir: rir, }, nil } diff --git a/grpc/pb-marshalling_test.go b/grpc/pb-marshalling_test.go index 332a3cb8317..1c3b4ee6dd3 100644 --- a/grpc/pb-marshalling_test.go +++ b/grpc/pb-marshalling_test.go @@ -154,7 +154,7 @@ func TestValidationResult(t *testing.T) { result := []core.ValidationRecord{vrA, vrB} prob := &probs.ProblemDetails{Type: probs.TLSProblem, Detail: "asd", HTTPStatus: 200} - pb, err := ValidationResultToPB(result, prob) + pb, err := ValidationResultToPB(result, prob, "", "") test.AssertNotError(t, err, "ValidationResultToPB failed") test.Assert(t, pb != nil, "Returned vapb.ValidationResult is nil") diff --git a/ra/ra.go b/ra/ra.go index 63ed2137616..55351f5bcb4 100644 --- a/ra/ra.go +++ b/ra/ra.go @@ -1748,7 +1748,7 @@ func (ra *RegistrationAuthorityImpl) recordValidation(ctx context.Context, authI } else { expires = ra.clk.Now().Add(ra.authorizationLifetime) } - vr, err := bgrpc.ValidationResultToPB(challenge.ValidationRecord, challenge.Error) + vr, err := bgrpc.ValidationResultToPB(challenge.ValidationRecord, challenge.Error, "", "") if err != nil { return err } diff --git a/ra/ra_test.go b/ra/ra_test.go index ca045cb1d00..e67eb772f85 100644 --- a/ra/ra_test.go +++ b/ra/ra_test.go @@ -156,6 +156,10 @@ func (dva *DummyValidationAuthority) PerformValidation(ctx context.Context, req return dva.PerformValidationRequestResultReturn, dva.PerformValidationRequestResultError } +func (dva *DummyValidationAuthority) ValidateChallenge(ctx context.Context, req *vapb.ValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + var ( // These values we simulate from the client AccountKeyJSONA = []byte(`{ diff --git a/va/caa_test.go b/va/caa_test.go index 477d3b84b3d..7c1ebd1487d 100644 --- a/va/caa_test.go +++ b/va/caa_test.go @@ -589,7 +589,7 @@ func (b caaBrokenDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, s } func TestDisabledMultiCAARechecking(t *testing.T) { - brokenRVA, _ := setupRemote(nil, "broken", caaBrokenDNS{}, "", "") + brokenRVA, _ := setupRemote(nil, "broken", caaBrokenDNS{}) remoteVAs := []RemoteVA{{brokenRVA, "broken"}} va, _ := setup(nil, 0, "local", remoteVAs, nil) @@ -663,10 +663,10 @@ func TestMultiCAARechecking(t *testing.T) { brokenUA = "broken" hijackedUA = "hijacked" ) - remoteVA, _ := setupRemote(nil, remoteUA, nil, "", "") - brokenVA, _ := setupRemote(nil, brokenUA, caaBrokenDNS{}, "", "") + remoteVA, _ := setupRemote(nil, remoteUA, nil) + brokenVA, _ := setupRemote(nil, brokenUA, caaBrokenDNS{}) // Returns incorrect results - hijackedVA, _ := setupRemote(nil, hijackedUA, caaHijackedDNS{}, "", "") + hijackedVA, _ := setupRemote(nil, hijackedUA, caaHijackedDNS{}) testCases := []struct { name string diff --git a/va/dns_test.go b/va/dns_test.go index a545228a47f..5ca06923c94 100644 --- a/va/dns_test.go +++ b/va/dns_test.go @@ -23,7 +23,7 @@ func TestDNSValidationEmpty(t *testing.T) { // This test calls PerformValidation directly, because that is where the // metrics checked below are incremented. - req := createValidationRequest("empty-txts.com", core.ChallengeTypeDNS01) + req := createPerformValidationRequest("empty-txts.com", core.ChallengeTypeDNS01) res, _ := va.PerformValidation(context.Background(), req) test.AssertEquals(t, res.Problems.ProblemType, "unauthorized") test.AssertEquals(t, res.Problems.Detail, "No TXT record found at _acme-challenge.empty-txts.com") diff --git a/va/proto/va.pb.go b/va/proto/va.pb.go index f73970cdee5..7ab4220452e 100644 --- a/va/proto/va.pb.go +++ b/va/proto/va.pb.go @@ -330,6 +330,85 @@ func (x *ValidationResult) GetRir() string { return "" } +type ValidationRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Identifier *proto.Identifier `protobuf:"bytes,1,opt,name=identifier,proto3" json:"identifier,omitempty"` + Challenge *proto.Challenge `protobuf:"bytes,2,opt,name=challenge,proto3" json:"challenge,omitempty"` + RegID int64 `protobuf:"varint,3,opt,name=regID,proto3" json:"regID,omitempty"` + AuthzID string `protobuf:"bytes,4,opt,name=authzID,proto3" json:"authzID,omitempty"` + KeyAuthorization string `protobuf:"bytes,5,opt,name=keyAuthorization,proto3" json:"keyAuthorization,omitempty"` +} + +func (x *ValidationRequest) Reset() { + *x = ValidationRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_va_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ValidationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidationRequest) ProtoMessage() {} + +func (x *ValidationRequest) ProtoReflect() protoreflect.Message { + mi := &file_va_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidationRequest.ProtoReflect.Descriptor instead. +func (*ValidationRequest) Descriptor() ([]byte, []int) { + return file_va_proto_rawDescGZIP(), []int{5} +} + +func (x *ValidationRequest) GetIdentifier() *proto.Identifier { + if x != nil { + return x.Identifier + } + return nil +} + +func (x *ValidationRequest) GetChallenge() *proto.Challenge { + if x != nil { + return x.Challenge + } + return nil +} + +func (x *ValidationRequest) GetRegID() int64 { + if x != nil { + return x.RegID + } + return 0 +} + +func (x *ValidationRequest) GetAuthzID() string { + if x != nil { + return x.AuthzID + } + return "" +} + +func (x *ValidationRequest) GetKeyAuthorization() string { + if x != nil { + return x.KeyAuthorization + } + return "" +} + var File_va_proto protoreflect.FileDescriptor var file_va_proto_rawDesc = []byte{ @@ -373,20 +452,38 @@ var file_va_proto_rawDesc = []byte{ 0x73, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x73, 0x70, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x73, 0x70, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x10, 0x0a, - 0x03, 0x72, 0x69, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x72, 0x69, 0x72, 0x32, - 0x4f, 0x0a, 0x02, 0x56, 0x41, 0x12, 0x49, 0x0a, 0x11, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x76, 0x61, 0x2e, - 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x76, 0x61, 0x2e, 0x56, 0x61, - 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x00, - 0x32, 0x44, 0x0a, 0x03, 0x43, 0x41, 0x41, 0x12, 0x3d, 0x0a, 0x0a, 0x49, 0x73, 0x43, 0x41, 0x41, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x15, 0x2e, 0x76, 0x61, 0x2e, 0x49, 0x73, 0x43, 0x41, 0x41, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x76, - 0x61, 0x2e, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x03, 0x72, 0x69, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x72, 0x69, 0x72, 0x22, + 0xd0, 0x01, 0x0a, 0x11, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x30, 0x0a, 0x0a, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, + 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x63, 0x6f, 0x72, 0x65, + 0x2e, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0a, 0x69, 0x64, 0x65, + 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x2d, 0x0a, 0x09, 0x63, 0x68, 0x61, 0x6c, 0x6c, + 0x65, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x63, 0x6f, 0x72, + 0x65, 0x2e, 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x52, 0x09, 0x63, 0x68, 0x61, + 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x67, 0x49, 0x44, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x72, 0x65, 0x67, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, + 0x61, 0x75, 0x74, 0x68, 0x7a, 0x49, 0x44, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, + 0x75, 0x74, 0x68, 0x7a, 0x49, 0x44, 0x12, 0x2a, 0x0a, 0x10, 0x6b, 0x65, 0x79, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x10, 0x6b, 0x65, 0x79, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x32, 0x93, 0x01, 0x0a, 0x02, 0x56, 0x41, 0x12, 0x49, 0x0a, 0x11, 0x50, 0x65, 0x72, + 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, + 0x2e, 0x76, 0x61, 0x2e, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x76, + 0x61, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x22, 0x00, 0x12, 0x42, 0x0a, 0x11, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, + 0x43, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x12, 0x15, 0x2e, 0x76, 0x61, 0x2e, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x14, 0x2e, 0x76, 0x61, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x00, 0x32, 0x44, 0x0a, 0x03, 0x43, 0x41, 0x41, 0x12, + 0x3d, 0x0a, 0x0a, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x15, 0x2e, + 0x76, 0x61, 0x2e, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x76, 0x61, 0x2e, 0x49, 0x73, 0x43, 0x41, 0x41, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x29, + 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, 0x74, + 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, 0x72, + 0x2f, 0x76, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, } var ( @@ -401,32 +498,38 @@ func file_va_proto_rawDescGZIP() []byte { return file_va_proto_rawDescData } -var file_va_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_va_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_va_proto_goTypes = []interface{}{ (*IsCAAValidRequest)(nil), // 0: va.IsCAAValidRequest (*IsCAAValidResponse)(nil), // 1: va.IsCAAValidResponse (*PerformValidationRequest)(nil), // 2: va.PerformValidationRequest (*AuthzMeta)(nil), // 3: va.AuthzMeta (*ValidationResult)(nil), // 4: va.ValidationResult - (*proto.ProblemDetails)(nil), // 5: core.ProblemDetails - (*proto.Challenge)(nil), // 6: core.Challenge - (*proto.ValidationRecord)(nil), // 7: core.ValidationRecord + (*ValidationRequest)(nil), // 5: va.ValidationRequest + (*proto.ProblemDetails)(nil), // 6: core.ProblemDetails + (*proto.Challenge)(nil), // 7: core.Challenge + (*proto.ValidationRecord)(nil), // 8: core.ValidationRecord + (*proto.Identifier)(nil), // 9: core.Identifier } var file_va_proto_depIdxs = []int32{ - 5, // 0: va.IsCAAValidResponse.problem:type_name -> core.ProblemDetails - 6, // 1: va.PerformValidationRequest.challenge:type_name -> core.Challenge - 3, // 2: va.PerformValidationRequest.authz:type_name -> va.AuthzMeta - 7, // 3: va.ValidationResult.records:type_name -> core.ValidationRecord - 5, // 4: va.ValidationResult.problems:type_name -> core.ProblemDetails - 2, // 5: va.VA.PerformValidation:input_type -> va.PerformValidationRequest - 0, // 6: va.CAA.IsCAAValid:input_type -> va.IsCAAValidRequest - 4, // 7: va.VA.PerformValidation:output_type -> va.ValidationResult - 1, // 8: va.CAA.IsCAAValid:output_type -> va.IsCAAValidResponse - 7, // [7:9] is the sub-list for method output_type - 5, // [5:7] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 6, // 0: va.IsCAAValidResponse.problem:type_name -> core.ProblemDetails + 7, // 1: va.PerformValidationRequest.challenge:type_name -> core.Challenge + 3, // 2: va.PerformValidationRequest.authz:type_name -> va.AuthzMeta + 8, // 3: va.ValidationResult.records:type_name -> core.ValidationRecord + 6, // 4: va.ValidationResult.problems:type_name -> core.ProblemDetails + 9, // 5: va.ValidationRequest.identifier:type_name -> core.Identifier + 7, // 6: va.ValidationRequest.challenge:type_name -> core.Challenge + 2, // 7: va.VA.PerformValidation:input_type -> va.PerformValidationRequest + 5, // 8: va.VA.ValidateChallenge:input_type -> va.ValidationRequest + 0, // 9: va.CAA.IsCAAValid:input_type -> va.IsCAAValidRequest + 4, // 10: va.VA.PerformValidation:output_type -> va.ValidationResult + 4, // 11: va.VA.ValidateChallenge:output_type -> va.ValidationResult + 1, // 12: va.CAA.IsCAAValid:output_type -> va.IsCAAValidResponse + 10, // [10:13] is the sub-list for method output_type + 7, // [7:10] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_va_proto_init() } @@ -495,6 +598,18 @@ func file_va_proto_init() { return nil } } + file_va_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ValidationRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -502,7 +617,7 @@ func file_va_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_va_proto_rawDesc, NumEnums: 0, - NumMessages: 5, + NumMessages: 6, NumExtensions: 0, NumServices: 2, }, diff --git a/va/proto/va.proto b/va/proto/va.proto index 3be26241330..9362ac6aa3b 100644 --- a/va/proto/va.proto +++ b/va/proto/va.proto @@ -7,6 +7,7 @@ import "core/proto/core.proto"; service VA { rpc PerformValidation(PerformValidationRequest) returns (ValidationResult) {} + rpc ValidateChallenge(ValidationRequest) returns (ValidationResult) {} } service CAA { @@ -43,3 +44,11 @@ message ValidationResult { string perspective = 3; string rir = 4; } + +message ValidationRequest { + core.Identifier identifier = 1; + core.Challenge challenge = 2; + int64 regID = 3; + string authzID = 4; + string keyAuthorization = 5; +} diff --git a/va/proto/va_grpc.pb.go b/va/proto/va_grpc.pb.go index b7c3df4f33b..250ffa49657 100644 --- a/va/proto/va_grpc.pb.go +++ b/va/proto/va_grpc.pb.go @@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( VA_PerformValidation_FullMethodName = "/va.VA/PerformValidation" + VA_ValidateChallenge_FullMethodName = "/va.VA/ValidateChallenge" ) // VAClient is the client API for VA service. @@ -27,6 +28,7 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type VAClient interface { PerformValidation(ctx context.Context, in *PerformValidationRequest, opts ...grpc.CallOption) (*ValidationResult, error) + ValidateChallenge(ctx context.Context, in *ValidationRequest, opts ...grpc.CallOption) (*ValidationResult, error) } type vAClient struct { @@ -47,11 +49,22 @@ func (c *vAClient) PerformValidation(ctx context.Context, in *PerformValidationR return out, nil } +func (c *vAClient) ValidateChallenge(ctx context.Context, in *ValidationRequest, opts ...grpc.CallOption) (*ValidationResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidationResult) + err := c.cc.Invoke(ctx, VA_ValidateChallenge_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // VAServer is the server API for VA service. // All implementations must embed UnimplementedVAServer // for forward compatibility type VAServer interface { PerformValidation(context.Context, *PerformValidationRequest) (*ValidationResult, error) + ValidateChallenge(context.Context, *ValidationRequest) (*ValidationResult, error) mustEmbedUnimplementedVAServer() } @@ -62,6 +75,9 @@ type UnimplementedVAServer struct { func (UnimplementedVAServer) PerformValidation(context.Context, *PerformValidationRequest) (*ValidationResult, error) { return nil, status.Errorf(codes.Unimplemented, "method PerformValidation not implemented") } +func (UnimplementedVAServer) ValidateChallenge(context.Context, *ValidationRequest) (*ValidationResult, error) { + return nil, status.Errorf(codes.Unimplemented, "method ValidateChallenge not implemented") +} func (UnimplementedVAServer) mustEmbedUnimplementedVAServer() {} // UnsafeVAServer may be embedded to opt out of forward compatibility for this service. @@ -93,6 +109,24 @@ func _VA_PerformValidation_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _VA_ValidateChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VAServer).ValidateChallenge(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VA_ValidateChallenge_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VAServer).ValidateChallenge(ctx, req.(*ValidationRequest)) + } + return interceptor(ctx, in, info, handler) +} + // VA_ServiceDesc is the grpc.ServiceDesc for VA service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -104,6 +138,10 @@ var VA_ServiceDesc = grpc.ServiceDesc{ MethodName: "PerformValidation", Handler: _VA_PerformValidation_Handler, }, + { + MethodName: "ValidateChallenge", + Handler: _VA_ValidateChallenge_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "va.proto", diff --git a/va/va.go b/va/va.go index 17c03cf6e01..0d6a4462ce5 100644 --- a/va/va.go +++ b/va/va.go @@ -31,8 +31,6 @@ import ( vapb "github.com/letsencrypt/boulder/va/proto" ) -const PrimaryPerspective = "Primary" - var ( // badTLSHeader contains the string 'HTTP /' which is returned when // we try to talk TLS to a server that only talks HTTP @@ -79,6 +77,8 @@ type RemoteClients struct { // extract this metadata which is useful for debugging gRPC connection issues. type RemoteVA struct { RemoteClients + // Address is the [hostname|IP]:port of the remote VA gRPC server. It is + // only used for logging and testing purposes. Address string } @@ -97,6 +97,15 @@ type vaMetrics struct { http01Redirects prometheus.Counter caaCounter *prometheus.CounterVec ipv4FallbackCounter prometheus.Counter + + // validationLatency is a histogram of the latency to perform validations + // from the primary and remote VA perspectives. It's labelled by: + // - operation: VA.ValidateChallenge or VA.CheckCAA as [challenge|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 } func initMetrics(stats prometheus.Registerer) *vaMetrics { @@ -196,6 +205,15 @@ func initMetrics(stats prometheus.Registerer) *vaMetrics { Help: "A counter of IPv4 fallbacks during TLS ALPN validation", }) stats.MustRegister(ipv4FallbackCounter) + 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) return &vaMetrics{ validationTime: validationTime, @@ -212,6 +230,7 @@ func initMetrics(stats prometheus.Registerer) *vaMetrics { http01Redirects: http01Redirects, caaCounter: caaCounter, ipv4FallbackCounter: ipv4FallbackCounter, + validationLatency: validationLatency, } } @@ -322,8 +341,6 @@ type verificationRequestEvent struct { ValidationLatency float64 Error string `json:",omitempty"` InternalError string `json:",omitempty"` - Perspective string `json:",omitempty"` - RIR string `json:",omitempty"` } // ipError is an error type used to pass though the IP address of the remote @@ -678,18 +695,6 @@ func (va *ValidationAuthorityImpl) PerformValidation(ctx context.Context, req *v logEvent.Challenge.Status = core.StatusValid } - if va.perspective != "" && va.perspective != PrimaryPerspective { - // This validation was performed by a remote VA. According to the - // requirements in section 5.4.1 (2) vii of the BRs we need to log - // the perspective used. Additionally, we'll log the RIR where this - // RVA is located. - // - // TODO(#7615): Make these fields mandatory for non-Primary - // perspectives and remove the (va.perspective != "") check. - logEvent.Perspective = va.perspective - logEvent.RIR = va.rir - } - va.metrics.localValidationTime.With(prometheus.Labels{ "type": string(logEvent.Challenge.Type), "result": string(logEvent.Challenge.Status), @@ -727,7 +732,7 @@ func (va *ValidationAuthorityImpl) PerformValidation(ctx context.Context, req *v if err != nil { logEvent.InternalError = err.Error() prob = detailedError(err) - return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob)) + return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob), "", "") } // Do remote validation. We do this after local validation is complete to @@ -736,5 +741,5 @@ func (va *ValidationAuthorityImpl) PerformValidation(ctx context.Context, req *v // own validation records, and it's not helpful to present multiple large // errors to the end user. prob = va.performRemoteValidation(ctx, req) - return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob)) + return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob), "", "") } diff --git a/va/va_test.go b/va/va_test.go index 705ca7d5372..6bd067202b5 100644 --- a/va/va_test.go +++ b/va/va_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rsa" "encoding/base64" + "encoding/json" "errors" "fmt" "math/big" @@ -11,6 +12,7 @@ import ( "net/http" "net/http/httptest" "os" + "regexp" "strings" "sync" "syscall" @@ -84,7 +86,7 @@ func TestMain(m *testing.M) { var accountURIPrefixes = []string{"http://boulder.service.consul:4000/acme/reg/"} -func createValidationRequest(domain string, challengeType core.AcmeChallenge) *vapb.PerformValidationRequest { +func createPerformValidationRequest(domain string, challengeType core.AcmeChallenge) *vapb.PerformValidationRequest { return &vapb.PerformValidationRequest{ DnsName: domain, Challenge: &corepb.Challenge{ @@ -148,10 +150,8 @@ func setup(srv *httptest.Server, maxRemoteFailures int, userAgent string, remote return va, logger } -func setupRemote(srv *httptest.Server, userAgent string, mockDNSClientOverride bdns.Client, perspective, rir string) (RemoteClients, *blog.Mock) { +func setupRemote(srv *httptest.Server, userAgent string, mockDNSClientOverride bdns.Client) (RemoteClients, *blog.Mock) { rva, log := setup(srv, 0, userAgent, nil, mockDNSClientOverride) - rva.perspective = perspective - rva.rir = rir return RemoteClients{VAClient: &inMemVA{*rva}, CAAClient: &inMemVA{*rva}}, log } @@ -197,15 +197,19 @@ func httpMultiSrv(t *testing.T, token string, allowedUAs map[string]bool) *multi return ms } -// cancelledVA is a mock that always returns context.Canceled for +// canceledVA is a mock that always returns context.Canceled for // PerformValidation calls -type cancelledVA struct{} +type canceledVA struct{} -func (v cancelledVA) PerformValidation(_ context.Context, _ *vapb.PerformValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { +func (v canceledVA) PerformValidation(_ context.Context, _ *vapb.PerformValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { return nil, context.Canceled } -func (v cancelledVA) IsCAAValid(_ context.Context, _ *vapb.IsCAAValidRequest, _ ...grpc.CallOption) (*vapb.IsCAAValidResponse, error) { +func (v canceledVA) IsCAAValid(_ context.Context, _ *vapb.IsCAAValidRequest, _ ...grpc.CallOption) (*vapb.IsCAAValidResponse, error) { + return nil, context.Canceled +} + +func (v canceledVA) ValidateChallenge(_ context.Context, _ *vapb.ValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { return nil, context.Canceled } @@ -226,6 +230,10 @@ func (b brokenRemoteVA) IsCAAValid(_ context.Context, _ *vapb.IsCAAValidRequest, return nil, errBrokenRemoteVA } +func (b brokenRemoteVA) ValidateChallenge(_ context.Context, _ *vapb.ValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { + return nil, errBrokenRemoteVA +} + // inMemVA is a wrapper which fulfills the VAClient and CAAClient // interfaces, but then forwards requests directly to its inner // ValidationAuthorityImpl rather than over the network. This lets a local @@ -242,6 +250,10 @@ func (inmem inMemVA) IsCAAValid(ctx context.Context, req *vapb.IsCAAValidRequest return inmem.rva.IsCAAValid(ctx, req) } +func (inmem inMemVA) ValidateChallenge(_ context.Context, req *vapb.ValidationRequest, _ ...grpc.CallOption) (*vapb.ValidationResult, error) { + return inmem.rva.ValidateChallenge(ctx, req) +} + func TestValidateMalformedChallenge(t *testing.T) { va, _ := setup(nil, 0, "", nil, nil) @@ -254,7 +266,7 @@ func TestValidateMalformedChallenge(t *testing.T) { func TestPerformValidationInvalid(t *testing.T) { va, _ := setup(nil, 0, "", nil, nil) - req := createValidationRequest("foo.com", core.ChallengeTypeDNS01) + req := createPerformValidationRequest("foo.com", core.ChallengeTypeDNS01) res, _ := va.PerformValidation(context.Background(), req) test.Assert(t, res.Problems != nil, "validation succeeded") @@ -270,7 +282,7 @@ func TestInternalErrorLogged(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) defer cancel() - req := createValidationRequest("nonexistent.com", core.ChallengeTypeHTTP01) + req := createPerformValidationRequest("nonexistent.com", core.ChallengeTypeHTTP01) _, err := va.PerformValidation(ctx, req) test.AssertNotError(t, err, "failed validation should not be an error") matchingLogs := mockLog.GetAllMatching( @@ -282,7 +294,7 @@ func TestPerformValidationValid(t *testing.T) { va, mockLog := setup(nil, 0, "", nil, nil) // create a challenge with well known token - req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + req := createPerformValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) res, _ := va.PerformValidation(context.Background(), req) test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) @@ -306,7 +318,7 @@ func TestPerformValidationWildcard(t *testing.T) { va, mockLog := setup(nil, 0, "", nil, nil) // create a challenge with well known token - req := createValidationRequest("*.good-dns01.com", core.ChallengeTypeDNS01) + req := createPerformValidationRequest("*.good-dns01.com", core.ChallengeTypeDNS01) // perform a validation for a wildcard name res, _ := va.PerformValidation(context.Background(), req) test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) @@ -337,7 +349,7 @@ func TestDCVAndCAASequencing(t *testing.T) { // When validation succeeds, CAA should be checked. mockLog.Clear() - req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + req := createPerformValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) res, err := va.PerformValidation(context.Background(), req) test.AssertNotError(t, err, "performing validation") test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) @@ -346,7 +358,7 @@ func TestDCVAndCAASequencing(t *testing.T) { // When validation fails, CAA should be skipped. mockLog.Clear() - req = createValidationRequest("bad-dns01.com", core.ChallengeTypeDNS01) + req = createPerformValidationRequest("bad-dns01.com", core.ChallengeTypeDNS01) res, err = va.PerformValidation(context.Background(), req) test.AssertNotError(t, err, "performing validation") test.Assert(t, res.Problems != nil, "validation succeeded") @@ -356,7 +368,7 @@ func TestDCVAndCAASequencing(t *testing.T) { func TestMultiVA(t *testing.T) { // Create a new challenge to use for the httpSrv - req := createValidationRequest("localhost", core.ChallengeTypeHTTP01) + req := createPerformValidationRequest("localhost", core.ChallengeTypeHTTP01) const ( remoteUA1 = "remote 1" @@ -373,8 +385,8 @@ func TestMultiVA(t *testing.T) { ms := httpMultiSrv(t, expectedToken, allowedUAs) defer ms.Close() - remoteVA1, _ := setupRemote(ms.Server, remoteUA1, nil, "", "") - remoteVA2, _ := setupRemote(ms.Server, remoteUA2, nil, "", "") + remoteVA1, _ := setupRemote(ms.Server, remoteUA1, nil) + remoteVA2, _ := setupRemote(ms.Server, remoteUA2, nil) remoteVAs := []RemoteVA{ {remoteVA1, remoteUA1}, {remoteVA2, remoteUA2}, @@ -384,8 +396,8 @@ func TestMultiVA(t *testing.T) { CAAClient: brokenRemoteVA{}, } cancelledVA := RemoteClients{ - VAClient: cancelledVA{}, - CAAClient: cancelledVA{}, + VAClient: canceledVA{}, + CAAClient: canceledVA{}, } unauthorized := probs.Unauthorized(fmt.Sprintf( @@ -511,8 +523,8 @@ func TestMultiVAEarlyReturn(t *testing.T) { ms := httpMultiSrv(t, expectedToken, allowedUAs) defer ms.Close() - remoteVA1, _ := setupRemote(ms.Server, remoteUA1, nil, "", "") - remoteVA2, _ := setupRemote(ms.Server, remoteUA2, nil, "", "") + remoteVA1, _ := setupRemote(ms.Server, remoteUA1, nil) + remoteVA2, _ := setupRemote(ms.Server, remoteUA2, nil) remoteVAs := []RemoteVA{ {remoteVA1, remoteUA1}, @@ -524,7 +536,7 @@ func TestMultiVAEarlyReturn(t *testing.T) { // Perform all validations start := time.Now() - req := createValidationRequest("localhost", core.ChallengeTypeHTTP01) + req := createPerformValidationRequest("localhost", core.ChallengeTypeHTTP01) res, _ := localVA.PerformValidation(ctx, req) // It should always fail @@ -561,8 +573,8 @@ func TestMultiVAPolicy(t *testing.T) { ms := httpMultiSrv(t, expectedToken, allowedUAs) defer ms.Close() - remoteVA1, _ := setupRemote(ms.Server, remoteUA1, nil, "", "") - remoteVA2, _ := setupRemote(ms.Server, remoteUA2, nil, "", "") + remoteVA1, _ := setupRemote(ms.Server, remoteUA1, nil) + remoteVA2, _ := setupRemote(ms.Server, remoteUA2, nil) remoteVAs := []RemoteVA{ {remoteVA1, remoteUA1}, @@ -573,7 +585,7 @@ func TestMultiVAPolicy(t *testing.T) { localVA, _ := setup(ms.Server, 0, localUA, remoteVAs, nil) // Perform validation for a domain not in the disabledDomains list - req := createValidationRequest("letsencrypt.org", core.ChallengeTypeHTTP01) + req := createPerformValidationRequest("letsencrypt.org", core.ChallengeTypeHTTP01) res, _ := localVA.PerformValidation(ctx, req) // It should fail if res.Problems == nil { @@ -591,28 +603,18 @@ func TestMultiVALogging(t *testing.T) { ms := httpMultiSrv(t, expectedToken, map[string]bool{localUA: true, rva1UA: true, rva2UA: true}) defer ms.Close() - rva1, rva1Log := setupRemote(ms.Server, rva1UA, nil, "dev-arin", "ARIN") - rva2, rva2Log := setupRemote(ms.Server, rva2UA, nil, "dev-ripe", "RIPE") + rva1, _ := setupRemote(ms.Server, rva1UA, nil) + rva2, _ := setupRemote(ms.Server, rva2UA, nil) remoteVAs := []RemoteVA{ {rva1, rva1UA}, {rva2, rva2UA}, } - va, vaLog := setup(ms.Server, 0, localUA, remoteVAs, nil) - req := createValidationRequest("letsencrypt.org", core.ChallengeTypeHTTP01) + va, _ := setup(ms.Server, 0, localUA, remoteVAs, nil) + req := createPerformValidationRequest("letsencrypt.org", core.ChallengeTypeHTTP01) res, err := va.PerformValidation(ctx, req) test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed with: %#v", res.Problems)) test.AssertNotError(t, err, "performing validation") - - // We do not log perspective or RIR for the local VAs. - test.Assert(t, len(vaLog.GetAllMatching(`"Perspective"`)) == 0, "expected no logged perspective for primary") - test.Assert(t, len(vaLog.GetAllMatching(`"RIR"`)) == 0, "expected no logged RIR for primary") - - // We do log perspective and RIR for the remote VAs. - test.Assert(t, len(rva1Log.GetAllMatching(`"Perspective":"dev-arin"`)) == 1, "expected perspective of VA to be dev-arin") - test.Assert(t, len(rva1Log.GetAllMatching(`"RIR":"ARIN"`)) == 1, "expected perspective of VA to be ARIN") - test.Assert(t, len(rva2Log.GetAllMatching(`"Perspective":"dev-ripe"`)) == 1, "expected perspective of VA to be dev-ripe") - test.Assert(t, len(rva2Log.GetAllMatching(`"RIR":"RIPE"`)) == 1, "expected perspective of VA to be RIPE") } func TestDetailedError(t *testing.T) { @@ -669,9 +671,9 @@ func TestDetailedError(t *testing.T) { func TestLogRemoteDifferentials(t *testing.T) { // Create some remote VAs - remoteVA1, _ := setupRemote(nil, "remote 1", nil, "", "") - remoteVA2, _ := setupRemote(nil, "remote 2", nil, "", "") - remoteVA3, _ := setupRemote(nil, "remote 3", nil, "", "") + remoteVA1, _ := setupRemote(nil, "remote 1", nil) + remoteVA2, _ := setupRemote(nil, "remote 2", nil) + remoteVA3, _ := setupRemote(nil, "remote 3", nil) remoteVAs := []RemoteVA{ {remoteVA1, "remote 1"}, {remoteVA2, "remote 2"}, @@ -734,3 +736,442 @@ func TestLogRemoteDifferentials(t *testing.T) { }) } } + +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +// setup returns an in-memory VA and a mock logger. The default resolver client +// is MockClient{}, but can be overridden. +func setupVA(srv *httptest.Server, ua string, rvas []RemoteVA, mockDNSClient bdns.Client) (*ValidationAuthorityImpl, *blog.Mock) { + features.Reset() + fc := clock.NewFake() + + mockLog := blog.NewMock() + if ua == "" { + ua = "user agent 1.0" + } + + va, err := NewValidationAuthorityImpl( + &bdns.MockClient{Log: mockLog}, + nil, + 0, + ua, + "letsencrypt.org", + metrics.NoopRegisterer, + fc, + mockLog, + accountURIPrefixes, + PrimaryPerspective, + "ARIN", + ) + + if mockDNSClient != nil { + va.dnsClient = mockDNSClient + } + + // Adjusting industry regulated ACME challenge port settings is fine during + // testing + if srv != nil { + port := getPort(srv) + va.httpPort = port + va.tlsPort = port + } + + if err != nil { + panic(fmt.Sprintf("Failed to create validation authority: %v", err)) + } + if rvas != nil { + va.remoteVAs = rvas + } + return va, mockLog +} + +type rvaConf struct { + // rir is the Regional Internet Registry for the remote VA. + rir string + + // ua if set to pass, the remote VA will always pass validation. If set to + // fail, the remote VA will always fail validation with probs.Unauthorized. + // This is set to pass by default. + ua string +} + +// setupRVAs returns a slice of RemoteVA instances for testing. confs is a slice +// of rir and user agent configurations for each RVA. mockDNSClient is optional, +// it allows the DNS client to be overridden. srv is optional, it allows for a +// test server to be specified. +func setupRVAs(confs []rvaConf, mockDNSClient bdns.Client, srv *httptest.Server) []RemoteVA { + remoteVAs := make([]RemoteVA, 0, len(confs)) + for i, c := range confs { + ua := "user agent 1.0" + if c.ua != "" { + ua = c.ua + } + + // Configure the remote VA. + rva, _ := setupVA(srv, ua, nil, mockDNSClient) + rva.perspective = fmt.Sprintf("dc-%d-%s", i, c.rir) + rva.rir = c.rir + + // Initialize the remote VA. + remoteVAs = append(remoteVAs, RemoteVA{ + Address: fmt.Sprintf("dc-%d-%s", i, c.rir), + RemoteClients: RemoteClients{ + VAClient: &inMemVA{*rva}, + CAAClient: &inMemVA{*rva}, + }, + }) + } + return remoteVAs +} + +func createValidationRequest(domain string, challengeType core.AcmeChallenge) *vapb.ValidationRequest { + return &vapb.ValidationRequest{ + Identifier: &corepb.Identifier{ + Type: string(identifier.TypeDNS), + Value: domain, + }, + Challenge: &corepb.Challenge{ + Type: string(challengeType), + Status: string(core.StatusPending), + Token: expectedToken, + }, + RegID: 1, + AuthzID: "1", + KeyAuthorization: expectedKeyAuthorization, + } +} + +func TestValidateChallengeInvalid(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}, {rir: "RIPE"}, {rir: "APNIC"}}, nil, nil) + va, mockLog := setupVA(nil, "", rvas, nil) + + req := createValidationRequest("foo.com", core.ChallengeTypeDNS01) + + res, err := va.ValidateChallenge(context.Background(), req) + test.AssertNotError(t, err, "ValidateChallenge failed, expected success") + test.Assert(t, res.Problems != nil, "validation succeeded, expected failure") + resultLog := mockLog.GetAllMatching(`Challenge validation result`) + test.AssertNotNil(t, resultLog, "ValidateChallenge didn't log validation result.") + test.AssertContains(t, resultLog[0], `"Identifier":"foo.com"`) + test.AssertMetricWithLabelsEquals(t, va.metrics.validationLatency, prometheus.Labels{ + "operation": "challenge", + "perspective": "primary", + "challenge_type": string(core.ChallengeTypeDNS01), + "problem_type": string(probs.UnauthorizedProblem), + "result": fail, + }, 1) +} + +func TestValidateChallengeInternalErrorLogged(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}, {rir: "RIPE"}, {rir: "APNIC"}}, nil, nil) + va, mockLog := setupVA(nil, "", rvas, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + req := createValidationRequest("nonexistent.com", core.ChallengeTypeHTTP01) + + _, err := va.ValidateChallenge(ctx, req) + test.AssertNotError(t, err, "Failed validation should be a prob but not an error") + resultLog := mockLog.GetAllMatching( + `Challenge validation result JSON=.*"InternalError":"127.0.0.1: Get.*nonexistent.com/\.well-known.*: context deadline exceeded`) + test.AssertEquals(t, len(resultLog), 1) + test.AssertMetricWithLabelsEquals(t, va.metrics.validationLatency, prometheus.Labels{ + "operation": challenge, + "perspective": PrimaryPerspective, + "challenge_type": string(core.ChallengeTypeHTTP01), + "problem_type": string(probs.ConnectionProblem), + "result": fail, + }, 1) +} + +func TestValidateChallengeValid(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}, {rir: "RIPE"}, {rir: "APNIC"}}, nil, nil) + va, mockLog := setupVA(nil, "", rvas, nil) + + req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + + res, err := va.ValidateChallenge(context.Background(), req) + test.AssertNotError(t, err, "validating challenge resulted in unexpected error") + test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) + resultLog := mockLog.GetAllMatching(`Challenge validation result`) + test.AssertNotNil(t, resultLog, "ValidateChallenge didn't log validation result.") + test.AssertContains(t, resultLog[0], `"Identifier":"good-dns01.com"`) + test.AssertMetricWithLabelsEquals(t, va.metrics.validationLatency, prometheus.Labels{ + "operation": challenge, + "perspective": PrimaryPerspective, + "challenge_type": string(core.ChallengeTypeDNS01), + "problem_type": "", + "result": pass, + }, 1) +} + +// TestValidateChallengeWildcard tests that the VA properly strips the `*.` +// prefix from a wildcard name provided to the ValidateChallenge function. +func TestValidateChallengeWildcard(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}, {rir: "RIPE"}, {rir: "APNIC"}}, nil, nil) + va, mockLog := setupVA(nil, "", rvas, nil) + + req := createValidationRequest("*.good-dns01.com", core.ChallengeTypeDNS01) + + res, _ := va.ValidateChallenge(context.Background(), req) + test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) + resultLog := mockLog.GetAllMatching(`Challenge validation result`) + test.AssertNotNil(t, resultLog, "ValidateChallenge didn't log validation result.") + + // The top level Identifier will reflect the wildcard name. + test.AssertContains(t, resultLog[0], `"Identifier":"*.good-dns01.com"`) + + // The ValidationRecord will contain the non-wildcard name. + test.AssertContains(t, resultLog[0], `"hostname":"good-dns01.com"`) + test.AssertMetricWithLabelsEquals(t, va.metrics.validationLatency, prometheus.Labels{ + "operation": challenge, + "perspective": PrimaryPerspective, + "challenge_type": string(core.ChallengeTypeDNS01), + "problem_type": "", + "result": pass, + }, 1) +} + +func TestValidateChallengeValidWithBrokenRVA(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}, {rir: "RIPE"}}, nil, nil) + brokenRVA := RemoteClients{VAClient: brokenRemoteVA{}, CAAClient: brokenRemoteVA{}} + rvas = append(rvas, RemoteVA{brokenRVA, "broken"}) + va, _ := setupVA(nil, "", rvas, nil) + + req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + + res, err := va.ValidateChallenge(context.Background(), req) + test.AssertNotError(t, err, "validating challenge resulted in unexpected error") + test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) +} + +func TestValidateChallengeValidWithCancelledRVA(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}, {rir: "RIPE"}}, nil, nil) + cancelledRVA := RemoteClients{VAClient: canceledVA{}, CAAClient: canceledVA{}} + rvas = append(rvas, RemoteVA{cancelledRVA, "cancelled"}) + va, _ := setupVA(nil, "", rvas, nil) + + req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + + res, err := va.ValidateChallenge(context.Background(), req) + test.AssertNotError(t, err, "validating challenge resulted in unexpected error") + test.Assert(t, res.Problems == nil, fmt.Sprintf("validation failed: %#v", res.Problems)) +} + +func TestValidateChallengeFailsWithTooManyBrokenRVAs(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}}, nil, nil) + brokenRVA := RemoteClients{VAClient: brokenRemoteVA{}, CAAClient: brokenRemoteVA{}} + rvas = append(rvas, RemoteVA{brokenRVA, "broken"}, RemoteVA{brokenRVA, "broken"}) + va, _ := setupVA(nil, "", rvas, nil) + + req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + + res, err := va.ValidateChallenge(context.Background(), req) + test.AssertNotError(t, err, "Failed validation should be a prob but not an error") + test.AssertContains(t, res.Problems.Detail, "During secondary domain validation: Secondary domain validation RPC failed") + test.AssertMetricWithLabelsEquals(t, va.metrics.validationLatency, prometheus.Labels{ + "operation": challenge, + "perspective": PrimaryPerspective, + "challenge_type": string(core.ChallengeTypeDNS01), + "problem_type": string(probs.ServerInternalProblem), + "result": fail, + }, 1) +} + +func TestValidateChallengeFailsWithTooManyCanceledRVAs(t *testing.T) { + rvas := setupRVAs([]rvaConf{{rir: "ARIN"}}, nil, nil) + canceledRVA := RemoteClients{VAClient: canceledVA{}, CAAClient: canceledVA{}} + rvas = append(rvas, RemoteVA{canceledRVA, "canceled"}, RemoteVA{canceledRVA, "canceled"}) + va, _ := setupVA(nil, "", rvas, nil) + + req := createValidationRequest("good-dns01.com", core.ChallengeTypeDNS01) + + res, err := va.ValidateChallenge(context.Background(), req) + test.AssertNotError(t, err, "Failed validation should be a prob but not an error") + test.AssertContains(t, res.Problems.Detail, "During secondary domain validation: Secondary domain validation RPC canceled") + test.AssertMetricWithLabelsEquals(t, va.metrics.validationLatency, prometheus.Labels{ + "operation": challenge, + "perspective": PrimaryPerspective, + "challenge_type": string(core.ChallengeTypeDNS01), + "problem_type": string(probs.ServerInternalProblem), + "result": fail, + }, 1) +} + +// parseMPICSummary extracts ... from "MPICSummary":{ ... } in a +// ValidateChallenge log and returns it as an mpicSummary struct. +func parseMPICSummary(t *testing.T, log []string) mpicSummary { + re := regexp.MustCompile(`"MPICSummary":\{.*\}`) + + var summary mpicSummary + for _, line := range log { + match := re.FindString(line) + if match != "" { + jsonStr := strings.TrimSuffix(match[len(`"MPICSummary":`):], "}") + if err := json.Unmarshal([]byte(jsonStr), &summary); err != nil { + t.Fatalf("Failed to parse MPICSummary: %v", err) + } + return summary + } + } + + t.Fatal("MPICSummary JSON not found in log") + return summary +} + +func TestValidateChallengeMPIC(t *testing.T) { + req := createValidationRequest("localhost", core.ChallengeTypeHTTP01) + + // srv is used for the Primary VA and the Remote VAs. The srv.Server + // produced will be used to mock the challenge recipient. When a VA (primary + // or remote) with a user-agent (UA) of "pass" attempt to validate a + // challenge, it will succeed. When a VA with a UA of "fail" attempts to + // validate a challenge it will fail with probs.Unauthorized. By controlling + // which VA or Remote VA(s) are configured with which UA, we can control the + // conditions of each case. + srv := httpMultiSrv(t, expectedToken, map[string]bool{pass: true, fail: false}) + defer srv.Close() + + testCases := []struct { + name string + primaryUA string + rvas []rvaConf + expectedProbType probs.ProblemType + expectLogContains string + expectQuorumResult string + expectPassedRIRs []string + }{ + { + // If the primary and all remote VAs pass, the validation will succeed. + name: "VA: pass, remote1(ARIN): pass, remote2(RIPE): pass, remote3(APNIC): pass", + primaryUA: pass, + rvas: []rvaConf{{"ARIN", pass}, {"RIPE", pass}, {"APNIC", pass}}, + expectedProbType: "", + expectLogContains: `"Challenge":{"type":"http-01","status":"valid"`, + expectQuorumResult: "3/3", + expectPassedRIRs: []string{"APNIC", "ARIN", "RIPE"}, + }, + { + // If the primary passes and just one remote VA fails, the + // validation will succeed. + name: "VA: pass, rva1(ARIN): pass, rva2(RIPE): pass, rva3(APNIC): fail", + primaryUA: pass, + rvas: []rvaConf{{"ARIN", pass}, {"RIPE", pass}, {"APNIC", fail}}, + expectedProbType: "", + expectLogContains: `"Challenge":{"type":"http-01","status":"valid"`, + expectQuorumResult: "2/3", + expectPassedRIRs: []string{"ARIN", "RIPE"}, + }, + { + // If the primary passes and two remote VAs fail, the validation + // will fail. + name: "VA: pass, rva1(ARIN): pass, rva2(RIPE): fail, rva3(APNIC): fail", + primaryUA: pass, + rvas: []rvaConf{{"ARIN", pass}, {"RIPE", fail}, {"APNIC", fail}}, + expectedProbType: probs.UnauthorizedProblem, + expectLogContains: "During secondary domain validation: The key authorization file from the server did not match this challenge.", + expectQuorumResult: "1/3", + expectPassedRIRs: []string{"ARIN"}, + }, + { + // If the primary fails, the remote VAs will not be queried, and the + // validation will fail. + name: "VA: fail, rva1(ARIN): pass, rva2(RIPE): pass, rva3(APNIC): pass", + primaryUA: fail, + rvas: []rvaConf{{"ARIN", pass}, {"RIPE", pass}, {"APNIC", pass}}, + expectedProbType: probs.UnauthorizedProblem, + expectLogContains: "The key authorization file from the server did not match this challenge.", + expectQuorumResult: "", + expectPassedRIRs: nil, + }, + { + // If the primary passes and all of the passing RVAs are from the + // same RIR, the validation will fail and the error message will + // indicate the problem. + name: "VA: pass, rva1(ARIN): pass, rva2(ARIN): pass, rva3(APNIC): fail", + primaryUA: pass, + rvas: []rvaConf{{"ARIN", pass}, {"ARIN", pass}, {"APNIC", fail}}, + expectedProbType: probs.UnauthorizedProblem, + expectLogContains: "During secondary domain validation: The key authorization file from the server did not match this challenge.", + expectQuorumResult: "2/3", + expectPassedRIRs: []string{"ARIN"}, + }, + { + // If the primary passes and is configured with 6+ remote VAs, then + // the validation can succeed with up to 2 remote VA failures and + // successes from at least 2 distinct RIRs. + name: "VA: pass, rva1(ARIN): pass, rva2(APNIC): pass, rva3(ARIN): pass, rva4(ARIN): pass, rva5(ARIN): pass, rva6(ARIN): pass, rva7(ARIN): fail, rva8(ARIN): fail", + primaryUA: pass, + rvas: []rvaConf{ + {"ARIN", pass}, {"APNIC", pass}, {"ARIN", pass}, {"ARIN", pass}, {"ARIN", fail}, {"ARIN", fail}, + }, + expectedProbType: "", + expectLogContains: `"Challenge":{"type":"http-01","status":"valid"`, + expectQuorumResult: "4/6", + expectPassedRIRs: []string{"APNIC", "ARIN"}, + }, + { + // If the primary passes and is configured with 6+ remote VAs which + // return 3 or more failures, the validation will fail. + name: "VA: pass, rva1(ARIN): pass, rva2(APNIC): pass, rva3(ARIN): pass, rva4(ARIN): pass, rva5(ARIN): pass, rva6(ARIN): pass, rva7(ARIN): fail, rva8(ARIN): fail", + primaryUA: pass, + rvas: []rvaConf{ + {"ARIN", pass}, {"APNIC", pass}, {"ARIN", pass}, {"ARIN", pass}, + {"ARIN", pass}, {"ARIN", fail}, {"ARIN", fail}, {"ARIN", fail}, + }, + expectedProbType: probs.UnauthorizedProblem, + expectLogContains: "During secondary domain validation: The key authorization file from the server did not match this challenge.", + expectQuorumResult: "5/8", + expectPassedRIRs: []string{"APNIC", "ARIN"}, + }, + { + // If the primary passes and is configured with 6+ remote VAs, then + // the validation can succeed with up to 2 remote VA failures unless + // one of the failed RVAs was the only one from a distinct RIR. + name: "VA: pass, rva1(ARIN): pass, rva2(APNIC): pass, rva3(ARIN): pass, rva4(ARIN): pass, rva5(ARIN): pass, rva6(ARIN): pass, rva7(ARIN): fail, rva8(ARIN): fail", + primaryUA: pass, + rvas: []rvaConf{ + {"ARIN", pass}, {"APNIC", fail}, {"ARIN", pass}, {"ARIN", pass}, + {"ARIN", pass}, {"ARIN", pass}, {"ARIN", pass}, {"ARIN", fail}, + }, + expectedProbType: probs.UnauthorizedProblem, + expectLogContains: "During secondary domain validation: The key authorization file from the server did not match this challenge.", + expectQuorumResult: "6/8", + expectPassedRIRs: []string{"ARIN"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rvas := setupRVAs(tc.rvas, nil, srv.Server) + primaryVA, mockLog := setupVA(srv.Server, tc.primaryUA, rvas, nil) + + res, err := primaryVA.ValidateChallenge(ctx, req) + test.AssertNotError(t, err, "These cases should only produce a probs, not errors") + + if tc.expectedProbType == "" { + // We expect validation to succeed. + test.Assert(t, res.Problems == nil, fmt.Sprintf("Unexpected challenge validation failure: %#v", res.Problems)) + } else { + // We expect validation to fail. + test.AssertNotNil(t, res.Problems, "Expected validation failure but got success") + test.AssertEquals(t, string(tc.expectedProbType), res.Problems.ProblemType) + } + if tc.expectLogContains != "" { + test.AssertNotError(t, mockLog.ExpectMatch(tc.expectLogContains), "Expected log line not found") + } + got := parseMPICSummary(t, mockLog.GetAll()) + if tc.expectQuorumResult != "" { + test.AssertDeepEquals(t, tc.expectQuorumResult, got.QuorumResult) + } + if tc.expectPassedRIRs != nil { + test.AssertDeepEquals(t, tc.expectPassedRIRs, got.RIRs) + } + }) + } +} diff --git a/va/vampic.go b/va/vampic.go new file mode 100644 index 00000000000..63ba005d239 --- /dev/null +++ b/va/vampic.go @@ -0,0 +1,326 @@ +package va + +import ( + "context" + "errors" + "fmt" + "maps" + "math/rand/v2" + "slices" + "time" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/probs" + vapb "github.com/letsencrypt/boulder/va/proto" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + PrimaryPerspective = "primary" + + challenge = "challenge" + caa = "caa" + all = "all" + pass = "pass" + fail = "fail" +) + +// 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.ValidateChallenge or VA.CheckCAA as [challenge|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()) +} + +func (va *ValidationAuthorityImpl) onPrimaryVA() bool { + return va.perspective == PrimaryPerspective +} + +// mpicSummary contains multiple fields that are exported for logging purposes. +// Use newSummary() to create a new mpicSummary. +type mpicSummary struct { + // Passed is the list of distinct perspectives that Passed validation. + Passed []string `json:"passedPerspectives"` + + // Failed is the list of distinct perspectives that Failed validation. + Failed []string `json:"failedPerspectives"` + + // RIRs is the list of distinct RIRs that passing perspectives belonged to. + RIRs []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"` +} + +// newSummary returns a new mpicSummary with empty slices so that JSON output is +// always an empty array instead of "null". +func newSummary() mpicSummary { + return mpicSummary{ + Passed: []string{}, + Failed: []string{}, + RIRs: []string{}, + } +} + +// validateChallengeAuditLog contains multiple fields that are exported for +// logging purposes. +type validateChallengeAuditLog struct { + AuthzID string `json:",omitempty"` + Requester int64 `json:",omitempty"` + Identifier string `json:",omitempty"` + Challenge core.Challenge `json:",omitempty"` + Error string `json:",omitempty"` + InternalError string `json:",omitempty"` + Latency float64 `json:",omitempty"` + MPICSummary mpicSummary `json:",omitempty"` +} + +// determineMaxAllowedFailures 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 determineMaxAllowedFailures(perspectives int) int { + if perspectives < 2 { + return 0 + } + if perspectives < 6 { + return 1 + } + return 2 +} + +// remoteValidateChallenge performs an MPIC-compliant remote validation of the +// challenge using the configured remote VAs. It returns a summary of the +// validation results and a problem if the validation failed. The summary is +// mandatory and must be returned even if the validation failed. +func (va *ValidationAuthorityImpl) remoteValidateChallenge(ctx context.Context, req *vapb.ValidationRequest) (mpicSummary, *probs.ProblemDetails) { + remoteVACount := len(va.remoteVAs) + if remoteVACount < 3 { + return mpicSummary{}, probs.ServerInternal("Insufficient remote perspectives: need at least 3") + } + + type remoteResult struct { + // rvaAddr is only used for logging. + rvaAddr string + response *vapb.ValidationResult + err error + } + + responses := make(chan *remoteResult, remoteVACount) + for _, i := range rand.Perm(remoteVACount) { + rva := va.remoteVAs[i] + + go func(rva RemoteVA) { + res, err := rva.ValidateChallenge(ctx, req) + responses <- &remoteResult{ + rvaAddr: rva.Address, + response: res, + err: err, + } + }(rva) + } + + passed := []string{} + failed := []string{} + passedRIRs := make(map[string]struct{}) + + maxRemoteFailures := determineMaxAllowedFailures(remoteVACount) + required := remoteVACount - maxRemoteFailures + + var firstProb *probs.ProblemDetails + for i := 0; i < remoteVACount; i++ { + res := <-responses + + var currProb *probs.ProblemDetails + if res.err != nil { + // The remote VA failed to respond. With no response, we cannot know + // the perspective name, so we use the remote VA address. + failed = append(failed, res.rvaAddr) + if errors.Is(res.err, context.Canceled) { + currProb = probs.ServerInternal("Secondary domain validation RPC canceled") + } else { + va.log.Errf("Remote VA %q.ValidateChallenge failed: %s", res.rvaAddr, res.err) + currProb = probs.ServerInternal("Secondary domain validation RPC failed") + } + + } else if res.response.Problems != nil { + // The remote VA returned a problem. + failed = append(failed, res.response.Perspective) + + var err error + currProb, err = bgrpc.PBToProblemDetails(res.response.Problems) + if err != nil { + va.log.Errf("Remote VA %q.ValidateChallenge returned malformed problem: %s", res.rvaAddr, err) + currProb = probs.ServerInternal("Secondary domain validation RPC returned malformed result") + } + + } else { + // The remote VA returned a successful response. + passed = append(passed, res.response.Perspective) + passedRIRs[res.response.Rir] = struct{}{} + } + + if firstProb == nil && currProb != nil { + // A problem was encountered for the first time. + firstProb = currProb + } + } + + // Prepare the summary, this MUST be returned even if the validation failed. + summary := mpicSummary{ + Passed: passed, + Failed: failed, + QuorumResult: fmt.Sprintf("%d/%d", len(passed), remoteVACount), + } + for rir := range maps.Keys(passedRIRs) { + summary.RIRs = append(summary.RIRs, rir) + } + slices.Sort(summary.RIRs) + + if len(passed) >= required { + // We may have enough successful responses. + if len(passedRIRs) < 2 { + if firstProb != nil { + firstProb.Detail = fmt.Sprintf("During secondary domain validation: %s", firstProb.Detail) + return summary, firstProb + } + return summary, probs.Unauthorized("Secondary domain validation failed to receive enough responses from disctinct RIRs") + } + // We have enough successful responses from distinct perspectives. + return summary, nil + } + + if len(failed) > maxRemoteFailures { + // We have too many failed responses. + if firstProb != nil { + firstProb.Detail = fmt.Sprintf("During secondary domain validation: %s", firstProb.Detail) + return summary, firstProb + } + } + // This return is unreachable because for any number of remote VAs (n), + // either at least (n - maxFailures) perspectives pass, or more than + // maxFailures fail. Thus, one of the above conditions is always satisfied. + return summary, probs.ServerInternal("Secondary domain validation failed to receive all responses") +} + +// ValidateChallenge performs a local validation of a challenge using the +// configured local VA. If the local validation passes, it will also perform an +// MPIC-compliant validation of the challenge using the configured remote VAs. +// +// Note: This method calls itself recursively to perform remote validation. +func (va *ValidationAuthorityImpl) ValidateChallenge(ctx context.Context, req *vapb.ValidationRequest) (*vapb.ValidationResult, error) { + if core.IsAnyNilOrZero(req, req.Identifier, req.Challenge, req.AuthzID, req.RegID, req.KeyAuthorization) { + return nil, berrors.InternalServerError("Incomplete validation request") + } + + identifier := identifier.NewDNS(req.Identifier.Value) + 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) + } + + var prob *probs.ProblemDetails + var localLatency time.Duration + var latency time.Duration + summary := newSummary() + start := va.clk.Now() + + auditLog := validateChallengeAuditLog{ + AuthzID: req.AuthzID, + Requester: req.RegID, + Identifier: req.Identifier.Value, + Challenge: chall, + } + + defer func() { + probType := "" + outcome := fail + if prob != nil { + // Validation failed. + probType = string(prob.Type) + auditLog.Error = prob.Error() + auditLog.Challenge.Error = prob + auditLog.Challenge.Status = core.StatusInvalid + + } else { + // Validation passed. + outcome = pass + auditLog.Challenge.Status = core.StatusValid + } + // Always observe local latency (primary|remote). + va.observeLatency(challenge, va.perspective, string(chall.Type), probType, outcome, localLatency) + if va.onPrimaryVA() { + // Log the MPIC summary. + auditLog.MPICSummary = summary + + if latency > 0 { + // Observe total latency (primary+remote). + va.observeLatency(challenge, all, string(chall.Type), probType, outcome, va.clk.Since(start)) + } + } + + // No matter what, log the audit log. + auditLog.Latency = va.clk.Since(start).Round(time.Millisecond).Seconds() + va.log.AuditObject("Challenge validation result", auditLog) + }() + + // Perform local validation. + records, localErr := va.validateChallenge(ctx, identifier, chall.Type, chall.Token, req.KeyAuthorization) + + // Stop the clock for local validation latency (this may be remote). + localLatency = va.clk.Since(start) + + // Log the validation records, even if validation failed. + auditLog.Challenge.ValidationRecord = records + + // The following checks are in a specific order to ensure that the most + // pertinent problems are returned first. + + if localErr == nil && !auditLog.Challenge.RecordsSane() { + // Validation was successful, but the records failed sanity check. + localErr = errors.New("records from local validation failed sanity check") + } + + if localErr != nil { + // Validation failed locally. + auditLog.InternalError = localErr.Error() + prob = detailedError(localErr) + return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob), va.perspective, va.rir) + } + + if va.onPrimaryVA() { + // Perform remote validation. + summary, prob = va.remoteValidateChallenge(ctx, req) + + // Stop the clock for total validation latency. + latency = va.clk.Since(start) + } + return bgrpc.ValidationResultToPB(records, filterProblemDetails(prob), va.perspective, va.rir) +}