Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use the 'SecTrustEvaluate' API for macOS 10.13 and earlier #157

Merged
merged 6 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 130 additions & 61 deletions src/truststore/_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+"""
Expand Down Expand Up @@ -115,6 +117,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]
Expand Down Expand Up @@ -258,6 +272,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:
Expand Down Expand Up @@ -365,7 +380,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
Expand Down Expand Up @@ -431,69 +445,124 @@ 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.13 and earlier don't support SecTrustEvaluateWithError()
# so we use SecTrustEvaluate() which means we need to construct error
# messages ourselves.
if _is_macos_version_10_14_or_later:
_verify_peercerts_impl_macos_10_14(ssl_context, 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_13(
ssl_context: ssl.SSLContext, sec_trust_ref: typing.Any
) -> None:
"""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))

try:
sec_trust_result_type_as_int = int(sec_trust_result_type.value)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good. If there was a link in #156 to here, that would have saved me some time to find this. See my comments there (and maybe close that PR if you have a superseding one).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested your changes on macOS 10.12 by copying the _macos.py from this changeset over the dysfunctional one bundled with pip 24.2, that made pip work again!

except (ValueError, TypeError):
sec_trust_result_type_as_int = -1

# See: https://developer.apple.com/documentation/security/sectrustevaluate(_:_:)?language=objc
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.13 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",
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",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Difficult to find something about these numerical codes.

https://opensource.apple.com/source/Security/Security-55471/sec/Security/SecTrust.h.auto.html says

typedef uint32_t SecTrustResultType;
enum {
    kSecTrustResultInvalid = 0,
    kSecTrustResultProceed = 1,
    kSecTrustResultConfirm SEC_DEPRECATED_ATTRIBUTE = 2,
    kSecTrustResultDeny = 3,
    kSecTrustResultUnspecified = 4,
    kSecTrustResultRecoverableTrustFailure = 5,
    kSecTrustResultFatalTrustFailure = 6,
    kSecTrustResultOtherError = 7
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, it's quite frustrating that these values aren't documented in Apple's own API documentation.

}
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_14(
ssl_context: ssl.SSLContext, sec_trust_ref: typing.Any
) -> None:
"""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)
)
# 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)
30 changes: 30 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class FailureHost:
"certificate name does not match",
# macOS with revocation checks
"certificates do not meet pinning requirements",
# macOS 10.13
"Recoverable trust failure occurred",
# Windows
"The certificate's CN name does not match the passed value.",
],
Expand All @@ -62,6 +64,8 @@ class FailureHost:
"“*.badssl.com” certificate is expired",
# macOS with revocation checks
"certificates do not meet pinning requirements",
# macOS 10.13
"Recoverable trust failure occurred",
# Windows
(
"A required certificate is not within its validity period when verifying "
Expand All @@ -79,6 +83,8 @@ class FailureHost:
"“*.badssl.com” certificate is not trusted",
# macOS with revocation checks
"certificates do not meet pinning requirements",
# macOS 10.13
"Recoverable trust failure occurred",
# Windows
(
"A certificate chain processed, but terminated in a root "
Expand All @@ -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.13
"Recoverable trust failure occurred",
# Windows
(
"A certificate chain processed, but terminated in a root "
Expand All @@ -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.13
"Recoverable trust failure occurred",
# Windows
(
"A certificate chain processed, but terminated in a root "
Expand All @@ -134,6 +144,8 @@ class FailureHost:
error_messages=[
# macOS
"“revoked.badssl.com” certificate is revoked",
# macOS 10.13
"Unknown error occurred",
# Windows
"The certificate is revoked.",
],
Expand All @@ -144,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():
Expand Down
Loading