From e218344b5a0c892de3ba9bf3f2e9b9e64896bb72 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 12:34:36 +0200 Subject: [PATCH] imaplib: fix CRAM-MD5 on FIPS-only environments --- Doc/library/imaplib.rst | 3 + Lib/imaplib.py | 14 ++++- Lib/test/test_imaplib.py | 56 +++++++++---------- ...-07-13-11-20-05.gh-issue-136134.xhh0Kq.rst | 3 + 4 files changed, 44 insertions(+), 32 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-07-13-11-20-05.gh-issue-136134.xhh0Kq.rst diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 9f198aebcb66b0..2a12a0ca8e960b 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -413,6 +413,9 @@ An :class:`IMAP4` instance has the following methods: the password. Will only work if the server ``CAPABILITY`` response includes the phrase ``AUTH=CRAM-MD5``. + .. versionchanged:: next + An :exc:`IMAP4.error` is raised if MD5 support is not available. + .. method:: IMAP4.logout() diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 2c3925958d011b..df5280c0f2b444 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -725,9 +725,17 @@ def login_cram_md5(self, user, password): def _CRAM_MD5_AUTH(self, challenge): """ Authobject to use with CRAM-MD5 authentication. """ import hmac - pwd = (self.password.encode('utf-8') if isinstance(self.password, str) - else self.password) - return self.user + " " + hmac.HMAC(pwd, challenge, 'md5').hexdigest() + + if isinstance(self.password, str): + password = self.password.encode('utf-8') + else: + password = self.password + + try: + authcode = hmac.HMAC(password, challenge, 'md5') + except ValueError: # HMAC-MD5 is not available + raise self.error("CRAM-MD5 authentication is not supported") + return f"{self.user} {authcode.hexdigest()}" def logout(self): diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index a13ee58d650e1b..3507fc83b6a2ae 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -12,8 +12,7 @@ import socket from test.support import verbose, run_with_tz, run_with_locale, cpython_only -from test.support import hashlib_helper -from test.support import threading_helper +from test.support import hashlib_helper, threading_helper import unittest from unittest import mock from datetime import datetime, timezone, timedelta @@ -256,7 +255,20 @@ def cmd_IDLE(self, tag, args): self._send_tagged(tag, 'BAD', 'Expected DONE') -class NewIMAPTestsMixin(): +class AuthHandler_CRAM_MD5(SimpleIMAPHandler): + capabilities = 'LOGINDISABLED AUTH=CRAM-MD5' + def cmd_AUTHENTICATE(self, tag, args): + self._send_textline('+ PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2Uucm' + 'VzdG9uLm1jaS5uZXQ=') + r = yield + if (r == b'dGltIGYxY2E2YmU0NjRiOWVmYT' + b'FjY2E2ZmZkNmNmMmQ5ZjMy\r\n'): + self._send_tagged(tag, 'OK', 'CRAM-MD5 successful') + else: + self._send_tagged(tag, 'NO', 'No access') + + +class NewIMAPTestsMixin: client = None def _setup(self, imap_handler, connect=True): @@ -439,40 +451,26 @@ def cmd_AUTHENTICATE(self, tag, args): @hashlib_helper.requires_hashdigest('md5', openssl=True) def test_login_cram_md5_bytes(self): - class AuthHandler(SimpleIMAPHandler): - capabilities = 'LOGINDISABLED AUTH=CRAM-MD5' - def cmd_AUTHENTICATE(self, tag, args): - self._send_textline('+ PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2Uucm' - 'VzdG9uLm1jaS5uZXQ=') - r = yield - if (r == b'dGltIGYxY2E2YmU0NjRiOWVmYT' - b'FjY2E2ZmZkNmNmMmQ5ZjMy\r\n'): - self._send_tagged(tag, 'OK', 'CRAM-MD5 successful') - else: - self._send_tagged(tag, 'NO', 'No access') - client, _ = self._setup(AuthHandler) - self.assertTrue('AUTH=CRAM-MD5' in client.capabilities) + client, _ = self._setup(AuthHandler_CRAM_MD5) + self.assertIn('AUTH=CRAM-MD5', client.capabilities) ret, _ = client.login_cram_md5("tim", b"tanstaaftanstaaf") self.assertEqual(ret, "OK") @hashlib_helper.requires_hashdigest('md5', openssl=True) def test_login_cram_md5_plain_text(self): - class AuthHandler(SimpleIMAPHandler): - capabilities = 'LOGINDISABLED AUTH=CRAM-MD5' - def cmd_AUTHENTICATE(self, tag, args): - self._send_textline('+ PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2Uucm' - 'VzdG9uLm1jaS5uZXQ=') - r = yield - if (r == b'dGltIGYxY2E2YmU0NjRiOWVmYT' - b'FjY2E2ZmZkNmNmMmQ5ZjMy\r\n'): - self._send_tagged(tag, 'OK', 'CRAM-MD5 successful') - else: - self._send_tagged(tag, 'NO', 'No access') - client, _ = self._setup(AuthHandler) - self.assertTrue('AUTH=CRAM-MD5' in client.capabilities) + client, _ = self._setup(AuthHandler_CRAM_MD5) + self.assertIn('AUTH=CRAM-MD5', client.capabilities) ret, _ = client.login_cram_md5("tim", "tanstaaftanstaaf") self.assertEqual(ret, "OK") + @hashlib_helper.block_algorithm("md5") + def test_login_cram_md5_blocked(self): + client, _ = self._setup(AuthHandler_CRAM_MD5) + self.assertIn('AUTH=CRAM-MD5', client.capabilities) + msg = re.escape("CRAM-MD5 authentication is not supported") + with self.assertRaisesRegex(imaplib.IMAP4.error, msg): + client.login_cram_md5("tim", b"tanstaaftanstaaf") + def test_aborted_authentication(self): class MyServer(SimpleIMAPHandler): def cmd_AUTHENTICATE(self, tag, args): diff --git a/Misc/NEWS.d/next/Library/2025-07-13-11-20-05.gh-issue-136134.xhh0Kq.rst b/Misc/NEWS.d/next/Library/2025-07-13-11-20-05.gh-issue-136134.xhh0Kq.rst new file mode 100644 index 00000000000000..619526ab12bee2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-13-11-20-05.gh-issue-136134.xhh0Kq.rst @@ -0,0 +1,3 @@ +:meth:`IMAP4.login_cram_md5 ` now raises an +:exc:`IMAP4.error ` if CRAM-MD5 authentication is not +supported. Patch by Bénédikt Tran.