From 0b5b6ca4de7c9f7a4a5f5eb2a16d8e90b6a868ff Mon Sep 17 00:00:00 2001 From: Dmitry Sharkov Date: Fri, 26 Sep 2025 18:20:39 -0400 Subject: [PATCH 1/2] added a flag allow_lookup_failure to CAA check parameters for BR carveout --- pyproject.toml | 2 +- src/open_mpic_core/__about__.py | 2 +- .../common_domain/check_parameters.py | 2 +- .../mpic_caa_checker/mpic_caa_checker.py | 5 +- .../open_mpic_core/test_mpic_caa_checker.py | 70 ++++++++++++++----- 5 files changed, 59 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 75a2f4d..f916457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ build.targets.wheel.packages = ["src/open_mpic_core"] "./tests/unit/test_util" = "open_mpic_core_test/test_util" # include tests in the wheel to facilitate integration testing in wrapper projects [tool.api] -spec_version = "3.5.0" +spec_version = "3.6.0" spec_repository = "https://github.com/open-mpic/open-mpic-specification" [tool.hatch.envs.default] diff --git a/src/open_mpic_core/__about__.py b/src/open_mpic_core/__about__.py index 0f607a5..7856d12 100644 --- a/src/open_mpic_core/__about__.py +++ b/src/open_mpic_core/__about__.py @@ -1 +1 @@ -__version__ = "6.0.0" +__version__ = "6.1.0" diff --git a/src/open_mpic_core/common_domain/check_parameters.py b/src/open_mpic_core/common_domain/check_parameters.py index 74bd550..103952f 100644 --- a/src/open_mpic_core/common_domain/check_parameters.py +++ b/src/open_mpic_core/common_domain/check_parameters.py @@ -13,7 +13,7 @@ class CaaCheckParameters(BaseModel): certificate_type: CertificateType = CertificateType.TLS_SERVER caa_domains: list[str] | None = None - # contact_info_query: bool | False = False # to better accommodate email/phone based DCV using contact info in CAA + allow_lookup_failure: bool = False # Baseline Requirements have a carve-out for CAA lookup failure; use carefully! class DcvValidationParameters(BaseModel, ABC): diff --git a/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py b/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py index 56cbc42..a0abf36 100644 --- a/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py +++ b/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py @@ -113,7 +113,10 @@ async def check_caa(self, caa_request: CaaCheckRequest) -> CaaCheckResponse: caa_check_response.details.records_seen = None if caa_lookup_error: - pass + # check if allow_lookup_failure is set to True, and allow issuance depending on error + if caa_request.caa_check_parameters and caa_request.caa_check_parameters.allow_lookup_failure: + caa_check_response.check_completed = True + caa_check_response.check_passed = True elif not caa_found: # if domain has no CAA records: valid for issuance caa_check_response.check_completed = True caa_check_response.check_passed = True diff --git a/tests/unit/open_mpic_core/test_mpic_caa_checker.py b/tests/unit/open_mpic_core/test_mpic_caa_checker.py index 43f02a9..14e33cf 100644 --- a/tests/unit/open_mpic_core/test_mpic_caa_checker.py +++ b/tests/unit/open_mpic_core/test_mpic_caa_checker.py @@ -191,6 +191,18 @@ async def check_caa__should_allow_issuance_given_matching_caa_record_found_in_pa caa_response = await caa_checker.check_caa(caa_request) assert caa_response.check_passed is True + async def check_caa__should_allow_issuance_relying_on_default_caa_domains(self, mocker): + caa_checker = TestMpicCaaChecker.create_configured_caa_checker() + resolver = caa_checker.resolver + record_name, expected_domain = "example.com", "example.com." + test_dns_query_answer = MockDnsObjectCreator.create_caa_query_answer(record_name, 0, "issue", "ca2.net", mocker) + self.patch_resolver_to_expect_domain( + mocker, resolver, expected_domain, test_dns_query_answer, dns.resolver.NoAnswer + ) + caa_request = CaaCheckRequest(domain_or_ip_target="example.com") + caa_response = await caa_checker.check_caa(caa_request) + assert caa_response.check_passed is True + async def check_caa__should_disallow_issuance_given_non_matching_caa_record_found(self, mocker): caa_checker = TestMpicCaaChecker.create_configured_caa_checker() resolver = caa_checker.resolver @@ -220,18 +232,7 @@ async def check_caa__should_disallow_smime_issuance_given_non_matching_caa_issue caa_response = await caa_checker.check_caa(caa_request) assert caa_response.check_passed is False - async def check_caa__should_allow_issuance_relying_on_default_caa_domains(self, mocker): - caa_checker = TestMpicCaaChecker.create_configured_caa_checker() - resolver = caa_checker.resolver - record_name, expected_domain = "example.com", "example.com." - test_dns_query_answer = MockDnsObjectCreator.create_caa_query_answer(record_name, 0, "issue", "ca2.net", mocker) - self.patch_resolver_to_expect_domain( - mocker, resolver, expected_domain, test_dns_query_answer, dns.resolver.NoAnswer - ) - caa_request = CaaCheckRequest(domain_or_ip_target="example.com") - caa_response = await caa_checker.check_caa(caa_request) - assert caa_response.check_passed is True - + # noinspection PyUnresolvedReferences @pytest.mark.parametrize("should_complete_check", [True, False]) async def check_caa__should_set_check_completed_true_if_no_errors_encountered_and_false_otherwise( self, should_complete_check, mocker @@ -248,6 +249,27 @@ async def check_caa__should_set_check_completed_true_if_no_errors_encountered_an assert caa_result.check_passed is should_complete_check assert caa_result.check_completed is should_complete_check + @pytest.mark.parametrize("error_type", [dns.resolver.NoNameservers, dns.resolver.LifetimeTimeout]) + async def check_caa__should_allow_issuance_on_lookup_failure_only_when_lookup_failure_is_explicitly_allowed( + self, error_type, mocker + ): + caa_checker = TestMpicCaaChecker.create_configured_caa_checker() + resolver = caa_checker.resolver + if error_type == dns.resolver.NoNameservers: + resolver_error = dns.resolver.NoNameservers( + request=dns.message.make_query("example.com", "CAA", "IN"), errors=[("192.0.0.1", True, 53, "SERVFAIL")] + ) + else: + resolver_error = dns.resolver.LifetimeTimeout( + timeout=10, errors=[("192.0.0.1", True, 53, "The DNS operation timed out after 10.000 seconds")] + ) + self.patch_resolver_with_answer_or_exception(mocker, resolver, resolver_error) + caa_request = self.create_caa_check_request("example.com", ["ca111.com"]) + caa_request.caa_check_parameters.allow_lookup_failure = True + caa_response = await caa_checker.check_caa(caa_request) + assert caa_response.check_passed is True + assert caa_response.check_completed is True + async def check_caa__should_include_timestamp_in_nanos_in_result(self, mocker): caa_checker = TestMpicCaaChecker.create_configured_caa_checker() resolver = caa_checker.resolver @@ -256,25 +278,37 @@ async def check_caa__should_include_timestamp_in_nanos_in_result(self, mocker): caa_response = await caa_checker.check_caa(caa_request) assert caa_response.timestamp_ns is not None - async def check_caa__should_return_failure_response_with_errors_given_error_in_dns_lookup(self, mocker): + async def check_caa__should_return_failure_response_given_error_in_dns_lookup(self, mocker): caa_checker = TestMpicCaaChecker.create_configured_caa_checker() resolver = caa_checker.resolver dns_lookup_error = dns.resolver.NoNameservers( request=dns.message.make_query("example.com", "CAA", "IN"), - errors=[ - ("192.0.2.1", True, 53, dns.exception.Timeout("Timeout resolving example.com")) - ], # List of (server, error) tuples + errors=[("192.0.2.1", True, 53, "SERVFAIL")], # List of (server, error) tuples ) self.patch_resolver_with_answer_or_exception(mocker, resolver, dns_lookup_error) caa_request = self.create_caa_check_request("example.com", ["ca111.com"]) caa_response = await caa_checker.check_caa(caa_request) check_response_details = CaaCheckResponseDetails(caa_record_present=None) # if error, don't know this detail - caa_response.timestamp_ns = None # ignore timestamp for comparison assert caa_response.check_passed is False assert caa_response.check_completed is False assert caa_response.details == check_response_details + + @pytest.mark.parametrize("allow_lookup_failure", [True, False]) + async def check_caa__should_return_errors_in_response_given_error_in_dns_lookup(self, allow_lookup_failure, mocker): + caa_checker = TestMpicCaaChecker.create_configured_caa_checker() + resolver = caa_checker.resolver + dns_lookup_error = dns.resolver.NoNameservers( + request=dns.message.make_query("example.com", "CAA", "IN"), + errors=[("192.0.2.1", True, 53, "SERVFAIL")], # List of (server, error) tuples + ) + self.patch_resolver_with_answer_or_exception(mocker, resolver, dns_lookup_error) + caa_request = self.create_caa_check_request("example.com", ["ca111.com"]) + caa_request.caa_check_parameters.allow_lookup_failure = allow_lookup_failure + caa_response = await caa_checker.check_caa(caa_request) + assert len(caa_response.errors) == 1 + assert "SERVFAIL" in caa_response.errors[0].error_message assert caa_response.errors[0].error_type == ErrorMessages.CAA_LOOKUP_ERROR.key - assert "Timeout resolving example.com" in caa_response.errors[0].error_message + @pytest.mark.parametrize("caa_answer_value, check_passed", [("ca1allowed.org", True), ("ca1notallowed.org", False)]) async def check_caa__should_return_rrset_and_domain_given_domain_with_caa_record_on_success_or_failure( From fab34c9e8bb0acd4a089dd221e8c37432852dc78 Mon Sep 17 00:00:00 2001 From: Dmitry Sharkov Date: Fri, 26 Sep 2025 21:53:43 -0400 Subject: [PATCH 2/2] restricted allow_lookup_failure to two errors: NoNameservers and LifetimeTimeout --- .../mpic_caa_checker/mpic_caa_checker.py | 25 ++++++++++--------- .../open_mpic_core/test_mpic_caa_checker.py | 20 +++++++++------ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py b/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py index a0abf36..58c2971 100644 --- a/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py +++ b/src/open_mpic_core/mpic_caa_checker/mpic_caa_checker.py @@ -58,11 +58,7 @@ async def find_caa_records_and_domain(self, caa_request) -> tuple[RRset, Name]: break except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): domain = domain.parent() - except Exception as e: - self.logger.error( - f"Exception during CAA lookup for {caa_request.domain_or_ip_target}: {e}. Trace ID: {caa_request.trace_identifier}" - ) - raise MpicCaaLookupException(f"{e}") from e + # will raise other exceptions that we want to catch in the calling function return rrset, domain @@ -84,7 +80,8 @@ async def check_caa(self, caa_request: CaaCheckRequest) -> CaaCheckResponse: if caa_request.domain_or_ip_target.startswith("*."): is_wc_domain = True - caa_lookup_error = False + error_encountered = False + caa_lookup_error = None caa_found = False domain = None rrset = None @@ -105,18 +102,22 @@ async def check_caa(self, caa_request: CaaCheckRequest) -> CaaCheckResponse: async with self.logger.trace_timing(f"CAA lookup for target {caa_request.domain_or_ip_target}"): rrset, domain = await self.find_caa_records_and_domain(caa_request) caa_found = rrset is not None - except (MpicCaaLookupException, ValueError) as e: - caa_lookup_error = True + except Exception as e: + error_encountered = True + caa_lookup_error = e error_message = f"Error during CAA lookup for {caa_request.domain_or_ip_target}: {e}. Trace ID: {caa_request.trace_identifier}" + self.logger.error(error_message) caa_check_response.errors = [MpicValidationError.create(ErrorMessages.CAA_LOOKUP_ERROR, error_message)] caa_check_response.details.found_at = None caa_check_response.details.records_seen = None - if caa_lookup_error: + if error_encountered: # if there was an error during lookup # check if allow_lookup_failure is set to True, and allow issuance depending on error - if caa_request.caa_check_parameters and caa_request.caa_check_parameters.allow_lookup_failure: - caa_check_response.check_completed = True - caa_check_response.check_passed = True + if isinstance(caa_lookup_error, (dns.resolver.LifetimeTimeout, dns.resolver.NoNameservers)): + if caa_request.caa_check_parameters and caa_request.caa_check_parameters.allow_lookup_failure: + # if the error was from the lookup process itself (e.g. timeout), allow issuance + caa_check_response.check_completed = True + caa_check_response.check_passed = True elif not caa_found: # if domain has no CAA records: valid for issuance caa_check_response.check_completed = True caa_check_response.check_passed = True diff --git a/tests/unit/open_mpic_core/test_mpic_caa_checker.py b/tests/unit/open_mpic_core/test_mpic_caa_checker.py index 14e33cf..de5cfea 100644 --- a/tests/unit/open_mpic_core/test_mpic_caa_checker.py +++ b/tests/unit/open_mpic_core/test_mpic_caa_checker.py @@ -249,9 +249,14 @@ async def check_caa__should_set_check_completed_true_if_no_errors_encountered_an assert caa_result.check_passed is should_complete_check assert caa_result.check_completed is should_complete_check - @pytest.mark.parametrize("error_type", [dns.resolver.NoNameservers, dns.resolver.LifetimeTimeout]) - async def check_caa__should_allow_issuance_on_lookup_failure_only_when_lookup_failure_is_explicitly_allowed( - self, error_type, mocker + # fmt: off + @pytest.mark.parametrize("error_type, allow_failure", [ + (dns.resolver.NoNameservers, True), + (dns.resolver.LifetimeTimeout, True), + (dns.resolver.YXDOMAIN, False), + ]) + async def check_caa__should_allow_issuance_on_certain_lookup_failures_when_lookup_failure_is_explicitly_allowed( + self, error_type, allow_failure, mocker ): caa_checker = TestMpicCaaChecker.create_configured_caa_checker() resolver = caa_checker.resolver @@ -259,16 +264,18 @@ async def check_caa__should_allow_issuance_on_lookup_failure_only_when_lookup_fa resolver_error = dns.resolver.NoNameservers( request=dns.message.make_query("example.com", "CAA", "IN"), errors=[("192.0.0.1", True, 53, "SERVFAIL")] ) - else: + elif error_type == dns.resolver.LifetimeTimeout: resolver_error = dns.resolver.LifetimeTimeout( timeout=10, errors=[("192.0.0.1", True, 53, "The DNS operation timed out after 10.000 seconds")] ) + else: + resolver_error = dns.resolver.YXDOMAIN() self.patch_resolver_with_answer_or_exception(mocker, resolver, resolver_error) caa_request = self.create_caa_check_request("example.com", ["ca111.com"]) caa_request.caa_check_parameters.allow_lookup_failure = True caa_response = await caa_checker.check_caa(caa_request) - assert caa_response.check_passed is True - assert caa_response.check_completed is True + assert caa_response.check_passed is allow_failure + assert caa_response.check_completed is allow_failure async def check_caa__should_include_timestamp_in_nanos_in_result(self, mocker): caa_checker = TestMpicCaaChecker.create_configured_caa_checker() @@ -309,7 +316,6 @@ async def check_caa__should_return_errors_in_response_given_error_in_dns_lookup( assert "SERVFAIL" in caa_response.errors[0].error_message assert caa_response.errors[0].error_type == ErrorMessages.CAA_LOOKUP_ERROR.key - @pytest.mark.parametrize("caa_answer_value, check_passed", [("ca1allowed.org", True), ("ca1notallowed.org", False)]) async def check_caa__should_return_rrset_and_domain_given_domain_with_caa_record_on_success_or_failure( self, caa_answer_value, check_passed, mocker