Source code for duck.security.passwords
"""
Password strength validation utilities.
This module provides reusable validators for checking password length,
numeric-only passwords, common passwords, and user-attribute similarity.
"""
from __future__ import annotations
import gzip
import difflib
from pathlib import Path
from typing import Iterable, Sequence
from duck.storage import duck_storage
_COMMON_PASSWORDS_CACHE: set[str] | None = None
_COMMON_PASSWORDS_PATH = Path(duck_storage) / "etc/others/common-passwords.txt.gz"
[docs]
class PasswordValidationError(ValueError):
"""
Raised when a password fails one or more strength checks.
"""
def __init__(self, messages: Sequence[str]) -> None:
super().__init__(f"Password validation failed: {messages[0]}")
self.messages = list(messages)
[docs]
def load_common_passwords(path: str | Path) -> set[str]:
"""
Load common passwords from a plain text or gzip-compressed file.
Args:
path: Path to a `.txt` or `.txt.gz` password list.
Returns:
A normalized set of lowercase passwords.
"""
password_path = Path(path)
if password_path.suffix == ".gz":
opener = gzip.open
else:
opener = open
with opener(password_path, "rt", encoding="utf-8") as file:
return {
line.strip().lower()
for line in file
if line.strip()
}
[docs]
def get_common_passwords(path: str | Path) -> set[str]:
"""
Load and cache common passwords.
Args:
path: Path to a common-password list.
Returns:
Cached set of common passwords.
"""
global _COMMON_PASSWORDS_CACHE
if _COMMON_PASSWORDS_CACHE is None:
_COMMON_PASSWORDS_CACHE = load_common_passwords(path)
return _COMMON_PASSWORDS_CACHE
[docs]
def is_too_similar(
password: str,
user_attributes: Iterable[str],
*,
max_similarity: float = 0.7,
) -> bool:
"""
Check whether a password is too similar to user attributes.
Args:
password: Raw password.
user_attributes: User-related values like username, email, or name.
max_similarity: Maximum allowed similarity ratio.
Returns:
True if the password is too similar, otherwise False.
"""
normalized_password = password.lower()
for value in user_attributes:
value = str(value).strip().lower()
if not value:
continue
# Compute similarity ratio
similarity = difflib.SequenceMatcher(
a=normalized_password,
b=value,
).quick_ratio()
# Check if similarity is greater than max_similarity
if similarity >= max_similarity:
return True
return False
[docs]
def validate_password_strength(
password: str,
*,
user_attributes: Iterable[str] = (),
common_passwords_path: str | Path | None = _COMMON_PASSWORDS_PATH,
min_length: int = 8,
max_similarity: float = 0.7,
) -> None:
"""
Validate password strength.
Args:
password: Raw password to validate.
user_attributes: Optional user-related values to compare against.
common_passwords_path: Optional path to common-passwords `.txt` or `.txt.gz`.
min_length: Minimum allowed password length.
max_similarity: Maximum allowed similarity to user attributes.
Raises:
TypeError: If password is not a string.
PasswordValidationError: If password fails validation.
"""
errors: list[str] = []
if not isinstance(password, str):
raise TypeError("Password must be a string.")
if len(password) < min_length:
errors.append(f"Password must contain at least {min_length} characters.")
if password.isdigit():
errors.append("Password cannot be entirely numeric.")
if common_passwords_path is not None:
common_passwords = get_common_passwords(common_passwords_path)
if password.lower() in common_passwords:
errors.append("Password is too common.")
if is_too_similar(password, user_attributes, max_similarity=max_similarity):
errors.append("Password is too similar to your personal information.")
if errors:
raise PasswordValidationError(errors)