diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index c35f5dc07..d0e36830c 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 @@ -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.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') 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..3def9f50d 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,20 @@ 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 + if not password or not isinstance(password, str): + return 0 + 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 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/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' 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' \