45 lines
1.2 KiB
Python
45 lines
1.2 KiB
Python
|
|
"""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)
|