import hashlib from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from cryptography.fernet import Fernet from app import logs from app.config import settings log = logs.get_logger(__name__) # argon2id with the library defaults (sensible memory/time cost). One shared hasher instance. _ph = PasswordHasher() def hash_token(raw: str) -> str: """SHA-256 hex digest for storing single-use tokens (email-link tokens, the setup token) by hash rather than in cleartext. Not a password hash — these are high-entropy randoms.""" return hashlib.sha256(raw.encode()).hexdigest() def hash_password(password: str) -> str: return _ph.hash(password) def verify_password(password: str, hashed: str | None) -> bool: if not hashed: return False try: return _ph.verify(hashed, password) except VerifyMismatchError: return False # silent-ok: a wrong password. The commonest path through this function. except Exception as exc: # noqa: BLE001 — never raise to the caller, whatever went wrong # Everything else is a hash this process cannot USE — truncated in the column, written by # a different argon2 build. It is answered as "wrong password", so the owner of that # account is locked out by a bug and told they mistyped. log.warning( "a stored password hash could not be verified", extra=logs.event("auth.password.hash_unusable", reason=type(exc).__name__), ) return False _fernet: Fernet | None = ( Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None ) def _require_fernet() -> Fernet: if _fernet is None: raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured") return _fernet def encrypt(value: str | None) -> str | None: if value is None: return None return _require_fernet().encrypt(value.encode()).decode() def decrypt(value: str | None) -> str | None: if value is None: return None return _require_fernet().decrypt(value.encode()).decode() def decrypt_optional(value: str | None) -> str | None: """Like decrypt() but returns None instead of raising when the value is absent or not a valid ciphertext (e.g. a legacy plaintext token stored before encryption) — so callers fall back to a refresh rather than erroring.""" if not value: return None try: return _require_fernet().decrypt(value.encode()).decode() except Exception as exc: # noqa: BLE001 — callers fall back to a refresh, never to an error # Info rather than warning because the docstring's legacy-plaintext case comes through # here too and is harmless. What is NOT harmless looks identical: a rotated # TOKEN_ENCRYPTION_KEY turns every stored token unreadable at once, and every account # then reads as "not connected" with nothing saying the key is the reason. log.info( "a stored ciphertext could not be decrypted", extra=logs.event("security.decrypt.failed", reason=type(exc).__name__), ) return None