From a71c5438ce9004ce1a2a776b4d196f3fe5fe78f9 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 12:34:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20hashlib.scrypt=20=E5=9C=A8=20Libre?= =?UTF-8?q?SSL=20=E6=9E=84=E5=BB=BA=E4=B8=8B=E7=BC=BA=E5=A4=B1=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E6=B3=A8=E5=86=8C=E8=BF=94=E5=9B=9E=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 自带 Python 3.9 链接 LibreSSL,hashlib 不提供 scrypt, register 调用 hash_password 时抛 AttributeError,被 Flask 兜成 500 internal error(登录随之全部失败)。 改用 PBKDF2-HMAC-SHA256(每个 CPython 构建都有),并采用自描述的 存储格式 pbkdf2_sha256$iterations$salt$hash,便于日后迁移算法。 verify_password 仍兼容旧的 salt:hash scrypt 格式(当解释器支持时)。 Co-Authored-By: Claude Haiku 4.5 --- backend/auth.py | 73 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/backend/auth.py b/backend/auth.py index edb2a94..c03b2ef 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1,9 +1,17 @@ """ -Authentication helpers: scrypt password hashing, JWT signing/verification, +Authentication helpers: password hashing, JWT signing/verification, and the require_auth decorator used by route blueprints. -Password hashing mirrors the original Node implementation exactly: - salt (16 random bytes, hex) : scrypt(password, salt, n=16384, r=8, p=1, dklen=64, hex) +Hashes are stored in a self-describing format so the algorithm can be +migrated later without invalidating existing rows: + + pbkdf2_sha256$$$ + +PBKDF2-HMAC-SHA256 is used because it is available in every CPython build. +`hashlib.scrypt` is NOT: builds linked against LibreSSL (notably the system +Python on macOS) omit it, which made registration fail with a 500. +Legacy `:` scrypt hashes are still verified when the +running interpreter supports scrypt. """ import hashlib import hmac @@ -16,6 +24,11 @@ from functools import wraps from config import JWT_SECRET, JWT_EXPIRY_DAYS +PBKDF2_ITERATIONS = 200_000 +PBKDF2_PREFIX = "pbkdf2_sha256" +_SALT_BYTES = 16 +_DK_LEN = 64 + class AuthError(Exception): def __init__(self, code, message): @@ -24,21 +37,21 @@ class AuthError(Exception): self.message = message +def _pbkdf2(password: str, salt: bytes, iterations: int) -> str: + return hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, iterations, dklen=_DK_LEN + ).hex() + + def hash_password(password: str) -> str: - salt = os.urandom(16) - derived = hashlib.scrypt( - password.encode("utf-8"), - salt=salt, - n=16384, - r=8, - p=1, - dklen=64, - ) - return f"{salt.hex()}:{derived.hex()}" + salt = os.urandom(_SALT_BYTES) + derived = _pbkdf2(password, salt, PBKDF2_ITERATIONS) + return f"{PBKDF2_PREFIX}${PBKDF2_ITERATIONS}${salt.hex()}${derived}" -def verify_password(password: str, stored: str) -> bool: - if not stored or ":" not in stored: +def _verify_legacy_scrypt(password: str, stored: str) -> bool: + """Verify a pre-migration `salt_hex:hash_hex` scrypt hash, if supported.""" + if not hasattr(hashlib, "scrypt"): return False salt_hex, hash_hex = stored.split(":", 1) try: @@ -46,14 +59,28 @@ def verify_password(password: str, stored: str) -> bool: except ValueError: return False derived = hashlib.scrypt( - password.encode("utf-8"), - salt=salt, - n=16384, - r=8, - p=1, - dklen=64, - ) - return hmac.compare_digest(derived.hex(), hash_hex) + password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=_DK_LEN + ).hex() + return hmac.compare_digest(derived, hash_hex) + + +def verify_password(password: str, stored: str) -> bool: + if not stored: + return False + + if stored.startswith(PBKDF2_PREFIX + "$"): + try: + _, iterations, salt_hex, hash_hex = stored.split("$", 3) + salt = bytes.fromhex(salt_hex) + derived = _pbkdf2(password, salt, int(iterations)) + except (ValueError, TypeError): + return False + return hmac.compare_digest(derived, hash_hex) + + if ":" in stored: + return _verify_legacy_scrypt(password, stored) + + return False def sign_token(user_id: str) -> str: