"""TOTP (RFC 6238) und einmalige Wiederherstellungscodes — nur Standardbibliothek.""" from __future__ import annotations import base64 import hashlib import hmac import secrets import struct import time from typing import Optional from urllib.parse import quote def generate_totp_secret() -> str: return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") def _decode_secret(secret: str) -> bytes: raw = "".join(ch for ch in (secret or "").upper() if ch.isalnum()) pad = "=" * ((8 - len(raw) % 8) % 8) return base64.b32decode(raw + pad, casefold=True) def totp_code(secret: str, counter: int) -> str: digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest() offset = digest[-1] & 0x0F binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF return f"{binary % 1_000_000:06d}" def verify_totp(secret: Optional[str], code: str, *, window: int = 1, now: Optional[int] = None) -> bool: if not secret: return False digits = "".join(ch for ch in (code or "") if ch.isdigit()) if len(digits) != 6: return False moment = int(time.time() if now is None else now) counter = moment // 30 for delta in range(-window, window + 1): if hmac.compare_digest(totp_code(secret, counter + delta), digits): return True return False def otpauth_uri(*, secret: str, account_name: str, issuer: str = "Shinkan Jinkendo") -> str: label = quote(f"{issuer}:{account_name}") return ( f"otpauth://totp/{label}?secret={quote(secret)}" f"&issuer={quote(issuer)}&algorithm=SHA1&digits=6&period=30" ) def generate_recovery_codes(count: int = 8) -> list[str]: codes = [] for _ in range(count): raw = secrets.token_hex(4) codes.append(f"{raw[:4]}-{raw[4:]}") return codes def hash_recovery_code(code: str) -> str: normalized = "".join(ch for ch in (code or "").lower() if ch.isalnum()) return hashlib.sha256(normalized.encode("utf-8")).hexdigest()