108 lines
2.9 KiB
Python
108 lines
2.9 KiB
Python
"""
|
|
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")),
|
|
}
|