Skip to content

Commit fc46839

Browse files
committed
Add hash_password and verify_password to Mailman.Utils.
SecurityManager and check_global_password imported these but they were never defined, breaking bin/update. Implement PBKDF2-HMAC-SHA256 ($…) for new hashes and accept legacy 40-char SHA1 hex with needs_upgrade=True.
1 parent d1c5042 commit fc46839

1 file changed

Lines changed: 80 additions & 0 deletions

File tree

Mailman/Utils.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from urllib.parse import urlparse, parse_qs
4343
import tempfile
4444
import io
45+
import binascii
46+
import hmac
4547
from email.parser import BytesParser
4648
from email.policy import HTTP
4749

@@ -698,6 +700,84 @@ def get_global_password(siteadmin=True):
698700
return challenge
699701

700702

703+
# PBKDF2-SHA256 for list admin / moderator / poster passwords (Mailman 2.2+).
704+
# Legacy: 40-char SHA1 hex digest (same as set_global_password / older Mailman).
705+
_PBKDF2_SHA256_PREFIX = '$pbkdf2$sha256$'
706+
_PBKDF2_ITERATIONS = 310000
707+
_PBKDF2_SALT_BYTES = 16
708+
_PBKDF2_DKLEN = 32
709+
710+
711+
def hash_password(plaintext):
712+
"""Hash a plaintext password for storage (PBKDF2-HMAC-SHA256).
713+
714+
Accepts str or bytes; returns an ASCII str starting with ``$pbkdf2$sha256$``.
715+
"""
716+
if isinstance(plaintext, bytes):
717+
pw = plaintext
718+
else:
719+
pw = str(plaintext).encode('utf-8', errors='replace')
720+
salt = os.urandom(_PBKDF2_SALT_BYTES)
721+
dk = hashlib.pbkdf2_hmac(
722+
'sha256', pw, salt, _PBKDF2_ITERATIONS, dklen=_PBKDF2_DKLEN)
723+
return '%s%d$%s$%s' % (
724+
_PBKDF2_SHA256_PREFIX,
725+
_PBKDF2_ITERATIONS,
726+
binascii.hexlify(salt).decode('ascii'),
727+
binascii.hexlify(dk).decode('ascii'))
728+
729+
730+
def verify_password(response, stored):
731+
"""Verify password against stored hash.
732+
733+
``stored`` is either a legacy SHA1 hex digest (40 chars) or PBKDF2 string
734+
from :func:`hash_password`.
735+
736+
``response`` may be str or bytes (UTF-8).
737+
738+
Returns ``(is_valid, needs_upgrade)`` where *needs_upgrade* is True if
739+
the password matched the legacy SHA1 format and should be replaced with
740+
a PBKDF2 hash.
741+
"""
742+
if stored is None:
743+
return False, False
744+
if isinstance(response, str):
745+
response_bytes = response.encode('utf-8', errors='replace')
746+
else:
747+
response_bytes = bytes(response)
748+
if not isinstance(stored, str):
749+
try:
750+
stored = stored.decode('ascii', errors='strict')
751+
except Exception:
752+
return False, False
753+
stored = stored.strip()
754+
if stored.startswith(_PBKDF2_SHA256_PREFIX):
755+
parts = stored.split('$')
756+
# ['', 'pbkdf2', 'sha256', iterations, salt_hex, dk_hex]
757+
if len(parts) != 6:
758+
return False, False
759+
try:
760+
iterations = int(parts[3])
761+
salt = binascii.unhexlify(parts[4])
762+
expected = binascii.unhexlify(parts[5])
763+
except (ValueError, binascii.Error):
764+
return False, False
765+
try:
766+
dk = hashlib.pbkdf2_hmac(
767+
'sha256', response_bytes, salt, iterations, dklen=len(expected))
768+
except Exception:
769+
return False, False
770+
if hmac.compare_digest(dk, expected):
771+
return True, False
772+
return False, False
773+
if len(stored) == 40 and re.match(r'^[0-9a-fA-F]{40}$', stored):
774+
digest = sha_new(response_bytes).hexdigest()
775+
if hmac.compare_digest(digest.lower(), stored.lower()):
776+
return True, True
777+
return False, False
778+
return False, False
779+
780+
701781
def check_global_password(response, siteadmin=True, auto_upgrade=False):
702782
"""Check a global password and optionally upgrade it if in old format.
703783

0 commit comments

Comments
 (0)