3131import errno
3232import base64
3333import random
34+ import secrets
3435import urllib
3536import urllib .request , urllib .error
3637import html .entities
@@ -663,6 +664,134 @@ def mkletter(c):
663664 return "%c%c" % tuple (map (mkletter , (chr1 , chr2 )))
664665
665666
667+
668+ # Password hashing functions for secure password storage
669+ # Format: $pbkdf2$<iterations>$<salt>$<hash>
670+ # Old format (SHA1): 40 hex characters, no prefix
671+
672+ # PBKDF2 iterations - should be high enough to be secure but not too slow
673+ PBKDF2_ITERATIONS = 100000
674+ PBKDF2_SALT_LENGTH = 16 # 128 bits
675+
676+ def hash_password (password ):
677+ """Hash a password using PBKDF2-SHA256.
678+
679+ Returns a string in the format: $pbkdf2$<iterations>$<salt>$<hash>
680+ where salt and hash are base64-encoded.
681+
682+ Args:
683+ password: The password to hash (str or bytes)
684+
685+ Returns:
686+ str: The hashed password with format prefix
687+ """
688+ if isinstance (password , str ):
689+ password = password .encode ('utf-8' )
690+
691+ # Generate a random salt
692+ salt = secrets .token_bytes (PBKDF2_SALT_LENGTH )
693+
694+ # Hash using PBKDF2-SHA256
695+ # hashlib.pbkdf2_hmac is available in Python 3.4+
696+ dk = hashlib .pbkdf2_hmac ('sha256' , password , salt , PBKDF2_ITERATIONS )
697+
698+ # Encode salt and hash as base64
699+ salt_b64 = base64 .b64encode (salt ).decode ('ascii' )
700+ hash_b64 = base64 .b64encode (dk ).decode ('ascii' )
701+
702+ return f'$pbkdf2${ PBKDF2_ITERATIONS } ${ salt_b64 } ${ hash_b64 } '
703+
704+
705+ def verify_password (password , stored_hash ):
706+ """Verify a password against a stored hash.
707+
708+ Supports both old SHA1 format (40 hex chars) and new PBKDF2 format.
709+
710+ Args:
711+ password: The password to verify (str or bytes)
712+ stored_hash: The stored hash to verify against (str)
713+
714+ Returns:
715+ tuple: (bool, bool) - (is_valid, needs_upgrade)
716+ is_valid: True if password matches
717+ needs_upgrade: True if password is valid but in old format
718+ """
719+ if isinstance (password , str ):
720+ password = password .encode ('utf-8' )
721+
722+ # Check if it's the new format (starts with $pbkdf2$)
723+ if stored_hash .startswith ('$pbkdf2$' ):
724+ return _verify_pbkdf2 (password , stored_hash ), False
725+
726+ # Old format: SHA1 hexdigest (40 hex characters)
727+ # Check if it looks like a SHA1 hash (40 hex chars)
728+ if len (stored_hash ) == 40 and all (c in '0123456789abcdef' for c in stored_hash .lower ()):
729+ sha1_hash = sha_new (password ).hexdigest ()
730+ if sha1_hash == stored_hash :
731+ return True , True # Valid but needs upgrade
732+ return False , False
733+
734+ # Fallback: try SHA1 comparison for backwards compatibility
735+ sha1_hash = sha_new (password ).hexdigest ()
736+ if sha1_hash == stored_hash :
737+ return True , True # Valid but needs upgrade
738+ return False , False
739+
740+
741+ def _verify_pbkdf2 (password , stored_hash ):
742+ """Verify a password against a PBKDF2 hash.
743+
744+ Args:
745+ password: The password to verify (bytes)
746+ stored_hash: The stored hash in format $pbkdf2$<iterations>$<salt>$<hash>
747+
748+ Returns:
749+ bool: True if password matches
750+ """
751+ try :
752+ # Parse the hash format: $pbkdf2$<iterations>$<salt>$<hash>
753+ parts = stored_hash .split ('$' )
754+ if len (parts ) != 5 or parts [1 ] != 'pbkdf2' :
755+ return False
756+
757+ iterations = int (parts [2 ])
758+ salt_b64 = parts [3 ]
759+ hash_b64 = parts [4 ]
760+
761+ # Decode salt and hash
762+ salt = base64 .b64decode (salt_b64 )
763+ stored_dk = base64 .b64decode (hash_b64 )
764+
765+ # Compute hash with same parameters
766+ dk = hashlib .pbkdf2_hmac ('sha256' , password , salt , iterations )
767+
768+ # Constant-time comparison to prevent timing attacks
769+ return secrets .compare_digest (dk , stored_dk )
770+ except (ValueError , TypeError , IndexError ):
771+ return False
772+
773+
774+ def is_old_password_format (stored_hash ):
775+ """Check if a stored hash is in the old SHA1 format.
776+
777+ Args:
778+ stored_hash: The stored hash to check (str)
779+
780+ Returns:
781+ bool: True if the hash is in old format (needs upgrade)
782+ """
783+ # New format starts with $pbkdf2$
784+ if stored_hash .startswith ('$pbkdf2$' ):
785+ return False
786+
787+ # Old format: 40 hex characters
788+ if len (stored_hash ) == 40 and all (c in '0123456789abcdef' for c in stored_hash .lower ()):
789+ return True
790+
791+ # If it doesn't match either format, assume old for backwards compatibility
792+ return True
793+
794+
666795
667796def set_global_password (pw , siteadmin = True ):
668797 if siteadmin :
@@ -673,10 +802,8 @@ def set_global_password(pw, siteadmin=True):
673802 omask = os .umask (0o026 )
674803 try :
675804 fp = open (filename , 'w' )
676- if isinstance (pw , bytes ):
677- fp .write (sha_new (pw ).hexdigest () + '\n ' )
678- else :
679- fp .write (sha_new (pw .encode ()).hexdigest () + '\n ' )
805+ # Use new PBKDF2 hashing for all new passwords
806+ fp .write (hash_password (pw ) + '\n ' )
680807 fp .close ()
681808 finally :
682809 os .umask (omask )
@@ -702,10 +829,11 @@ def check_global_password(response, siteadmin=True):
702829 challenge = get_global_password (siteadmin )
703830 if challenge is None :
704831 return None
705- if isinstance (response , bytes ):
706- return challenge == sha_new (response ).hexdigest ()
707- else :
708- return challenge == sha_new (response .encode ()).hexdigest ()
832+ # Use verify_password which handles both old SHA1 and new PBKDF2 formats
833+ is_valid , needs_upgrade = verify_password (response , challenge )
834+ # Note: We don't auto-upgrade global passwords here since they're in files
835+ # and we'd need to write back to the file. This could be added if needed.
836+ return is_valid
709837
710838
711839
0 commit comments