from __future__ import annotations import base64 import hashlib import hmac import os import secrets _ALGORITHM = "pbkdf2_sha256" _DEFAULT_ITERATIONS = 260_000 _SALT_BYTES = 16 def new_public_credential() -> str: """Create a URL-safe credential with at least 256 bits of entropy.""" return secrets.token_urlsafe(32) def public_credential_hash(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def verify_public_credential(value: str, expected_hash: str | None) -> bool: if not expected_hash: return False return hmac.compare_digest(public_credential_hash(value), expected_hash) def hash_participant_password( password: str, *, iterations: int = _DEFAULT_ITERATIONS, ) -> str: """Hash a public-participant access password for durable storage.""" salt = os.urandom(_SALT_BYTES) digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, iterations, ) return "$".join( ( _ALGORITHM, str(iterations), base64.b64encode(salt).decode("ascii"), base64.b64encode(digest).decode("ascii"), ) ) def verify_participant_password(password: str, encoded: str | None) -> bool: """Verify a participant password without exposing the stored hash.""" if not encoded: return False try: algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3) if algorithm != _ALGORITHM: return False iterations = int(iterations_text) salt = base64.b64decode(salt_b64.encode("ascii"), validate=True) expected = base64.b64decode(digest_b64.encode("ascii"), validate=True) except (TypeError, ValueError): return False actual = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, iterations, ) return hmac.compare_digest(actual, expected) __all__ = [ "hash_participant_password", "new_public_credential", "public_credential_hash", "verify_participant_password", "verify_public_credential", ]