From 7b7bc1f5cf8dfc1ca0ff39ca4f9492e226841fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:32:47 +0200 Subject: [PATCH 1/3] smtplib: fix CRAM-MD5 on FIPS-only environments --- Lib/smtplib.py | 27 ++++++++++--- Lib/test/test_smtplib.py | 38 ++++++++++++++++--- ...-07-13-13-31-22.gh-issue-136134.mh6VjS.rst | 5 +++ 3 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-07-13-13-31-22.gh-issue-136134.mh6VjS.rst diff --git a/Lib/smtplib.py b/Lib/smtplib.py index 84d6d858e7dec1..10999ed8ccb661 100644 --- a/Lib/smtplib.py +++ b/Lib/smtplib.py @@ -45,6 +45,7 @@ import email.utils import email.message import email.generator +import functools import base64 import hmac import copy @@ -177,6 +178,18 @@ def _quote_periods(bindata): def _fix_eols(data): return re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data) + +# CRAM-MD5 may be supported by the server but not by us +# if HMAC-MD5 is not supported. +@functools.cache +def _have_cram_md5_support(): + try: + hmac.new(b'', b'', 'md5').hexdigest() + return True + except ValueError: + return False + + try: import ssl except ImportError: @@ -665,8 +678,11 @@ def auth_cram_md5(self, challenge=None): # CRAM-MD5 does not support initial-response. if challenge is None: return None - return self.user + " " + hmac.HMAC( - self.password.encode('ascii'), challenge, 'md5').hexdigest() + if not _have_cram_md5_support(): + raise SMTPException("CRAM-MD5 is not supported") + password = self.password.encode('ascii') + authcode = hmac.HMAC(password, challenge, 'md5') + return f"{self.user} {authcode.hexdigest()}" def auth_plain(self, challenge=None): """ Authobject to use with PLAIN authentication. Requires self.user and @@ -718,9 +734,10 @@ def login(self, user, password, *, initial_response_ok=True): advertised_authlist = self.esmtp_features["auth"].split() # Authentication methods we can handle in our preferred order: - preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN'] - - # We try the supported authentications in our preferred order, if + if _have_cram_md5_support(): + preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN'] + else: + preferred_auths = ['PLAIN', 'LOGIN'] # the server supports them. authlist = [auth for auth in preferred_auths if auth in advertised_authlist] diff --git a/Lib/test/test_smtplib.py b/Lib/test/test_smtplib.py index 4c9fc14bd43f54..31204b1f95b9f6 100644 --- a/Lib/test/test_smtplib.py +++ b/Lib/test/test_smtplib.py @@ -926,11 +926,15 @@ def _auth_cram_md5(self, arg=None): except ValueError as e: self.push('535 Splitting response {!r} into user and password ' 'failed: {}'.format(logpass, e)) - return False - valid_hashed_pass = hmac.HMAC( - sim_auth[1].encode('ascii'), - self._decode_base64(sim_cram_md5_challenge).encode('ascii'), - 'md5').hexdigest() + return + + pwd = sim_auth[1].encode('ascii') + msg = self._decode_base64(sim_cram_md5_challenge).encode('ascii') + try: + valid_hashed_pass = hmac.HMAC(pwd, msg, 'md5').hexdigest() + except ValueError: + self.push('504 CRAM-MD5 is not supported') + return self._authenticated(user, hashed_pass == valid_hashed_pass) # end AUTH related stuff. @@ -1031,6 +1035,7 @@ def handle_error(self): class SMTPSimTests(unittest.TestCase): def setUp(self): + smtplib._have_cram_md5_support.cache_clear() self.thread_key = threading_helper.threading_setup() self.real_getfqdn = socket.getfqdn socket.getfqdn = mock_socket.getfqdn @@ -1181,6 +1186,29 @@ def testAUTH_CRAM_MD5(self): self.assertEqual(resp, (235, b'Authentication Succeeded')) smtp.close() + @hashlib_helper.block_algorithm('md5') + def testAUTH_CRAM_MD5_blocked(self): + # CRAM-MD5 is the only "known" method by the server, + # but it is not supported by the client. In particular, + # no challenge will ever be sent. + self.serv.add_feature("AUTH CRAM-MD5") + smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', + timeout=support.LOOPBACK_TIMEOUT) + self.addCleanup(smtp.close) + msg = re.escape("No suitable authentication method found.") + with self.assertRaisesRegex(smtplib.SMTPException, msg): + smtp.login(sim_auth[0], sim_auth[1]) + + @hashlib_helper.block_algorithm('md5') + def testAUTH_CRAM_MD5_blocked_and_fallback(self): + # Test that PLAIN is tried after CRAM-MD5 failed + self.serv.add_feature("AUTH CRAM-MD5 PLAIN") + smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', + timeout=support.LOOPBACK_TIMEOUT) + self.addCleanup(smtp.close) + resp = smtp.login(sim_auth[0], sim_auth[1]) + self.assertEqual(resp, (235, b'Authentication Succeeded')) + @hashlib_helper.requires_hashdigest('md5', openssl=True) def testAUTH_multiple(self): # Test that multiple authentication methods are tried. diff --git a/Misc/NEWS.d/next/Library/2025-07-13-13-31-22.gh-issue-136134.mh6VjS.rst b/Misc/NEWS.d/next/Library/2025-07-13-13-31-22.gh-issue-136134.mh6VjS.rst new file mode 100644 index 00000000000000..f0290be9ba1e05 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-13-13-31-22.gh-issue-136134.mh6VjS.rst @@ -0,0 +1,5 @@ +:meth:`!SMTP.auth_cram_md5` now raises an :exc:`~smtplib.SMTPException` +instead of a :exc:`ValueError` if Python has been built without MD5 support. +In particular, :class:`~smtplib.SMTP` clients will not attempt to use this +method even if the remote server is assumed to support it. Patch by Bénédikt +Tran. From e2df647b1d63972cbdfd0e4d895e1ec294816f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 21 Jul 2025 10:34:40 +0200 Subject: [PATCH 2/3] simplify checks --- Lib/smtplib.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Lib/smtplib.py b/Lib/smtplib.py index 10999ed8ccb661..fcfe6e4688656d 100644 --- a/Lib/smtplib.py +++ b/Lib/smtplib.py @@ -179,12 +179,16 @@ def _fix_eols(data): return re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data) -# CRAM-MD5 may be supported by the server but not by us -# if HMAC-MD5 is not supported. +# Use unbounded LRU cache instead of global variable to ease mocking. @functools.cache def _have_cram_md5_support(): + """Check if CRAM-MD5 is supported by the host. + + Note that CRAM-MD5 may be supported by the server + but not by the client if HMAC-MD5 is not supported. + """ try: - hmac.new(b'', b'', 'md5').hexdigest() + hmac.digest(b'', b'', 'md5') return True except ValueError: return False From 49282109ff35e9437cfa597457a51b81957e2491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 21 Jul 2025 10:35:21 +0200 Subject: [PATCH 3/3] smaller diff --- Lib/test/test_smtplib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_smtplib.py b/Lib/test/test_smtplib.py index 31204b1f95b9f6..e1c4141f29ef3e 100644 --- a/Lib/test/test_smtplib.py +++ b/Lib/test/test_smtplib.py @@ -927,7 +927,6 @@ def _auth_cram_md5(self, arg=None): self.push('535 Splitting response {!r} into user and password ' 'failed: {}'.format(logpass, e)) return - pwd = sim_auth[1].encode('ascii') msg = self._decode_base64(sim_cram_md5_challenge).encode('ascii') try: