2026-06-26 03:15:36 +02:00
|
|
|
import hashlib
|
|
|
|
|
|
2026-06-19 14:03:11 +02:00
|
|
|
from argon2 import PasswordHasher
|
2026-06-11 01:01:37 +02:00
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
|
|
|
|
|
2026-06-19 14:03:11 +02:00
|
|
|
# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
|
|
|
|
|
_ph = PasswordHasher()
|
|
|
|
|
|
|
|
|
|
|
2026-06-26 03:15:36 +02:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 14:03:11 +02:00
|
|
|
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 Exception:
|
|
|
|
|
# Any failure (mismatch, malformed hash) is a non-match — never raise to the caller.
|
|
|
|
|
return False
|
|
|
|
|
|
2026-06-11 01:01:37 +02:00
|
|
|
_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()
|
2026-07-11 21:26:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
return None
|