"""Password hashing and opaque token generation. CLAUDE.md invariant #5: secrets never leave the server. Nothing in this file is ever included in a Pydantic response model — that's enforced by schemas/auth.py simply not declaring these fields, not by anything here, so double-check any new response schema doesn't accidentally add one back. """ import hashlib import secrets from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError # t=3, m=64MiB, p=4 — matches docs/PLAN.md's stated parameters exactly. _hasher = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4) def hash_password(password: str) -> str: return _hasher.hash(password) def verify_password(password: str, password_hash: str) -> bool: try: return _hasher.verify(password_hash, password) except VerifyMismatchError: return False def generate_token() -> tuple[str, bytes]: """Return (opaque_token_to_hand_to_the_client, sha256_digest_to_store). The raw token is returned to the caller exactly once and is never persisted anywhere — only its digest is stored, so a database leak doesn't hand out working session tokens. """ raw = secrets.token_urlsafe(32) digest = hashlib.sha256(raw.encode("ascii")).digest() return raw, digest def hash_token(raw_token: str) -> bytes: """Recompute the digest of a client-presented token, for lookup by token_hash.""" return hashlib.sha256(raw_token.encode("ascii")).digest() def hash_invite_code(code: str) -> bytes: return hashlib.sha256(code.encode("ascii")).digest()