""" TOTP (Time-based One-Time Password) generation for Bitwarden vault items. """ import hashlib import hmac import struct import time from typing import Optional # Try to use pyotp if available, otherwise use manual implementation try: import pyotp HAS_PYOTP = True except ImportError: HAS_PYOTP = False def generate_totp(secret: str, period: int = 30, digits: int = 6) -> str: """Generate a TOTP code from a secret.""" if not secret: return "" # Clean the secret (remove spaces, convert to uppercase) secret = secret.replace(" ", "").upper() # Try to parse as otpauth:// URI if secret.startswith("otpauth://"): parsed = _parse_otpauth_uri(secret) if parsed: secret = parsed["secret"] period = parsed.get("period", period) digits = parsed.get("digits", digits) if HAS_PYOTP: totp = pyotp.TOTP(secret, interval=period, digits=digits) return totp.now() return _generate_totp_manual(secret, period, digits) def get_totp_remaining_seconds(period: int = 30) -> int: """Get seconds remaining until current TOTP code expires.""" return period - (int(time.time()) % period) def _generate_totp_manual(secret: str, period: int, digits: int) -> str: """Manual TOTP implementation when pyotp is not available.""" # Decode base32 secret _BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" secret = secret.upper() # Remove padding padding_needed = (8 - len(secret) % 8) % 8 secret += "=" * padding_needed # Decode base32 binary = b"" for char in secret: if char == "=": continue try: val = _BASE32_CHARS.index(char) except ValueError: continue binary += struct.pack(">B", val) # Time counter counter = int(time.time()) // period counter_bytes = struct.pack(">Q", counter) # HMAC-SHA1 hmac_result = hmac.new(binary, counter_bytes, hashlib.sha1).digest() # Dynamic truncation offset = hmac_result[-1] & 0x0F truncated = struct.unpack( ">I", hmac_result[offset : offset + 4] )[0] truncated &= 0x7FFFFFFF # Generate code code = truncated % (10 ** digits) return str(code).zfill(digits) def _parse_otpauth_uri(uri: str) -> Optional[dict]: """Parse an otpauth:// URI.""" # otpauth://totp/Label?secret=XXX&issuer=XXX&period=30&digits=6 if not uri.startswith("otpauth://"): return None parts = uri.split("?", 1) if len(parts) < 2: return None params = {} for param in parts[1].split("&"): key, _, value = param.partition("=") params[key] = value secret = params.get("secret", "") return { "secret": secret, "issuer": params.get("issuer", ""), "period": int(params.get("period", "30")), "digits": int(params.get("digits", "6")), }