"""SOC opaque token generation + verification. Decision D-006: clear token returned once at creation, bcrypt hash persisted. """ from __future__ import annotations import hmac import secrets from dataclasses import dataclass import bcrypt _TOKEN_BYTES = 32 # 256 bits of entropy _DEFAULT_ROUNDS = 12 @dataclass(frozen=True, slots=True) class SocTokenMaterial: plain: str hashed: str def generate_token(*, rounds: int = _DEFAULT_ROUNDS) -> SocTokenMaterial: """Generate a fresh SOC token and its bcrypt hash.""" plain = secrets.token_urlsafe(_TOKEN_BYTES) salt = bcrypt.gensalt(rounds=rounds) hashed = bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8") return SocTokenMaterial(plain=plain, hashed=hashed) def verify_token(plain: str, hashed: str) -> bool: """Constant-time bcrypt verification of a SOC token.""" if not plain or not hashed: return False try: return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) except (ValueError, TypeError): return False def safe_compare(a: str, b: str) -> bool: """Constant-time string compare for non-hashed contexts.""" return hmac.compare_digest(a, b)