import base64 import hashlib import hmac import secrets import struct import time import urllib.parse def generate_totp_secret(length: int = 20) -> str: raw = secrets.token_bytes(length) return base64.b32encode(raw).decode("ascii").rstrip("=") def build_otpauth_uri(*, secret: str, account_name: str, issuer: str) -> str: label = urllib.parse.quote(f"{issuer}:{account_name}") issuer_q = urllib.parse.quote(issuer) secret_q = urllib.parse.quote(secret) return f"otpauth://totp/{label}?secret={secret_q}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30" def _totp_code(secret: str, for_time: int | None = None, step: int = 30, digits: int = 6) -> str: now = int(time.time()) if for_time is None else int(for_time) counter = now // step padded = secret + "=" * ((8 - len(secret) % 8) % 8) key = base64.b32decode(padded, casefold=True) msg = struct.pack(">Q", counter) digest = hmac.new(key, msg, hashlib.sha1).digest() offset = digest[-1] & 0x0F binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF return str(binary % (10**digits)).zfill(digits) def verify_totp_code(secret: str, code: str, *, window: int = 1, step: int = 30, digits: int = 6) -> bool: prepared = "".join(ch for ch in code if ch.isdigit()) if len(prepared) != digits: return False now = int(time.time()) for delta in range(-window, window + 1): expected = _totp_code(secret, for_time=now + delta * step, step=step, digits=digits) if secrets.compare_digest(expected, prepared): return True return False