diff --git a/src/password_manager/totp.py b/src/password_manager/totp.py new file mode 100644 index 0000000..4e50e22 --- /dev/null +++ b/src/password_manager/totp.py @@ -0,0 +1,61 @@ +"""TOTP management utilities for SeedPass.""" + +from __future__ import annotations + +import sys +import time +from urllib.parse import quote + +import pyotp + +from utils import key_derivation + + +class TotpManager: + """Helper methods for TOTP secrets and codes.""" + + @staticmethod + def derive_secret(seed: str, index: int) -> str: + """Derive a TOTP secret from a BIP39 seed and index.""" + return key_derivation.derive_totp_secret(seed, index) + + @classmethod + def current_code(cls, seed: str, index: int, timestamp: int | None = None) -> str: + """Return the TOTP code for the given seed and index.""" + secret = cls.derive_secret(seed, index) + totp = pyotp.TOTP(secret) + if timestamp is None: + return totp.now() + return totp.at(timestamp) + + @staticmethod + def make_otpauth_uri( + label: str, secret: str, period: int = 30, digits: int = 6 + ) -> str: + """Construct an otpauth:// URI for use with authenticator apps.""" + label_enc = quote(label) + return f"otpauth://totp/{label_enc}?secret={secret}&period={period}&digits={digits}" + + @staticmethod + def time_remaining(period: int = 30, timestamp: int | None = None) -> int: + """Return seconds remaining until the current TOTP period resets.""" + if timestamp is None: + timestamp = int(time.time()) + return period - (timestamp % period) + + @classmethod + def print_progress_bar(cls, period: int = 30) -> None: + """Print a simple progress bar for the current TOTP period.""" + remaining = cls.time_remaining(period) + total = period + bar_len = 20 + while remaining > 0: + progress = total - remaining + filled = int(bar_len * progress / total) + bar = "[" + "#" * filled + "-" * (bar_len - filled) + "]" + sys.stdout.write(f"\r{bar} {remaining:2d}s") + sys.stdout.flush() + time.sleep(1) + remaining -= 1 + sys.stdout.write("\n") + sys.stdout.flush() diff --git a/src/requirements.txt b/src/requirements.txt index 9af5f20..d7b2add 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -18,3 +18,4 @@ websockets>=15.0.0 tomli hypothesis mutmut==2.4.4 +pyotp>=2.8.0 diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index 837cd5c..8c164dc 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -152,3 +152,19 @@ def derive_index_key_seed_only(seed: str) -> bytes: def derive_index_key(seed: str) -> bytes: """Derive the index encryption key.""" return derive_index_key_seed_only(seed) + + +def derive_totp_secret(seed: str, index: int) -> str: + """Derive a base32-encoded TOTP secret from a BIP39 seed.""" + try: + from local_bip85 import BIP85 + + seed_bytes = Bip39SeedGenerator(seed).Generate() + bip85 = BIP85(seed_bytes) + entropy = bip85.derive_entropy(index=index, bytes_len=10, app_no=2) + secret = base64.b32encode(entropy).decode("utf-8") + logger.debug(f"Derived TOTP secret for index {index}: {secret}") + return secret + except Exception as e: + logger.error(f"Failed to derive TOTP secret: {e}", exc_info=True) + raise