Skip to content

gh-136134: smtplib: fix CRAM-MD5 on FIPS-only environments #136623

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
27 changes: 22 additions & 5 deletions Lib/smtplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import email.utils
import email.message
import email.generator
import functools
import base64
import hmac
import copy
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
38 changes: 33 additions & 5 deletions Lib/test/test_smtplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading