Skip to content

Commit fe2254e

Browse files
authored
PYTHON-5849 Fix OCSP and pyOpenSSL context compatibility with pyOpenSSL 26.2.0 (#2832)
1 parent 93054b0 commit fe2254e

5 files changed

Lines changed: 59 additions & 40 deletions

File tree

pymongo/ocsp_support.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -328,22 +328,15 @@ def _ocsp_callback(conn: Connection, ocsp_bytes: bytes, user_data: Optional[_Cal
328328
"""Callback for use with OpenSSL.SSL.Context.set_ocsp_client_callback."""
329329
# always pass in user_data but OpenSSL requires it be optional
330330
assert user_data
331-
pycert = conn.get_peer_certificate()
332-
if pycert is None:
331+
cert = conn.get_peer_certificate(as_cryptography=True)
332+
if cert is None:
333333
_LOGGER.debug("No peer cert?")
334334
return False
335-
cert = pycert.to_cryptography()
336-
# Use the verified chain when available (pyopenssl>=20.0).
337-
if hasattr(conn, "get_verified_chain"):
338-
pychain = conn.get_verified_chain()
339-
trusted_ca_certs = None
340-
else:
341-
pychain = conn.get_peer_cert_chain()
342-
trusted_ca_certs = user_data.trusted_ca_certs
343-
if not pychain:
335+
chain = conn.get_verified_chain(as_cryptography=True)
336+
trusted_ca_certs = None
337+
if not chain:
344338
_LOGGER.debug("No peer cert chain?")
345339
return False
346-
chain = [cer.to_cryptography() for cer in pychain]
347340
issuer = _get_issuer_cert(cert, chain, trusted_ca_certs)
348341
must_staple = False
349342
# https://tools.ietf.org/html/rfc7633#section-4.2.3.1

pymongo/pyopenssl_context.py

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,16 @@ class SSLContext:
201201
context.
202202
"""
203203

204-
__slots__ = ("_protocol", "_ctx", "_callback_data", "_check_hostname")
204+
__slots__ = ("_protocol", "_ctx", "_callback_data", "_check_hostname", "_options")
205205

206206
def __init__(self, protocol: int):
207207
self._protocol = protocol
208208
self._ctx = _SSL.Context(self._protocol)
209209
self._callback_data = _CallbackData()
210210
self._check_hostname = True
211+
# Cache options to avoid calling set_options() after a Connection is
212+
# created (pyOpenSSL >= 26.2.0 disallows mutations on a used Context).
213+
self._options = self._ctx.set_options(0)
211214
# OCSP
212215
# XXX: Find a better place to do this someday, since this is client
213216
# side configuration and wrap_socket tries to support both client and
@@ -231,24 +234,7 @@ def __get_verify_mode(self) -> VerifyMode:
231234

232235
def __set_verify_mode(self, value: VerifyMode) -> None:
233236
"""Setter for verify_mode."""
234-
235-
def _cb(
236-
_connobj: _SSL.Connection,
237-
_x509obj: _crypto.X509,
238-
_errnum: int,
239-
_errdepth: int,
240-
retcode: int,
241-
) -> bool:
242-
# It seems we don't need to do anything here. Twisted doesn't,
243-
# and OpenSSL's SSL_CTX_set_verify let's you pass NULL
244-
# for the callback option. It's weird that PyOpenSSL requires
245-
# this.
246-
# This is optional in pyopenssl >= 20 and can be removed once minimum
247-
# supported version is bumped
248-
# See: pyopenssl.org/en/latest/changelog.html#id47
249-
return bool(retcode)
250-
251-
self._ctx.set_verify(_VERIFY_MAP[value], _cb)
237+
self._ctx.set_verify(_VERIFY_MAP[value], None)
252238

253239
verify_mode = property(__get_verify_mode, __set_verify_mode)
254240

@@ -271,16 +257,14 @@ def __set_check_ocsp_endpoint(self, value: bool) -> None:
271257
check_ocsp_endpoint = property(__get_check_ocsp_endpoint, __set_check_ocsp_endpoint)
272258

273259
def __get_options(self) -> int:
274-
# Calling set_options adds the option to the existing bitmask and
275-
# returns the new bitmask.
276-
# https://www.pyopenssl.org/en/stable/api/ssl.html#OpenSSL.SSL.Context.set_options
277-
return self._ctx.set_options(0)
260+
return self._options
278261

279262
def __set_options(self, value: int) -> None:
280263
# Explicitly convert to int, since newer CPython versions
281264
# use enum.IntFlag for options. The values are the same
282265
# regardless of implementation.
283-
self._ctx.set_options(int(value))
266+
self._options = int(value)
267+
self._ctx.set_options(self._options)
284268

285269
options = property(__get_options, __set_options)
286270

requirements/ocsp.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# PyOpenSSL 17.0.0 introduced support for OCSP. 17.1.0 introduced
22
# a related feature we need. 17.2.0 fixes a bug
33
# in set_default_verify_paths we should really avoid.
4-
# service_identity 18.1.0 introduced support for IP addr matching.
4+
# service_identity 24.2.0 dropped use of X509.get_extension() which was
5+
# removed in pyOpenSSL 26.2.0.
56
# Fallback to certifi on Windows if we can't load CA certs from the system
67
# store and just use certifi on macOS.
78
# pyopenssl, cryptography, and service_identity must be set in tandem.
@@ -10,4 +11,4 @@ certifi>=2023.7.22;os.name=='nt' or sys_platform=='darwin'
1011
pyopenssl>=26.2.0
1112
requests>=2.23.0,<3.0
1213
cryptography>=42.0.0
13-
service_identity>=23.1.0
14+
service_identity>=24.2.0

test/test_pyopenssl_context.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,19 @@
2222

2323
import ssl
2424
import sys
25-
from unittest.mock import patch
25+
from unittest.mock import MagicMock, patch
2626

2727
sys.path[0:0] = [""]
2828

2929
from test import unittest
3030

3131
try:
3232
from pymongo import pyopenssl_context as _ctx_module
33+
from pymongo.ocsp_support import _ocsp_callback
3334
from pymongo.pyopenssl_context import (
3435
PROTOCOL_SSLv23,
3536
SSLContext,
37+
_CallbackData,
3638
_is_ip_address,
3739
_ragged_eof,
3840
)
@@ -267,5 +269,44 @@ def test_delegates_to_ctx(self):
267269
mock_lvl.assert_called_once_with("/tmp/ca.pem", None)
268270

269271

272+
# ---------------------------------------------------------------------------
273+
# _ocsp_callback — early-exit branches
274+
# ---------------------------------------------------------------------------
275+
276+
277+
class TestOcspCallback(unittest.TestCase):
278+
"""Unit tests for _ocsp_callback using a mocked SSL Connection."""
279+
280+
def _make_callback_data(self):
281+
return _CallbackData()
282+
283+
def _make_conn(self, *, peer_cert, chain):
284+
"""Return a mock Connection whose certificate methods return the given values."""
285+
conn = MagicMock()
286+
conn.get_peer_certificate.return_value = peer_cert
287+
conn.get_verified_chain.return_value = chain
288+
return conn
289+
290+
@unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.")
291+
def test_returns_false_when_peer_cert_is_none(self):
292+
conn = self._make_conn(peer_cert=None, chain=None)
293+
result = _ocsp_callback(conn, b"", self._make_callback_data())
294+
self.assertFalse(result)
295+
conn.get_peer_certificate.assert_called_once_with(as_cryptography=True)
296+
297+
@unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.")
298+
def test_returns_false_when_chain_is_none(self):
299+
conn = self._make_conn(peer_cert=MagicMock(), chain=None)
300+
result = _ocsp_callback(conn, b"", self._make_callback_data())
301+
self.assertFalse(result)
302+
conn.get_verified_chain.assert_called_once_with(as_cryptography=True)
303+
304+
@unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.")
305+
def test_returns_false_when_chain_is_empty(self):
306+
conn = self._make_conn(peer_cert=MagicMock(), chain=[])
307+
result = _ocsp_callback(conn, b"", self._make_callback_data())
308+
self.assertFalse(result)
309+
310+
270311
if __name__ == "__main__":
271312
unittest.main()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)