From 61f7830e05c752fe8a133356179a4c7984bdaab6 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 2 Oct 2024 08:51:41 -0500 Subject: [PATCH 1/6] Use the 'SecTrustEvaluate' API for macOS 10.12 and earlier Co-authored-by: =?UTF-8?q?Ren=C3=A9=20Dudfield?= --- src/truststore/_macos.py | 181 ++++++++++++++++++++++++++------------- 1 file changed, 120 insertions(+), 61 deletions(-) diff --git a/src/truststore/_macos.py b/src/truststore/_macos.py index b234ffe..2f29db7 100644 --- a/src/truststore/_macos.py +++ b/src/truststore/_macos.py @@ -115,6 +115,18 @@ def _load_cdll(name: str, macos10_16_path: str) -> CDLL: ] Security.SecTrustGetTrustResult.restype = OSStatus + Security.SecTrustEvaluate.argtypes = [ + SecTrustRef, + POINTER(SecTrustResultType), + ] + Security.SecTrustEvaluate.restype = OSStatus + + Security.SecTrustEvaluateWithError.argtypes = [ + SecTrustRef, + POINTER(CFErrorRef), + ] + Security.SecTrustEvaluateWithError.restype = c_bool + Security.SecTrustRef = SecTrustRef # type: ignore[attr-defined] Security.SecTrustResultType = SecTrustResultType # type: ignore[attr-defined] Security.OSStatus = OSStatus # type: ignore[attr-defined] @@ -258,6 +270,7 @@ def _handle_osstatus(result: OSStatus, _: typing.Any, args: typing.Any) -> typin Security.SecTrustSetAnchorCertificates.errcheck = _handle_osstatus # type: ignore[assignment] Security.SecTrustSetAnchorCertificatesOnly.errcheck = _handle_osstatus # type: ignore[assignment] Security.SecTrustGetTrustResult.errcheck = _handle_osstatus # type: ignore[assignment] +Security.SecTrustEvaluate.errcheck = _handle_osstatus # type: ignore[assignment] class CFConst: @@ -365,7 +378,6 @@ def _verify_peercerts_impl( certs = None policies = None trust = None - cf_error = None try: if server_hostname is not None: cf_str_hostname = None @@ -431,69 +443,116 @@ def _verify_peercerts_impl( # We always want system certificates. Security.SecTrustSetAnchorCertificatesOnly(trust, False) - cf_error = CoreFoundation.CFErrorRef() - sec_trust_eval_result = Security.SecTrustEvaluateWithError( - trust, ctypes.byref(cf_error) - ) - # sec_trust_eval_result is a bool (0 or 1) - # where 1 means that the certs are trusted. - if sec_trust_eval_result == 1: - is_trusted = True - elif sec_trust_eval_result == 0: - is_trusted = False + # macOS 10.12 and earlier don't support SecTrustEvaluateWithError() + # so we use SecTrustEvaluate() which means we need to construct error + # messages ourselves. + if 1 or _mac_version_info < (10, 12): + _verify_peercerts_impl_macos_10_12(trust) else: - raise ssl.SSLError( - f"Unknown result from Security.SecTrustEvaluateWithError: {sec_trust_eval_result!r}" - ) - - cf_error_code = 0 - if not is_trusted: - cf_error_code = CoreFoundation.CFErrorGetCode(cf_error) - - # If the error is a known failure that we're - # explicitly okay with from SSLContext configuration - # we can set is_trusted accordingly. - if ssl_context.verify_mode != ssl.CERT_REQUIRED and ( - cf_error_code == CFConst.errSecNotTrusted - or cf_error_code == CFConst.errSecCertificateExpired - ): - is_trusted = True - elif ( - not ssl_context.check_hostname - and cf_error_code == CFConst.errSecHostNameMismatch - ): - is_trusted = True - - # If we're still not trusted then we start to - # construct and raise the SSLCertVerificationError. - if not is_trusted: - cf_error_string_ref = None - try: - cf_error_string_ref = CoreFoundation.CFErrorCopyDescription(cf_error) - - # Can this ever return 'None' if there's a CFError? - cf_error_message = ( - _cf_string_ref_to_str(cf_error_string_ref) - or "Certificate verification failed" - ) - - # TODO: Not sure if we need the SecTrustResultType for anything? - # We only care whether or not it's a success or failure for now. - sec_trust_result_type = Security.SecTrustResultType() - Security.SecTrustGetTrustResult( - trust, ctypes.byref(sec_trust_result_type) - ) - - err = ssl.SSLCertVerificationError(cf_error_message) - err.verify_message = cf_error_message - err.verify_code = cf_error_code - raise err - finally: - if cf_error_string_ref: - CoreFoundation.CFRelease(cf_error_string_ref) - + _verify_peercerts_impl_macos_10_13(ssl_context, trust) finally: if policies: CoreFoundation.CFRelease(policies) if trust: CoreFoundation.CFRelease(trust) + + +def _verify_peercerts_impl_macos_10_12(sec_trust_ref: typing.Any) -> None: + """Verify using 'SecTrustEvaluate' API for macOS 10.12 and earlier. + macOS 10.13 added the 'SecTrustEvaluateWithError' API. + """ + sec_trust_result_type = Security.SecTrustResultType() + Security.SecTrustEvaluate(sec_trust_ref, ctypes.byref(sec_trust_result_type)) + + try: + sec_trust_result_type_as_int = int(sec_trust_result_type.value) + except (ValueError, TypeError): + sec_trust_result_type_as_int = -1 + + # See: https://developer.apple.com/documentation/security/sectrustevaluate(_:_:)?language=objc + if sec_trust_result_type_as_int not in (1, 4): + sec_trust_result_type_to_message = { + 0: "Invalid trust result type", + # 1: "Trust evaluation succeeded", + 2: "Trust was explicitly denied", + 3: "Fatal trust failure occurred", + # 4: "Trust result is unspecified (but trusted)", + 5: "Recoverable trust failure occurred", + 6: "Unknown error occurred", + 7: "User confirmation required", + } + error_message = sec_trust_result_type_to_message.get( + sec_trust_result_type_as_int, + f"Unknown trust result: {sec_trust_result_type_as_int}", + ) + + err = ssl.SSLCertVerificationError(error_message) + err.verify_message = error_message + err.verify_code = sec_trust_result_type_as_int + raise err + + +def _verify_peercerts_impl_macos_10_13( + ssl_context: ssl.SSLContext, sec_trust_ref: typing.Any +) -> None: + """Verify using 'SecTrustEvaluateWithError' API for macOS 10.13+.""" + cf_error = CoreFoundation.CFErrorRef() + sec_trust_eval_result = Security.SecTrustEvaluateWithError( + sec_trust_ref, ctypes.byref(cf_error) + ) + # sec_trust_eval_result is a bool (0 or 1) + # where 1 means that the certs are trusted. + if sec_trust_eval_result == 1: + is_trusted = True + elif sec_trust_eval_result == 0: + is_trusted = False + else: + raise ssl.SSLError( + f"Unknown result from Security.SecTrustEvaluateWithError: {sec_trust_eval_result!r}" + ) + + cf_error_code = 0 + if not is_trusted: + cf_error_code = CoreFoundation.CFErrorGetCode(cf_error) + + # If the error is a known failure that we're + # explicitly okay with from SSLContext configuration + # we can set is_trusted accordingly. + if ssl_context.verify_mode != ssl.CERT_REQUIRED and ( + cf_error_code == CFConst.errSecNotTrusted + or cf_error_code == CFConst.errSecCertificateExpired + ): + is_trusted = True + elif ( + not ssl_context.check_hostname + and cf_error_code == CFConst.errSecHostNameMismatch + ): + is_trusted = True + + # If we're still not trusted then we start to + # construct and raise the SSLCertVerificationError. + if not is_trusted: + cf_error_string_ref = None + try: + cf_error_string_ref = CoreFoundation.CFErrorCopyDescription(cf_error) + + # Can this ever return 'None' if there's a CFError? + cf_error_message = ( + _cf_string_ref_to_str(cf_error_string_ref) + or "Certificate verification failed" + ) + + # TODO: Not sure if we need the SecTrustResultType for anything? + # We only care whether or not it's a success or failure for now. + sec_trust_result_type = Security.SecTrustResultType() + Security.SecTrustGetTrustResult( + sec_trust_ref, ctypes.byref(sec_trust_result_type) + ) + + err = ssl.SSLCertVerificationError(cf_error_message) + err.verify_message = cf_error_message + err.verify_code = cf_error_code + raise err + finally: + if cf_error_string_ref: + CoreFoundation.CFRelease(cf_error_string_ref) From 6a016c9fde04c3dc9f7b047817888b6ab52183a9 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Wed, 2 Oct 2024 09:17:46 -0500 Subject: [PATCH 2/6] Add expected error messages to test cases --- tests/test_api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index 85b3e7d..957fd4b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -49,6 +49,8 @@ class FailureHost: "certificate name does not match", # macOS with revocation checks "certificates do not meet pinning requirements", + # macOS 10.12 + "Recoverable trust failure occurred", # Windows "The certificate's CN name does not match the passed value.", ], @@ -62,6 +64,8 @@ class FailureHost: "“*.badssl.com” certificate is expired", # macOS with revocation checks "certificates do not meet pinning requirements", + # macOS 10.12 + "Recoverable trust failure occurred", # Windows ( "A required certificate is not within its validity period when verifying " @@ -79,6 +83,8 @@ class FailureHost: "“*.badssl.com” certificate is not trusted", # macOS with revocation checks "certificates do not meet pinning requirements", + # macOS 10.12 + "Recoverable trust failure occurred", # Windows ( "A certificate chain processed, but terminated in a root " @@ -96,6 +102,8 @@ class FailureHost: "“BadSSL Untrusted Root Certificate Authority” certificate is not trusted", # macOS with revocation checks "certificates do not meet pinning requirements", + # macOS 10.12 + "Recoverable trust failure occurred", # Windows ( "A certificate chain processed, but terminated in a root " @@ -112,6 +120,8 @@ class FailureHost: "“superfish.badssl.com” certificate is not trusted", # macOS with revocation checks "certificates do not meet pinning requirements", + # macOS 10.12 + "Recoverable trust failure occurred", # Windows ( "A certificate chain processed, but terminated in a root " @@ -134,6 +144,8 @@ class FailureHost: error_messages=[ # macOS "“revoked.badssl.com” certificate is revoked", + # macOS 10.12 + "Unknown error occurred", # Windows "The certificate is revoked.", ], From 08a98a3ee47b9ca343d016746d31def5844441f0 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Fri, 4 Oct 2024 14:35:04 -0500 Subject: [PATCH 3/6] Allow ignoring certificate errors --- src/truststore/_macos.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/truststore/_macos.py b/src/truststore/_macos.py index 2f29db7..120cf0d 100644 --- a/src/truststore/_macos.py +++ b/src/truststore/_macos.py @@ -447,7 +447,7 @@ def _verify_peercerts_impl( # so we use SecTrustEvaluate() which means we need to construct error # messages ourselves. if 1 or _mac_version_info < (10, 12): - _verify_peercerts_impl_macos_10_12(trust) + _verify_peercerts_impl_macos_10_12(ssl_context, trust) else: _verify_peercerts_impl_macos_10_13(ssl_context, trust) finally: @@ -457,7 +457,9 @@ def _verify_peercerts_impl( CoreFoundation.CFRelease(trust) -def _verify_peercerts_impl_macos_10_12(sec_trust_ref: typing.Any) -> None: +def _verify_peercerts_impl_macos_10_12( + ssl_context: ssl.SSLContext, sec_trust_ref: typing.Any +) -> None: """Verify using 'SecTrustEvaluate' API for macOS 10.12 and earlier. macOS 10.13 added the 'SecTrustEvaluateWithError' API. """ @@ -470,7 +472,13 @@ def _verify_peercerts_impl_macos_10_12(sec_trust_ref: typing.Any) -> None: sec_trust_result_type_as_int = -1 # See: https://developer.apple.com/documentation/security/sectrustevaluate(_:_:)?language=objc - if sec_trust_result_type_as_int not in (1, 4): + if ( + ssl_context.verify_mode == ssl.CERT_REQUIRED + and sec_trust_result_type_as_int not in (1, 4) + ): + # Note that we're not able to ignore only hostname errors + # for macOS 10.12 and earlier, so check_hostname=False will + # still return an error. sec_trust_result_type_to_message = { 0: "Invalid trust result type", # 1: "Trust evaluation succeeded", From 45aaa05f5a16588633e26311d064773b8c9ad402 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Fri, 4 Oct 2024 15:28:17 -0500 Subject: [PATCH 4/6] Test both configurations --- src/truststore/_macos.py | 20 +++++++++++--------- tests/test_api.py | 30 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/truststore/_macos.py b/src/truststore/_macos.py index 120cf0d..4d2b44b 100644 --- a/src/truststore/_macos.py +++ b/src/truststore/_macos.py @@ -25,6 +25,8 @@ f"Only OS X 10.8 and newer are supported, not {_mac_version_info[0]}.{_mac_version_info[1]}" ) +_is_macos_version_10_14_or_later = _mac_version_info >= (10, 14) + def _load_cdll(name: str, macos10_16_path: str) -> CDLL: """Loads a CDLL by name, falling back to known path on 10.16+""" @@ -443,11 +445,11 @@ def _verify_peercerts_impl( # We always want system certificates. Security.SecTrustSetAnchorCertificatesOnly(trust, False) - # macOS 10.12 and earlier don't support SecTrustEvaluateWithError() + # macOS 10.13 and earlier don't support SecTrustEvaluateWithError() # so we use SecTrustEvaluate() which means we need to construct error # messages ourselves. - if 1 or _mac_version_info < (10, 12): - _verify_peercerts_impl_macos_10_12(ssl_context, trust) + if _is_macos_version_10_14_or_later: + _verify_peercerts_impl_macos_10_14(ssl_context, trust) else: _verify_peercerts_impl_macos_10_13(ssl_context, trust) finally: @@ -457,11 +459,11 @@ def _verify_peercerts_impl( CoreFoundation.CFRelease(trust) -def _verify_peercerts_impl_macos_10_12( +def _verify_peercerts_impl_macos_10_13( ssl_context: ssl.SSLContext, sec_trust_ref: typing.Any ) -> None: - """Verify using 'SecTrustEvaluate' API for macOS 10.12 and earlier. - macOS 10.13 added the 'SecTrustEvaluateWithError' API. + """Verify using 'SecTrustEvaluate' API for macOS 10.13 and earlier. + macOS 10.14 added the 'SecTrustEvaluateWithError' API. """ sec_trust_result_type = Security.SecTrustResultType() Security.SecTrustEvaluate(sec_trust_ref, ctypes.byref(sec_trust_result_type)) @@ -477,7 +479,7 @@ def _verify_peercerts_impl_macos_10_12( and sec_trust_result_type_as_int not in (1, 4) ): # Note that we're not able to ignore only hostname errors - # for macOS 10.12 and earlier, so check_hostname=False will + # for macOS 10.13 and earlier, so check_hostname=False will # still return an error. sec_trust_result_type_to_message = { 0: "Invalid trust result type", @@ -500,10 +502,10 @@ def _verify_peercerts_impl_macos_10_12( raise err -def _verify_peercerts_impl_macos_10_13( +def _verify_peercerts_impl_macos_10_14( ssl_context: ssl.SSLContext, sec_trust_ref: typing.Any ) -> None: - """Verify using 'SecTrustEvaluateWithError' API for macOS 10.13+.""" + """Verify using 'SecTrustEvaluateWithError' API for macOS 10.14+.""" cf_error = CoreFoundation.CFErrorRef() sec_trust_eval_result = Security.SecTrustEvaluateWithError( sec_trust_ref, ctypes.byref(cf_error) diff --git a/tests/test_api.py b/tests/test_api.py index 957fd4b..3879b6a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -49,7 +49,7 @@ class FailureHost: "certificate name does not match", # macOS with revocation checks "certificates do not meet pinning requirements", - # macOS 10.12 + # macOS 10.13 "Recoverable trust failure occurred", # Windows "The certificate's CN name does not match the passed value.", @@ -64,7 +64,7 @@ class FailureHost: "“*.badssl.com” certificate is expired", # macOS with revocation checks "certificates do not meet pinning requirements", - # macOS 10.12 + # macOS 10.13 "Recoverable trust failure occurred", # Windows ( @@ -83,7 +83,7 @@ class FailureHost: "“*.badssl.com” certificate is not trusted", # macOS with revocation checks "certificates do not meet pinning requirements", - # macOS 10.12 + # macOS 10.13 "Recoverable trust failure occurred", # Windows ( @@ -102,7 +102,7 @@ class FailureHost: "“BadSSL Untrusted Root Certificate Authority” certificate is not trusted", # macOS with revocation checks "certificates do not meet pinning requirements", - # macOS 10.12 + # macOS 10.13 "Recoverable trust failure occurred", # Windows ( @@ -120,7 +120,7 @@ class FailureHost: "“superfish.badssl.com” certificate is not trusted", # macOS with revocation checks "certificates do not meet pinning requirements", - # macOS 10.12 + # macOS 10.13 "Recoverable trust failure occurred", # Windows ( @@ -144,7 +144,7 @@ class FailureHost: error_messages=[ # macOS "“revoked.badssl.com” certificate is revoked", - # macOS 10.12 + # macOS 10.13 "Unknown error occurred", # Windows "The certificate is revoked.", @@ -156,6 +156,24 @@ class FailureHost: pytest.mark.parametrize("failure", failure_hosts_list, ids=attrgetter("host")) ) +# Fixture which tests both the SecTrustEvaluate (macOS <=10.13) and +# SecTrustEvaluteWithError (macOS >=10.14) APIs +# on macOS versions that support both APIs. +if platform.system() == "Darwin" and tuple( + map(int, platform.mac_ver()[0].split(".")) +) >= (10, 14): + + @pytest.fixture(autouse=True, params=[True, False]) + def mock_macos_version_10_13(request): + import truststore._macos + + prev = truststore._macos._is_macos_version_10_14_or_later + truststore._macos._is_macos_version_10_14_or_later = request.param + try: + yield + finally: + truststore._macos._is_macos_version_10_14_or_later = prev + @pytest.fixture def trustme_ca(): From 690ffaff62f5b7cb6502dc8c5ca0b6b4c407d539 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Mon, 21 Oct 2024 11:59:52 -0500 Subject: [PATCH 5/6] Fix mapping of SecTrustResultType codes --- src/truststore/_macos.py | 13 +++++++------ tests/test_api.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/truststore/_macos.py b/src/truststore/_macos.py index 4d2b44b..1f91141 100644 --- a/src/truststore/_macos.py +++ b/src/truststore/_macos.py @@ -473,7 +473,8 @@ def _verify_peercerts_impl_macos_10_13( except (ValueError, TypeError): sec_trust_result_type_as_int = -1 - # See: https://developer.apple.com/documentation/security/sectrustevaluate(_:_:)?language=objc + # Apple doesn't document these values in their own API docs. + # See: https://github.com/xybp888/iOS-SDKs/blob/master/iPhoneOS13.0.sdk/System/Library/Frameworks/Security.framework/Headers/SecTrust.h#L84 if ( ssl_context.verify_mode == ssl.CERT_REQUIRED and sec_trust_result_type_as_int not in (1, 4) @@ -484,12 +485,12 @@ def _verify_peercerts_impl_macos_10_13( sec_trust_result_type_to_message = { 0: "Invalid trust result type", # 1: "Trust evaluation succeeded", - 2: "Trust was explicitly denied", - 3: "Fatal trust failure occurred", - # 4: "Trust result is unspecified (but trusted)", + 2: "User confirmation required", + 3: "User specified that certificate is not trusted", + # 4: "Trust result is unspecified", 5: "Recoverable trust failure occurred", - 6: "Unknown error occurred", - 7: "User confirmation required", + 6: "Fatal trust failure occurred", + 7: "Other error occurred, certificate may be revoked", } error_message = sec_trust_result_type_to_message.get( sec_trust_result_type_as_int, diff --git a/tests/test_api.py b/tests/test_api.py index 3879b6a..6664a9a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -145,7 +145,7 @@ class FailureHost: # macOS "“revoked.badssl.com” certificate is revoked", # macOS 10.13 - "Unknown error occurred", + "Other error occurred, certificate may be revoked", # Windows "The certificate is revoked.", ], From d11a003df453ff3522d8528b9a20a1a4de8853d9 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Mon, 21 Oct 2024 13:57:02 -0500 Subject: [PATCH 6/6] Delay ctypes for SecTrustEvaluateWithError --- src/truststore/_macos.py | 21 +++++++++++++-------- tests/test_api.py | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/truststore/_macos.py b/src/truststore/_macos.py index 1f91141..37a7752 100644 --- a/src/truststore/_macos.py +++ b/src/truststore/_macos.py @@ -123,12 +123,6 @@ def _load_cdll(name: str, macos10_16_path: str) -> CDLL: ] Security.SecTrustEvaluate.restype = OSStatus - Security.SecTrustEvaluateWithError.argtypes = [ - SecTrustRef, - POINTER(CFErrorRef), - ] - Security.SecTrustEvaluateWithError.restype = c_bool - Security.SecTrustRef = SecTrustRef # type: ignore[attr-defined] Security.SecTrustResultType = SecTrustResultType # type: ignore[attr-defined] Security.OSStatus = OSStatus # type: ignore[attr-defined] @@ -211,8 +205,19 @@ def _load_cdll(name: str, macos10_16_path: str) -> CDLL: CoreFoundation.CFStringRef = CFStringRef # type: ignore[attr-defined] CoreFoundation.CFErrorRef = CFErrorRef # type: ignore[attr-defined] -except AttributeError: - raise ImportError("Error initializing ctypes") from None +except AttributeError as e: + raise ImportError(f"Error initializing ctypes: {e}") from None + +# SecTrustEvaluateWithError is macOS 10.14+ +if _is_macos_version_10_14_or_later: + try: + Security.SecTrustEvaluateWithError.argtypes = [ + SecTrustRef, + POINTER(CFErrorRef), + ] + Security.SecTrustEvaluateWithError.restype = c_bool + except AttributeError as e: + raise ImportError(f"Error initializing ctypes: {e}") from None def _handle_osstatus(result: OSStatus, _: typing.Any, args: typing.Any) -> typing.Any: diff --git a/tests/test_api.py b/tests/test_api.py index 6664a9a..2686837 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -145,7 +145,7 @@ class FailureHost: # macOS "“revoked.badssl.com” certificate is revoked", # macOS 10.13 - "Other error occurred, certificate may be revoked", + "Fatal trust failure occurred", # Windows "The certificate is revoked.", ], @@ -157,7 +157,7 @@ class FailureHost: ) # Fixture which tests both the SecTrustEvaluate (macOS <=10.13) and -# SecTrustEvaluteWithError (macOS >=10.14) APIs +# SecTrustEvaluateWithError (macOS >=10.14) APIs # on macOS versions that support both APIs. if platform.system() == "Darwin" and tuple( map(int, platform.mac_ver()[0].split("."))