from __future__ import annotations import base64 import hashlib import hmac import secrets import struct from urllib.parse import quote def generate_totp_secret(length: int = 20) -> str: return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=') def normalize_totp_token(value: str | None) -> str: return ''.join(ch for ch in (value or '').strip() if ch.isdigit()) def _secret_bytes(secret: str) -> bytes: padded = secret.strip().replace(' ', '').upper() padding = '=' * ((8 - len(padded) % 8) % 8) return base64.b32decode(padded + padding, casefold=True) def generate_totp_token(secret: str, for_time: int, *, digits: int = 6, period: int = 30) -> str: counter = int(for_time // period) key = _secret_bytes(secret) msg = struct.pack('>Q', counter) digest = hmac.new(key, msg, hashlib.sha1).digest() offset = digest[-1] & 0x0F code_int = struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF return str(code_int % (10**digits)).zfill(digits) def verify_totp_token(secret: str, token: str, *, for_time: int, digits: int = 6, period: int = 30, window: int = 1) -> bool: normalized = normalize_totp_token(token) if len(normalized) != digits: return False for offset in range(-window, window + 1): candidate_time = for_time + (offset * period) if generate_totp_token(secret, candidate_time, digits=digits, period=period) == normalized: return True return False def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str: label = quote(f'{issuer}:{account_name}') issuer_q = quote(issuer) return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30'