From c005fc6187fb05684df973dee8cbaa241ae248a9 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Wed, 29 Apr 2026 13:14:30 +0530 Subject: [PATCH 1/4] Updated master password grading to use zxcvbn --- keepercommander/commands/utils.py | 6 +++--- keepercommander/utils.py | 35 +++++++++++++++++++++++++++++++ requirements.txt | 1 + unit-tests/test_crypto.py | 8 +++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index c35f5dc07..d98cfb1a5 100644 --- a/keepercommander/commands/utils.py +++ b/keepercommander/commands/utils.py @@ -47,7 +47,7 @@ from ..params import KeeperParams, LAST_RECORD_UID, LAST_FOLDER_UID, LAST_SHARED_FOLDER_UID from ..proto import ssocloud_pb2, enterprise_pb2, APIRequest_pb2 from ..security_audit import needs_security_audit, update_security_audit_data -from ..utils import password_score +from ..utils import password_score, master_password_score from ..vault import KeeperRecord from ..versioning import is_binary_app, is_up_to_date_version @@ -2328,8 +2328,8 @@ def execute(self, params, **kwargs): if euids: params.breach_watch.delete_euids(params, euids) else: - score = utils.password_score(new_password) - logging.info('Password strength: %s', 'WEAK' if score < 40 else 'FAIR' if score < 60 else 'MEDIUM' if score < 80 else 'STRONG') + score = utils.master_password_score(new_password) + logging.info('Password strength: %s', 'WEAK' if score <= 25 else 'FAIR' if score == 50 else 'MEDIUM' if score == 75 else 'STRONG') iterations = current_salt.iterations if current_salt else constants.PBKDF2_ITERATIONS iterations = max(iterations, constants.PBKDF2_ITERATIONS) diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 384ab4926..02a27daa5 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -27,6 +27,7 @@ from . import crypto from .constants import EMAIL_PATTERN +import zxcvbn as _zxcvbn VALID_URL_SCHEME_CHARS = '+-.:' @@ -429,6 +430,40 @@ def is_pw_strong(pw_score): # type: (int) -> bool return pw_score >= 80 +_MASTER_PASSWORD_SCORE_MAP = {0: 25, 1: 25, 2: 50, 3: 75, 4: 100} + + +def master_password_score(password): # type: (str) -> int + """Return a strength value for a Master Password using zxcvbn estimated guess count. + + Level Strength Value Estimated guesses + 0 & 1 Weak 25 < 10^6 + 2 Fair 50 10^6 – 10^8 + 3 Medium 75 10^8 – 10^10 + 4 Strong 100 > 10^10 + """ + if not password or not isinstance(password, str): + return 0 + result = _zxcvbn.zxcvbn(password) + return _MASTER_PASSWORD_SCORE_MAP[result['score']] + + +def is_master_pw_weak(pw_score): # type: (int) -> bool + return pw_score <= 25 + + +def is_master_pw_fair(pw_score): # type: (int) -> bool + return pw_score == 50 + + +def is_master_pw_medium(pw_score): # type: (int) -> bool + return pw_score == 75 + + +def is_master_pw_strong(pw_score): # type: (int) -> bool + return pw_score == 100 + + def is_rec_at_risk(bw_result): # type (int) -> bool return bw_result in (2, 3) diff --git a/requirements.txt b/requirements.txt index 156d2f51f..fd16d5654 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ asciitree +zxcvbn bcrypt colorama prompt_toolkit diff --git a/unit-tests/test_crypto.py b/unit-tests/test_crypto.py index b02c754fd..21750ce92 100644 --- a/unit-tests/test_crypto.py +++ b/unit-tests/test_crypto.py @@ -160,6 +160,14 @@ def test_password_score(self): self.assertEqual(utils.password_score('AAAbbbCCC11'), 38) self.assertEqual(utils.password_score('password'), 8) + def test_master_password_score(self): + # zxcvbn-based scoring: returns 25 (Weak), 50 (Fair), 75 (Medium), or 100 (Strong) + self.assertEqual(utils.master_password_score('!@#$%^&*()'), 25) # zxcvbn score 1 -> Weak + self.assertEqual(utils.master_password_score('aZkljfzsnmp4w9058dsqln5yf(&*))(*)(345'), 100) # zxcvbn score 4 -> Strong + self.assertEqual(utils.master_password_score('c3>^sxuKZ[Ndyo(OBE14'), 100) # zxcvbn score 4 -> Strong + self.assertEqual(utils.master_password_score('AAAbbbCCC11'), 75) # zxcvbn score 3 -> Medium + self.assertEqual(utils.master_password_score('password'), 25) # zxcvbn score 0 -> Weak + _test_random_data = \ 'cKGoVph_X0NKjk8jQgxyQWRElUY7IsbbIJaRcJVlnOb7AchFiY-izmTTOlgArwIqAxKDKSRAWx2Q1pX' \ From 5ef390a317432665640c9a58d41c3eb4aaad1d6f Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Wed, 29 Apr 2026 13:30:08 +0530 Subject: [PATCH 2/4] Added zxcvbn to install_requires --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 732d48d9b..5612a0100 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,6 +27,7 @@ include_package_data = True install_requires = asciitree bcrypt + zxcvbn colorama cryptography>=41.0.0 fido2>=2.0.0; python_version>='3.10' From 9d72d75050877e10e36983d1a3ae218ded1a5376 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Thu, 30 Apr 2026 12:13:02 +0530 Subject: [PATCH 3/4] Added zxcvbn-based master password strength grading alongside BreachWatch score. --- keepercommander/commands/utils.py | 6 +++--- keepercommander/utils.py | 8 -------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index d98cfb1a5..d0e36830c 100644 --- a/keepercommander/commands/utils.py +++ b/keepercommander/commands/utils.py @@ -2319,6 +2319,9 @@ def execute(self, params, **kwargs): logging.warning('Password rules:\n%s', '\n'.join((f' {x}' for x in failed_rules))) return + score = utils.master_password_score(new_password) + logging.info('Password strength: %s', 'WEAK' if score <= 25 else 'FAIR' if score == 50 else 'MEDIUM' if score == 75 else 'STRONG') + if params.breach_watch: euids = [] for result in params.breach_watch.scan_passwords(params, [new_password]): @@ -2327,9 +2330,6 @@ def execute(self, params, **kwargs): logging.info('Breachwatch password scan result: %s', 'WEAK' if result[1].breachDetected else 'GOOD') if euids: params.breach_watch.delete_euids(params, euids) - else: - score = utils.master_password_score(new_password) - logging.info('Password strength: %s', 'WEAK' if score <= 25 else 'FAIR' if score == 50 else 'MEDIUM' if score == 75 else 'STRONG') iterations = current_salt.iterations if current_salt else constants.PBKDF2_ITERATIONS iterations = max(iterations, constants.PBKDF2_ITERATIONS) diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 02a27daa5..94c9b51dd 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -434,14 +434,6 @@ def is_pw_strong(pw_score): # type: (int) -> bool def master_password_score(password): # type: (str) -> int - """Return a strength value for a Master Password using zxcvbn estimated guess count. - - Level Strength Value Estimated guesses - 0 & 1 Weak 25 < 10^6 - 2 Fair 50 10^6 – 10^8 - 3 Medium 75 10^8 – 10^10 - 4 Strong 100 > 10^10 - """ if not password or not isinstance(password, str): return 0 result = _zxcvbn.zxcvbn(password) From 0b4d0fc7744b2da875a94c533e0ad842a4ed0854 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Mon, 4 May 2026 17:40:21 +0530 Subject: [PATCH 4/4] Addressed PR review comments on master_password_score --- keepercommander/utils.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 94c9b51dd..3def9f50d 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -436,24 +436,12 @@ def is_pw_strong(pw_score): # type: (int) -> bool def master_password_score(password): # type: (str) -> int if not password or not isinstance(password, str): return 0 - result = _zxcvbn.zxcvbn(password) - return _MASTER_PASSWORD_SCORE_MAP[result['score']] - - -def is_master_pw_weak(pw_score): # type: (int) -> bool - return pw_score <= 25 - - -def is_master_pw_fair(pw_score): # type: (int) -> bool - return pw_score == 50 - - -def is_master_pw_medium(pw_score): # type: (int) -> bool - return pw_score == 75 - - -def is_master_pw_strong(pw_score): # type: (int) -> bool - return pw_score == 100 + try: + result = _zxcvbn.zxcvbn(password) + return _MASTER_PASSWORD_SCORE_MAP.get(result.get('score'), 25) + except Exception as e: + logging.debug('zxcvbn scoring failed: %s', e) + return 25 def is_rec_at_risk(bw_result): # type (int) -> bool