from __future__ import annotations import base64 import hashlib import hmac import secrets from dataclasses import dataclass class PasswordPolicyError(ValueError): pass @dataclass(frozen=True, slots=True) class PasswordHasher: work_factor: int = 2**15 block_size: int = 8 parallelism: int = 1 salt_bytes: int = 16 key_bytes: int = 32 def validate(self, password: str) -> None: if not 8 <= len(password) <= 128: raise PasswordPolicyError("密码长度应为8至128位。") categories = ( any(character.isalpha() for character in password), any(character.isdigit() for character in password), any(not character.isalnum() for character in password), ) if sum(categories) < 2: raise PasswordPolicyError("密码至少包含字母、数字、符号中的两类。") def hash(self, password: str) -> str: self.validate(password) salt = secrets.token_bytes(self.salt_bytes) digest = hashlib.scrypt( password.encode("utf-8"), salt=salt, n=self.work_factor, r=self.block_size, p=self.parallelism, dklen=self.key_bytes, maxmem=64 * 1024 * 1024, ) encoded_salt = base64.urlsafe_b64encode(salt).decode("ascii") encoded_digest = base64.urlsafe_b64encode(digest).decode("ascii") return ( f"scrypt${self.work_factor}${self.block_size}${self.parallelism}" f"${encoded_salt}${encoded_digest}" ) def verify(self, password: str, encoded: str) -> bool: try: scheme, work_factor, block_size, parallelism, salt, expected = encoded.split("$") if scheme != "scrypt": return False digest = hashlib.scrypt( password.encode("utf-8"), salt=base64.urlsafe_b64decode(salt.encode("ascii")), n=int(work_factor), r=int(block_size), p=int(parallelism), dklen=self.key_bytes, maxmem=64 * 1024 * 1024, ) return hmac.compare_digest(base64.urlsafe_b64encode(digest).decode("ascii"), expected) except (TypeError, ValueError): return False