"""TOTP two-factor helpers: secrets, QR enrollment, code checks, recovery codes. Recovery codes are random (80 bits each), so a fast sha256 digest is enough at rest — unlike passwords they can't be dictionary-attacked. Each code is one-time: a successful match removes its hash from the stored list. """ import hashlib import io import json import secrets import pyotp import qrcode import qrcode.image.svg ISSUER = "Ten31 Portal" RECOVERY_CODE_COUNT = 8 def new_secret() -> str: return pyotp.random_base32() def otpauth_uri(secret: str, account_name: str) -> str: return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=ISSUER) def qr_svg(uri: str) -> str: """The enrollment QR as a standalone SVG document (no raster deps needed).""" img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=14) buf = io.BytesIO() img.save(buf) return buf.getvalue().decode() def verify_code(secret: str, code: str) -> bool: # valid_window=1 accepts the neighbouring 30s steps, tolerating clock drift. return pyotp.TOTP(secret).verify(code.strip().replace(" ", ""), valid_window=1) def _normalize_recovery(code: str) -> str: return code.strip().replace("-", "").replace(" ", "").lower() def _digest(code: str) -> str: return hashlib.sha256(_normalize_recovery(code).encode()).hexdigest() def generate_recovery_codes() -> tuple[list[str], str]: """Return (plaintext codes to show once, JSON of their digests to store).""" codes = [] for _ in range(RECOVERY_CODE_COUNT): raw = secrets.token_hex(10) # 20 hex chars, 80 bits codes.append(f"{raw[:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:]}") return codes, json.dumps([_digest(c) for c in codes]) def consume_recovery_code(stored_json: str | None, code: str) -> str | None: """If ``code`` matches an unused recovery code, return the updated JSON without it. Returns None when the code doesn't match (or none are stored). """ if not stored_json: return None digests: list[str] = json.loads(stored_json) d = _digest(code) if d not in digests: return None digests.remove(d) return json.dumps(digests)