"""Passwords and tokens. Passwords are hashed with scrypt from the standard library. Stored format: scrypt$$$

$$. The parameters are stored with the hash, so they can be raised later without invalidating existing passwords. Access tokens are short-lived JWTs (HS256). Refresh tokens are opaque random strings, only their hash is stored in the database. """ import base64 import hashlib import hmac import secrets from datetime import datetime, timedelta, timezone import jwt from app.config import settings _N, _R, _P = 2**14, 8, 1 _KEY_LENGTH = 32 JWT_ALGORITHM = "HS256" def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes: return hashlib.scrypt( password.encode(), salt=salt, n=n, r=r, p=p, dklen=_KEY_LENGTH, maxmem=128 * n * r * 2, ) def hash_password(password: str) -> str: salt = secrets.token_bytes(16) key = _derive(password, salt, _N, _R, _P) encode = lambda value: base64.b64encode(value).decode() # noqa: E731 return f"scrypt${_N}${_R}${_P}${encode(salt)}${encode(key)}" def verify_password(password: str, stored: str) -> bool: try: scheme, n, r, p, salt, key = stored.split("$") if scheme != "scrypt": return False expected = base64.b64decode(key) actual = _derive(password, base64.b64decode(salt), int(n), int(r), int(p)) except ValueError: return False return hmac.compare_digest(actual, expected) # Verified when the email is unknown, so that a login takes the same time either way DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) def create_access_token(user_id: int, role: str) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "role": role, "iat": now, "exp": now + timedelta(minutes=settings.ACCESS_TOKEN_TTL_MINUTES), } return jwt.encode(payload, settings.JWT_SECRET, algorithm=JWT_ALGORITHM) def decode_access_token(token: str) -> dict: """Payload of a valid access token, jwt.PyJWTError otherwise (bad signature, expired...).""" return jwt.decode( token, settings.JWT_SECRET, algorithms=[JWT_ALGORITHM], options={"require": ["sub", "exp"]}, ) def new_refresh_token() -> str: return secrets.token_urlsafe(48) def hash_token(token: str) -> str: # The token is random with 384 bits of entropy: a plain fast hash is enough return hashlib.sha256(token.encode()).hexdigest()