55import re
66import secrets
77
8+ try :
9+ import argon2 .exceptions as argon2_exceptions
10+ from argon2 import PasswordHasher
11+
12+ _PASSWORD_HASHER = PasswordHasher ()
13+ except ImportError :
14+ _PASSWORD_HASHER = None
15+
816_PBKDF2_ITERATIONS = 600_000
917_PBKDF2_SALT_BYTES = 16
1018_PBKDF2_ALGORITHM = "pbkdf2_sha256"
1523
1624
1725def hash_dashboard_password (raw_password : str ) -> str :
18- """Return a salted hash for dashboard password using PBKDF2-HMAC-SHA256."""
26+ """Return a salted hash for dashboard password using Argon2 (if available) or PBKDF2-HMAC-SHA256 fallback ."""
1927 if not isinstance (raw_password , str ) or raw_password == "" :
2028 raise ValueError ("Password cannot be empty" )
2129
30+ if _PASSWORD_HASHER is not None :
31+ try :
32+ return _PASSWORD_HASHER .hash (raw_password )
33+ except Exception as e :
34+ raise ValueError (f"Failed to hash password securely (argon2): { e !s} " )
35+
2236 salt = secrets .token_hex (_PBKDF2_SALT_BYTES )
2337 digest = hashlib .pbkdf2_hmac (
2438 "sha256" ,
@@ -65,21 +79,24 @@ def _is_pbkdf2_hash(stored: str) -> bool:
6579 return isinstance (stored , str ) and stored .startswith (_PBKDF2_FORMAT )
6680
6781
82+ def _is_argon2_hash (stored : str ) -> bool :
83+ return isinstance (stored , str ) and stored .startswith ("$argon2" )
84+
85+
6886def verify_dashboard_password (stored_hash : str , candidate_password : str ) -> bool :
69- """Verify password against legacy md5 or new PBKDF2-SHA256 format."""
87+ """Verify password against legacy md5, new PBKDF2-SHA256 format, or Argon2 ."""
7088 if not isinstance (stored_hash , str ) or not isinstance (candidate_password , str ):
7189 return False
7290
73- if _is_legacy_md5_hash (stored_hash ):
74- # Keep compatibility with existing md5-based deployments:
75- # new clients send plain password, old clients may send md5 of it.
76- candidate_md5 = hashlib .md5 (candidate_password .encode ("utf-8" )).hexdigest ()
77- return hmac .compare_digest (
78- stored_hash .lower (), candidate_md5 .lower ()
79- ) or hmac .compare_digest (
80- stored_hash .lower (),
81- candidate_password .lower (),
82- )
91+ if _is_argon2_hash (stored_hash ):
92+ if _PASSWORD_HASHER is None :
93+ return False
94+ try :
95+ return _PASSWORD_HASHER .verify (stored_hash , candidate_password )
96+ except argon2_exceptions .VerifyMismatchError :
97+ return False
98+ except Exception :
99+ return False
83100
84101 if _is_pbkdf2_hash (stored_hash ):
85102 parts : list [str ] = stored_hash .split ("$" )
@@ -100,6 +117,28 @@ def verify_dashboard_password(stored_hash: str, candidate_password: str) -> bool
100117 )
101118 return hmac .compare_digest (stored_key , candidate_key )
102119
120+ if _is_legacy_md5_hash (stored_hash ):
121+ # Keep compatibility with existing md5-based deployments:
122+ # new clients send plain password, old clients may send md5 of it.
123+ candidate_md5 = hashlib .md5 (candidate_password .encode ("utf-8" )).hexdigest ()
124+ return hmac .compare_digest (
125+ stored_hash .lower (), candidate_md5 .lower ()
126+ ) or hmac .compare_digest (
127+ stored_hash .lower (),
128+ candidate_password .lower (),
129+ )
130+
131+ # Legacy SHA-256 fallback compatibility (from dev branch)
132+ if len (stored_hash ) == 64 and all (
133+ c in "0123456789abcdefABCDEF" for c in stored_hash
134+ ):
135+ candidate_sha256 = hashlib .sha256 (
136+ candidate_password .encode ("utf-8" )
137+ ).hexdigest ()
138+ return hmac .compare_digest (
139+ stored_hash .lower (), candidate_sha256 .lower ()
140+ ) or hmac .compare_digest (stored_hash .lower (), candidate_password .lower ())
141+
103142 return False
104143
105144
@@ -109,5 +148,13 @@ def is_default_dashboard_password(stored_hash: str) -> bool:
109148
110149
111150def is_legacy_dashboard_password (stored_hash : str ) -> bool :
112- """Check whether the password is still stored with legacy MD5."""
113- return _is_legacy_md5_hash (stored_hash )
151+ """Check whether the password is still stored with legacy MD5 or plain SHA256."""
152+ if not isinstance (stored_hash , str ) or not stored_hash :
153+ return False
154+ if _is_legacy_md5_hash (stored_hash ):
155+ return True
156+ if len (stored_hash ) == 64 and all (
157+ c in "0123456789abcdefABCDEF" for c in stored_hash
158+ ):
159+ return True
160+ return False
0 commit comments