|
42 | 42 | from urllib.parse import urlparse, parse_qs |
43 | 43 | import tempfile |
44 | 44 | import io |
| 45 | +import binascii |
| 46 | +import hmac |
45 | 47 | from email.parser import BytesParser |
46 | 48 | from email.policy import HTTP |
47 | 49 |
|
@@ -698,6 +700,84 @@ def get_global_password(siteadmin=True): |
698 | 700 | return challenge |
699 | 701 |
|
700 | 702 |
|
| 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 | + |
701 | 781 | def check_global_password(response, siteadmin=True, auto_upgrade=False): |
702 | 782 | """Check a global password and optionally upgrade it if in old format. |
703 | 783 | |
|
0 commit comments