""" TOTP-Enrollment und -Verifikation (RFC 6238) inkl. Recovery-Codes. Pflicht-2FA: siehe Konzept 4.5 / 6.2. Das TOTP-Secret wird mit einem eigenen AAD-Kontext ("totp") verschluesselt gespeichert -- Schluesseltrennung vom SSH-Key-Material ist ueber den associated_data-Parameter realisiert (beide nutzen zwar denselben KEK, sind aber durch AAD kontextgebunden und nicht gegeneinander austauschbar). """ from __future__ import annotations import hashlib import secrets import pyotp from app.security.crypto import decrypt_secret, encrypt_secret _TOTP_AAD = b"totp_secret" def generate_totp_secret() -> str: return pyotp.random_base32() def encrypt_totp_secret(secret: str) -> bytes: return encrypt_secret(secret.encode(), associated_data=_TOTP_AAD) def decrypt_totp_secret(blob: bytes) -> str: return decrypt_secret(blob, associated_data=_TOTP_AAD).decode() def provisioning_uri(secret: str, username: str, issuer: str = "Jumphost") -> str: return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer) def verify_totp_code(secret: str, code: str) -> bool: """Verifiziert mit +-1 Zeitfenster Toleranz gegen Clock-Drift.""" totp = pyotp.TOTP(secret) return totp.verify(code, valid_window=1) def generate_recovery_codes(count: int = 10) -> list[str]: """Erzeugt Einmal-Recovery-Codes im Klartext (nur zur einmaligen Anzeige).""" return [secrets.token_hex(5) for _ in range(count)] def hash_recovery_code(code: str) -> str: # Recovery-Codes sind hochentropisch (40 Bit hex) -- ein schneller, # gesalzener Hash reicht hier aus; dennoch SHA-256 mit Pfeffer aus KEK-Kontext. return hashlib.sha256(code.encode() + b"recovery_code_pepper").hexdigest()